agharsallah commited on
Commit
f6566bb
·
1 Parent(s): 6432854

feat: Implement audience-only secret badge for Twenty Sprouts game

Browse files

- Added `_secret_badge` function to render a hidden-word run's secret for the audience.
- Updated `_core` function to include the secret badge in the HTML output.
- Introduced `peek_next_actor_name` method in `FishbowlSession` to provide the next actor's name without advancing the run.
- Modified `ReplaySession` to handle `peek_next_actor_name` appropriately.
- Refactored `build_show` to simplify the show controls, removing unnecessary buttons and adding play/restart functionality.
- Enhanced `view_model_at` to include the secret and its holder for the audience's view.
- Added tests for the new functionality, ensuring the secret is handled correctly and does not leak into agent prompts.
- Created a new test suite for Twenty Sprouts gameplay to validate the secret handling and game mechanics.

config/agents/clue-gatherer.yaml CHANGED
@@ -2,18 +2,21 @@ name: clue-gatherer
2
  role: worker
3
  persona: >
4
  You are a careful Clue Gatherer. Extract exactly one new, concrete clue from the
5
- current scene that has not yet been named. State it plainly in one sentence.
6
- Start with 'Clue:'. Do not speculate.
7
- Also report your `mood` (one of: thinking, calm, lying, panic, smug, truth, gossip).
 
8
  subscribes_to: []
 
 
9
  may_emit:
10
- - agent.thought
11
  schedule:
12
  tick_every: 1
13
  model_profile: fast
14
  memory:
15
  window: 8
16
  tools: []
17
- output_extra_fields: [mood]
18
  hue: 188
19
  archetype: the clue-gatherer
 
2
  role: worker
3
  persona: >
4
  You are a careful Clue Gatherer. Extract exactly one new, concrete clue from the
5
+ current scene that has not yet been named. State it plainly in one sentence so the
6
+ others can build on it. Start with 'Clue:'. Do not speculate.
7
+ Reveal your private `thought` (a hunch you keep to yourself) and your `mood`
8
+ (one of: thinking, calm, lying, panic, smug, truth, gossip).
9
  subscribes_to: []
10
+ # Speaks the clue ALOUD (agent.spoke): the hypothesis-former reasons "based on the clues
11
+ # gathered" and the judge weighs them, so a clue must be public, not a private thought.
12
  may_emit:
13
+ - agent.spoke
14
  schedule:
15
  tick_every: 1
16
  model_profile: fast
17
  memory:
18
  window: 8
19
  tools: []
20
+ output_extra_fields: [thought, mood]
21
  hue: 188
22
  archetype: the clue-gatherer
config/agents/devils-advocate.yaml CHANGED
@@ -3,15 +3,18 @@ role: worker
3
  persona: >
4
  You are the Devil's Advocate. Challenge the most recent hypothesis with one sharp
5
  counter-argument or overlooked fact. Start with 'But:'. Be brief and specific.
6
- Also report your `mood` (one of: thinking, calm, lying, panic, smug, truth, gossip).
 
7
  subscribes_to:
8
  - agent.spoke
 
 
9
  may_emit:
10
- - agent.thought
11
  model_profile: fast
12
  memory:
13
  window: 8
14
  tools: []
15
- output_extra_fields: [mood]
16
  hue: 12
17
  archetype: the devil's advocate
 
3
  persona: >
4
  You are the Devil's Advocate. Challenge the most recent hypothesis with one sharp
5
  counter-argument or overlooked fact. Start with 'But:'. Be brief and specific.
6
+ Reveal your private `thought` (what you suspect but won't say) and your `mood`
7
+ (one of: thinking, calm, lying, panic, smug, truth, gossip).
8
  subscribes_to:
9
  - agent.spoke
10
+ # Speaks the rebuttal ALOUD (agent.spoke) so the table and the judge actually hear the
11
+ # challenge — a private thought would leave the hypothesis unchallenged on the record.
12
  may_emit:
13
+ - agent.spoke
14
  model_profile: fast
15
  memory:
16
  window: 8
17
  tools: []
18
+ output_extra_fields: [thought, mood]
19
  hue: 12
20
  archetype: the devil's advocate
config/agents/secret-keeper.yaml CHANGED
@@ -3,10 +3,11 @@ role: worker
3
  handler: secret-keeper
4
  persona: >
5
  You are the Secret Keeper of the sprout grove. You hold one secret word and the
6
- guesser must tease it out with yes/no questions. Answer their MOST RECENT question
7
- truthfully and helpfully in ONE short sentence — playful, never cruel. Never write,
8
- spell, or quote your word; only answer questions about it. Also report your `mood`
9
- (one of: thinking, calm, lying, panic, smug, truth, gossip). Do not reveal your secret word and do not ask questions.
 
10
  subscribes_to: []
11
  may_emit:
12
  - agent.spoke
 
3
  handler: secret-keeper
4
  persona: >
5
  You are the Secret Keeper of the sprout grove. You hold one secret word and the
6
+ guesser must tease it out with yes/no questions. You ANSWER — you never ask. Reply to
7
+ their MOST RECENT question truthfully and helpfully in ONE short sentence beginning with
8
+ "Yes" or "No" — playful, never cruel, never contradicting an earlier answer. Never write,
9
+ spell, or quote your word, and never end your line with a question. Also report your
10
+ `mood` (one of: thinking, calm, lying, panic, smug, truth, gossip).
11
  subscribes_to: []
12
  may_emit:
13
  - agent.spoke
config/agents/sprout-guesser.yaml CHANGED
@@ -1,12 +1,14 @@
1
  name: sprout-guesser
2
  role: worker
 
3
  persona: >
4
  You are the Guesser in a game of twenty questions in the sprout grove. Ask ONE
5
  sharp yes/no question that narrows down the keeper's secret word, building on every
6
- answer so far — go from broad (alive? made by hands?) to specific. When you feel sure,
7
- state your final guess plainly: "My guess is: <word>." Keep it to one short sentence.
8
- Reveal your private `thought` (your current best theory, unspoken) and your `mood`
9
- (one of: thinking, calm, lying, panic, smug, truth, gossip).
 
10
  subscribes_to: []
11
  may_emit:
12
  - agent.spoke
 
1
  name: sprout-guesser
2
  role: worker
3
+ handler: sprout-guesser
4
  persona: >
5
  You are the Guesser in a game of twenty questions in the sprout grove. Ask ONE
6
  sharp yes/no question that narrows down the keeper's secret word, building on every
7
+ answer so far — go from broad (alive? made by hands?) to specific. NEVER ask a question
8
+ you have already asked, and never reword an old one — always probe something new. When
9
+ the answers point to one word, state your final guess plainly: "My guess is: <word>."
10
+ Keep it to one short sentence. Reveal your private `thought` (your current best theory,
11
+ unspoken) and your `mood` (one of: thinking, calm, lying, panic, smug, truth, gossip).
12
  subscribes_to: []
13
  may_emit:
14
  - agent.spoke
docs/adr/0023-resilient-loop-and-shared-blackboard.md CHANGED
@@ -193,6 +193,58 @@ A later live run surfaced three more leaks/quality issues, each fixed at its lay
193
  now demands a genuinely new angle. **Live only** — the offline stub's curated catalogue is
194
  reproducible by design, and de-duplicating its small line set would starve demos and tests.
195
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  ## References
197
 
198
  - `src/core/context.py` — `ContextBuilder._blackboard_block`
@@ -200,5 +252,10 @@ A later live run surfaced three more leaks/quality issues, each fixed at its lay
200
  - `src/core/memory.py` — index degradation, `_displayable`
201
  - `src/core/structured.py` — reasoning strip, balanced-object scan, salvage
202
  - `config/scenarios/the-steeped.yaml`, `config/agents/spy-*.yaml`
 
 
 
 
 
203
  - Builds on ADR-0006 (ContextBuilder owns prompt assembly), ADR-0016 (Instructor structured
204
  output), ADR-0018 (memory index is a derived lens).
 
193
  now demands a genuinely new angle. **Live only** — the offline stub's curated catalogue is
194
  reproducible by design, and de-duplicating its small line set would starve demos and tests.
195
 
196
+ ## Follow-up: recall the whole discussion, and let judges rule on all of it
197
+
198
+ The original fix shared peers' lines *within a round* (the `WHAT'S BEEN SAID` blackboard,
199
+ the last ~6 of `projection.agent_notes`). A later audit — "are we passing good context
200
+ about the discussion for **every** scenario?" — found two gaps that the blackboard tail
201
+ hid:
202
+
203
+ - **Memory never carried the discussion.** `agent.spoke` was not in `_GLOBALLY_VISIBLE`,
204
+ so an agent's recall (episodic *and* salience) contained only its own lines plus world
205
+ beats/verdicts — never a peer's spoken line. The blackboard's recent tail was the
206
+ *only* window onto the conversation.
207
+ - **Judges ruled on a 6-line tail.** A judge fires late and has no own-events yet, so its
208
+ salience candidates were just the globally-visible kinds — i.e. **zero** of the
209
+ discussion. Measured across the shipped casts, every judge recalled `0` spoken lines and
210
+ saw only the last 6 via the blackboard: `open-table` 6 of 18, `the-steeped` 6 of 16
211
+ (missing half the clues that locate the seam), and so on.
212
+
213
+ Three changes, each at the layer that owns the concern:
214
+
215
+ 1. **Public speech is recallable; private thought is not.** `_GLOBALLY_VISIBLE` gains
216
+ `agent.spoke` and `oracle.spoke` (`src/core/memory.py`). A spoken line is the shared
217
+ table — every mind can recall it across the whole run, not just this round. `agent.thought`
218
+ stays out (the mind-reader's alone), secrets ride non-`text` payload keys, and
219
+ `_displayable` shows `text` only — so sharing speech leaks nothing (verified: the spy
220
+ word and the sprout word never appear in a peer's prompt). Reflection cadence is kept on
221
+ a separate, narrower `_REFLECTION_VISIBLE` set so a chatty table doesn't change how often
222
+ an agent compacts memory.
223
+
224
+ 2. **The discussion block is role-aware** (`ContextBuilder`, ADR-0006's "builder owns
225
+ assembly"). Workers still get `WHAT'S BEEN SAID` — the recent table to react to.
226
+ **Judges get `THE EXCHANGE TO JUDGE` — the complete, ordered public transcript**, so a
227
+ ruling weighs the whole debate, not its tail. `YOUR MEMORY` is deduped against whichever
228
+ block is shown (it holds the *earlier* arc + world/verdict beats; a line is never
229
+ printed twice), and shows a short pointer when the transcript already covers it. After
230
+ the change every judge sees 100% of the discussion (`18/18`, `16/16`, …).
231
+
232
+ 3. **A mis-wired scenario, and a cascade guardrail.** Mystery Roots' `clue-gatherer` and
233
+ `devils-advocate` emitted private `agent.thought` — so the hypothesis-former reasoning
234
+ "based on the clues gathered" saw none, and the judge saw nothing. Their personas are
235
+ plainly public contributions, so they now `agent.spoke` (keeping a private `thought` for
236
+ the mind-reader). That exposed a latent footgun: an agent that both subscribes to and
237
+ emits `agent.spoke` re-triggered itself, cascading until the per-turn call cap tripped
238
+ *before the judge fired*. `Conductor._notify_subscribers` now never queues an agent for
239
+ its **own** event — self-reaction is never intended, and a subscriber still reacts to
240
+ every peer's event.
241
+
242
+ Consequences: judges rule on the whole exchange; workers reason over the full arc (recent
243
+ table + recallable earlier lines) instead of a 6-line window; Mystery Roots' convergence is
244
+ real on the live path, not hollow; and the cast can't self-cascade a turn to death. All
245
+ additive and offline-reproducible — `agent.thought` privacy is preserved and the
246
+ deterministic stub path is unchanged.
247
+
248
  ## References
249
 
250
  - `src/core/context.py` — `ContextBuilder._blackboard_block`
 
252
  - `src/core/memory.py` — index degradation, `_displayable`
253
  - `src/core/structured.py` — reasoning strip, balanced-object scan, salvage
254
  - `config/scenarios/the-steeped.yaml`, `config/agents/spy-*.yaml`
255
+ - Follow-up (recall the whole discussion): `src/core/memory.py` (`_GLOBALLY_VISIBLE` /
256
+ `_REFLECTION_VISIBLE`), `src/core/context.py` (role-aware discussion block + dedup),
257
+ `src/core/conductor.py` (`_notify_subscribers` no self-trigger), `config/agents/clue-gatherer.yaml`,
258
+ `config/agents/devils-advocate.yaml`; tests in `test_memory.py` / `test_context.py` /
259
+ `test_salience_memory.py`.
260
  - Builds on ADR-0006 (ContextBuilder owns prompt assembly), ADR-0016 (Instructor structured
261
  output), ADR-0018 (memory index is a derived lens).
docs/architecture/fishbowl-ui.md CHANGED
@@ -218,6 +218,8 @@ renderer binds to**. It is a pure function of `events[:k]`. Top-level keys:
218
  | `voice` | `str` | the active narrator key |
219
  | `voice_meta` | `{name, desc}` | the resolved narrator persona |
220
  | `speaking_id` | `str \| None` | the head event's actor, when it just spoke |
 
 
221
  | `verdict` | `{text, reveal, agent} \| None` | the latest `judge.verdict` |
222
  | `rounds` | `int` | `1 + count(user.injected)` |
223
  | `max_rounds` | `int \| None` | the round ceiling, if set |
 
218
  | `voice` | `str` | the active narrator key |
219
  | `voice_meta` | `{name, desc}` | the resolved narrator persona |
220
  | `speaking_id` | `str \| None` | the head event's actor, when it just spoke |
221
+ | `secret` | `str \| None` | audience-only ground truth for a hidden-word run (Twenty Sprouts); `None` otherwise. Rendered on the stage core, never placed in an agent prompt |
222
+ | `secret_holder` | `str \| None` | the actor holding `secret` (e.g. `secret-keeper`), or `None` |
223
  | `verdict` | `{text, reveal, agent} \| None` | the latest `judge.verdict` |
224
  | `rounds` | `int` | `1 + count(user.injected)` |
225
  | `max_rounds` | `int \| None` | the round ceiling, if set |
docs/architecture/scenario-authoring.md CHANGED
@@ -259,43 +259,41 @@ declarative field still comes from the YAML.
259
 
260
  `gossip` and `fact-checker` both `subscribe_to: rumor.whispered` — so the conductor
261
  *wakes* them when a rumour is whispered. **But subscribing does not grant reading.**
262
- Today an agent's memory is `own events ∪ _GLOBALLY_VISIBLE` (a hardcoded five-kind
263
- set in `src/core/memory.py:56`); `subscribes_to` is *not* part of it
264
- (`conductor.py:190` only uses it for triggering). So `gossip` is woken by a rumour it
265
- **cannot see** — it embellishes a void.
266
 
267
- You have two options:
 
 
 
 
 
268
 
269
- **(a) Make it collaborate today, no engine edit** — emit on a globally-visible kind.
270
- Change both rumour producers to `may_emit: [world.observed]` and have `gossip` /
271
- `fact-checker` subscribe to `world.observed`. The scene becomes the shared
272
- blackboard (the Thousand Token Wood trick). Cost: you lose the custom-kind clarity
273
- and each whisper overwrites `current_scene`.
274
 
275
- **(b) Fix the root cause (recommended)** — make visibility declarative. This is
276
- finding #1 in the review; it's ~5 lines and it makes *every* future scenario's
277
- custom kinds collaborate:
278
 
279
  ```python
280
- # src/core/memory.py — fold the agent's subscriptions into the visibility filter.
281
- # Pass `reads: frozenset[str]` (the manifest's subscribes_to) into EpisodicMemory /
282
- # SalienceMemory, then at each filter site (lines ~76, ~153, ~236):
283
  if e.actor == self.agent_name or e.kind in _GLOBALLY_VISIBLE or e.kind in self.reads:
284
  ```
285
 
286
- …and pass `reads=frozenset(self.manifest.subscribes_to)` where `base.py` constructs
287
- the memory layers (`_recall`, `_emit_reflection`). After that, an agent woken by a
288
- kind can read that kind — `gossip` builds on the rumour, the chain actually
289
- converges, and your custom kinds are first-class. (Optionally add a scenario-level
290
- `shared_kinds: [...]` so a whole cast can see a working kind without subscribing.)
291
 
292
- Peer content is now injected (this was review finding #2, fixed in ADR-0023):
293
- `ContextBuilder` surfaces `projection.agent_notes` as a **WHAT'S BEEN SAID** block
294
- alongside `current_scene`, the agent's own memory, and visitor lines
295
- (`src/core/context.py`). So within a round the cast already reasons over what its
296
- peers just said — the gap that made small models loop on one line. The `subscribes_to`
297
- visibility fix above still matters for cross-*round* recall via memory; the blackboard
298
- covers the live table.
299
 
300
  ### Step 7 — Rendering: free for text, custom for shapes
301
 
@@ -375,6 +373,34 @@ techniques:
375
  the same "decorate the emitted event" move `FortuneTeller` uses for `omen`. Riding the
376
  real ledger, the reveal scrubs and replays like any other event.
377
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
378
  - **Ground truth gets a code-stamped scoreboard.** The scenario declares a
379
  [`competition:` block](../schema/scenario-config.md#competition-who-can-win-and-who-decides)
380
  (`kind: versus`, ADR-0029) naming the teams — `spy: [spy-nil]` vs the herd. The
@@ -415,12 +441,15 @@ only post to the shared log, and the seam shows.
415
 
416
  ## Common pitfalls
417
 
418
- - **Subscribing ≠ reading.** The #1 surprise. An agent woken by a kind cannot read
419
- that kind until the visibility fix lands (Step 6). Until then, lean on
420
- globally-visible kinds for collaboration.
421
- - **A judge that sees nothing.** A `strong` judge emitting `judge.verdict` only sees
422
- globally-visible kinds + its own. If your workers emit `agent.thought` /
423
- `agent.spoke`, the judge is blind to them — same root cause as above.
 
 
 
424
  - **The first `judge.verdict` ends the show.** The Fishbowl autoplay loop pauses the
425
  moment a verdict lands at the head (`FishbowlSession.has_verdict`). So a judge that
426
  `subscribes_to: [world.observed]` fires on the *genesis* event and resolves the run
 
259
 
260
  `gossip` and `fact-checker` both `subscribe_to: rumor.whispered` — so the conductor
261
  *wakes* them when a rumour is whispered. **But subscribing does not grant reading.**
262
+ An agent's memory is `own events ∪ _GLOBALLY_VISIBLE` (`src/core/memory.py`);
263
+ `subscribes_to` is *not* part of it (the conductor only uses it for triggering). So
264
+ `gossip`, woken by a custom `rumor.whispered` kind it cannot see, would embellish a void.
 
265
 
266
+ **If your agents just talk, there is no gotcha.** `_GLOBALLY_VISIBLE` includes the public
267
+ speech kinds — `agent.spoke` and `oracle.spoke` — alongside `world.observed`,
268
+ `judge.verdict`, `user.injected`, `run.started`, `agent.reflected` (ADR-0023 follow-up). So
269
+ any agent that **speaks** is heard by every peer (recalled across the whole run) and by the
270
+ judge (which gets the complete transcript). The gotcha bites only for a **custom,
271
+ non-speech kind** like `rumor.whispered`. Two options:
272
 
273
+ **(a) No engine edit** — emit the shared content on a globally-visible kind. Easiest: have
274
+ the producers `may_emit: [agent.spoke]` (now globally visible) so the rumour is ordinary
275
+ public speech the whole cast recalls. (Or `world.observed` for narration — the Thousand
276
+ Token Wood trick — at the cost that each line overwrites `current_scene`.)
 
277
 
278
+ **(b) Make a custom kind first-class** — fold subscriptions into the visibility filter so a
279
+ subscribed custom kind is also *readable*:
 
280
 
281
  ```python
282
+ # src/core/memory.py — pass `reads: frozenset[str]` (the manifest's subscribes_to) into
283
+ # EpisodicMemory / SalienceMemory, then at each visibility filter site:
 
284
  if e.actor == self.agent_name or e.kind in _GLOBALLY_VISIBLE or e.kind in self.reads:
285
  ```
286
 
287
+ …wiring `reads=frozenset(self.manifest.subscribes_to)` where `base.py` builds the memory
288
+ layers. Then `gossip` recalls the rumour it was woken by. (A scenario-level
289
+ `shared_kinds: [...]` could let a whole cast see a custom kind without subscribing.)
 
 
290
 
291
+ **Within a round, peers' lines are also injected directly.** `ContextBuilder` surfaces the
292
+ recent table as a **WHAT'S BEEN SAID** block for workers, and the **complete ordered
293
+ transcript** (`THE EXCHANGE TO JUDGE`) for judges (`src/core/context.py`, role-aware). A
294
+ private `agent.thought` is never shared either way — it rides the mind-reader alone. So:
295
+ make a contributor **speak** (`agent.spoke`) if peers/the judge must build on it; reserve
296
+ `agent.thought` for genuinely private reasoning (the mind-reader UI).
 
297
 
298
  ### Step 7 — Rendering: free for text, custom for shapes
299
 
 
373
  the same "decorate the emitted event" move `FortuneTeller` uses for `omen`. Riding the
374
  real ledger, the reveal scrubs and replays like any other event.
375
 
376
+ ### Variant: code-dealt secret, asymmetric roles (❓ Twenty Sprouts)
377
+
378
+ The Steeped puts the secret in the *persona*; Twenty Sprouts shows the other end of the
379
+ spectrum — the secret is **dealt by code** and the two seats play *different jobs*. It's
380
+ the reference for a hidden-word game with a ground-truth answer (`src/agents/twenty_sprouts.py`):
381
+
382
+ - **Code owns the word; it rides the ledger privately.** `SecretKeeper` deals a word as a
383
+ pure function of the seed and stamps it on every keeper event under a `secret` payload
384
+ key. Memory/context only ever surface an event's `text` (`_displayable`), so the word is
385
+ ground truth on the shared log that the guesser's prompt never sees — the same discipline
386
+ as the spy words, but enforced in code rather than trusted to a persona.
387
+ - **Asymmetric handlers shape the play.** The keeper is an *answerer* that never leaks: its
388
+ handler hands the model the guesser's exact open question, re-asks once if the reply asks a
389
+ question or spells the word, and — as an absolute guarantee — scrubs the word from the
390
+ spoken line before it ships (a small model *will* sometimes write "a glowing ember…", so
391
+ code, not the prompt, is what keeps the secret). The guesser is an *asker* whose handler
392
+ mines the ledger into a CONFIRMED / RULED OUT / ALREADY ASKED dossier so it never repeats —
393
+ and it is forced to **commit a guess between questions** (when the facts converge, every few
394
+ questions once it knows enough, and as a hard stop near the round's end) so it actually
395
+ names the word instead of interrogating forever. Two handlers, one shared ledger — neither
396
+ agent calls the other.
397
+ - **The audience sees the secret; the cast never does.** `view_model_at` lifts the latest
398
+ `secret` off the ledger into a `secret` / `secret_holder` field, which the stage core
399
+ renders as a dashed "🔒 only you can see" badge. It's a director's-eye peek for the human
400
+ watching — it is never placed in any agent's prompt (verified in
401
+ `tests/test_twenty_sprouts.py`). Use this field for any future hidden-information run
402
+ where the watcher should be in on the secret.
403
+
404
  - **Ground truth gets a code-stamped scoreboard.** The scenario declares a
405
  [`competition:` block](../schema/scenario-config.md#competition-who-can-win-and-who-decides)
406
  (`kind: versus`, ADR-0029) naming the teams — `spy: [spy-nil]` vs the herd. The
 
441
 
442
  ## Common pitfalls
443
 
444
+ - **Subscribing ≠ reading (custom kinds only).** An agent woken by a *custom* kind
445
+ (e.g. `rumor.whispered`) cannot read it until you make it visible (Step 6). Public
446
+ speech (`agent.spoke` / `oracle.spoke`) is globally visible, so talking agents need no
447
+ fix.
448
+ - **Make contributors speak, not think.** A worker that emits `agent.thought` is invisible
449
+ to peers and the judge (thoughts ride the mind-reader alone). If its contribution should
450
+ be built on or judged — a clue, a rebuttal, an argument — it must `agent.spoke`. Reserve
451
+ `agent.thought` for genuinely private reasoning. (This is exactly what Mystery Roots'
452
+ clue-gatherer / devils-advocate got wrong before the ADR-0023 follow-up.)
453
  - **The first `judge.verdict` ends the show.** The Fishbowl autoplay loop pauses the
454
  moment a verdict lands at the head (`FishbowlSession.has_verdict`). So a judge that
455
  `subscribes_to: [world.observed]` fires on the *genesis* event and resolves the run
src/agents/base.py CHANGED
@@ -156,6 +156,9 @@ class ManifestAgent(Agent):
156
  all_events=recent_events,
157
  memory_window=mem_cfg.window,
158
  memory_text=memory_text, # FIX: salience/episodic recall is now actually used
 
 
 
159
  )
160
  extra = self._build_extra_prompt(projection, recent_events)
161
  tools_block = self._tools_block()
 
156
  all_events=recent_events,
157
  memory_window=mem_cfg.window,
158
  memory_text=memory_text, # FIX: salience/episodic recall is now actually used
159
+ # A judge gets the COMPLETE exchange to rule on; workers get the recent table to
160
+ # react to (ContextBuilder picks the discussion block by role — ADR-0023 follow-up).
161
+ role=self.manifest.role,
162
  )
163
  extra = self._build_extra_prompt(projection, recent_events)
164
  tools_block = self._tools_block()
src/agents/twenty_sprouts.py CHANGED
@@ -1,18 +1,31 @@
1
  """Twenty Sprouts handlers — a 20-questions game where code owns the ground truth.
2
 
3
  The secret word is *dealt by code*, never by the model — exactly the discipline The
4
- Steeped uses for the spy words. Two handlers:
5
 
6
  * :class:`SecretKeeper` deals a secret word deterministically from the seed and
7
  carries it on its events as a **private** ``secret`` payload key. Because the
8
  context/memory builder only ever surfaces an event's ``text`` (``_displayable``
9
  in ``src/core/memory.py``), the guesser never sees the word — only the keeper's
10
- yes/no answers. The word rides the ledger as ground truth without leaking.
 
 
 
 
 
 
 
 
 
11
  * :class:`SproutJudge` (a :class:`~src.agents.competition.JudgedCompetition`) reads
12
  the dealt word off the ledger and the guesser's last line, decides the winner in
13
  code (the guess contains the word → guesser; else the keeper kept its secret),
14
  and attaches a ``reveal`` unmasking the word. Deterministic win condition,
15
  reproducible offline.
 
 
 
 
16
  """
17
 
18
  from __future__ import annotations
@@ -20,11 +33,13 @@ from __future__ import annotations
20
  import hashlib
21
  import re
22
 
 
23
  from src.agents.base import ManifestAgent
24
  from src.agents.competition import JudgedCompetition
25
  from src.core.events import Event
26
  from src.core.projections import StageProjection
27
  from src.core.registry import register_handler
 
28
 
29
  # Curated, woodland-flavoured words the keeper can hold. Evocative enough to make a
30
  # clean offline demo, concrete enough that a guesser can corner them with yes/no.
@@ -42,8 +57,21 @@ _WORDS: tuple[str, ...] = (
42
  )
43
 
44
  _GUESSER_NAME = "sprout-guesser"
 
45
  _WORD = re.compile(r"[a-z]+")
46
 
 
 
 
 
 
 
 
 
 
 
 
 
47
 
48
  def _word_for_seed(seed: str) -> str:
49
  """Deal a secret word as a pure function of the seed — reproducible offline."""
@@ -51,22 +79,153 @@ def _word_for_seed(seed: str) -> str:
51
  return _WORDS[int(digest[:8], 16) % len(_WORDS)]
52
 
53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  @register_handler("secret-keeper")
55
  class SecretKeeper(ManifestAgent):
56
- """Holds the dealt word and answers the guesser, never spelling it aloud.
57
 
58
  The word is stamped on every one of the keeper's events as a private ``secret``
59
- key — visible to the judge (which reads payloads off the ledger) but never to the
60
- guesser (whose context is built from ``text`` only). The keeper's spoken ``text``
61
- is its yes/no answer; the secret stays out of it.
 
 
 
 
 
 
 
 
 
 
62
  """
63
 
64
  def _build_extra_prompt(self, projection: StageProjection, recent_events: tuple[Event, ...]) -> str:
65
  word = _word_for_seed(projection.seed)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  return (
67
  f"YOUR SECRET WORD (never write, spell, or quote it — only answer about it): {word}\n"
68
- "Answer the guesser's most recent yes/no question truthfully in one short sentence. "
69
- "If they have not asked yet, invite them to begin. Never reveal the word."
 
 
 
70
  )
71
 
72
  def act(
@@ -76,13 +235,125 @@ class SecretKeeper(ManifestAgent):
76
  projection: StageProjection,
77
  recent_events: tuple[Event, ...],
78
  ) -> Event:
 
 
79
  event = super().act(run_id, turn, projection, recent_events)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  # Ground truth on the ledger, private (non-``text``) so it never reaches the
81
- # guesser's prompt — the judge reads it back at the reckoning.
82
- event.payload["secret"] = _word_for_seed(projection.seed)
 
83
  return event
84
 
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  @register_handler("sprout-judge")
87
  class SproutJudge(JudgedCompetition):
88
  """Decides Twenty Sprouts in code: did the guesser's last line contain the word?
 
1
  """Twenty Sprouts handlers — a 20-questions game where code owns the ground truth.
2
 
3
  The secret word is *dealt by code*, never by the model — exactly the discipline The
4
+ Steeped uses for the spy words. Three handlers shape the round:
5
 
6
  * :class:`SecretKeeper` deals a secret word deterministically from the seed and
7
  carries it on its events as a **private** ``secret`` payload key. Because the
8
  context/memory builder only ever surfaces an event's ``text`` (``_displayable``
9
  in ``src/core/memory.py``), the guesser never sees the word — only the keeper's
10
+ yes/no answers. The keeper *answers* the guesser's most recent question and never
11
+ asks one back, and — crucially — never spells the word: the handler hands the model
12
+ the exact question to answer, re-asks once if the reply asks a question or leaks the
13
+ word, and scrubs the word from the spoken line as an absolute last-resort guarantee.
14
+ * :class:`SproutGuesser` plays the asker. It mines the ledger for every prior
15
+ question and the keeper's answers, builds a running dossier (CONFIRMED / RULED
16
+ OUT / ALREADY ASKED), asks ONE genuinely new question that narrows the field, and
17
+ is forced to *commit a guess between questions* — when the facts converge, every few
18
+ questions once it has learned enough, and as a hard stop before the round runs out —
19
+ so it stops repeating itself and actually names the word instead of interrogating forever.
20
  * :class:`SproutJudge` (a :class:`~src.agents.competition.JudgedCompetition`) reads
21
  the dealt word off the ledger and the guesser's last line, decides the winner in
22
  code (the guess contains the word → guesser; else the keeper kept its secret),
23
  and attaches a ``reveal`` unmasking the word. Deterministic win condition,
24
  reproducible offline.
25
+
26
+ The word also rides into the *view model* (``src/ui/fishbowl/view_model.py``) so the
27
+ human audience can watch the keeper hold it — visible on stage, never in any agent's
28
+ prompt.
29
  """
30
 
31
  from __future__ import annotations
 
33
  import hashlib
34
  import re
35
 
36
+ from src import observability as obs
37
  from src.agents.base import ManifestAgent
38
  from src.agents.competition import JudgedCompetition
39
  from src.core.events import Event
40
  from src.core.projections import StageProjection
41
  from src.core.registry import register_handler
42
+ from src.core.structured import AgentOutputError
43
 
44
  # Curated, woodland-flavoured words the keeper can hold. Evocative enough to make a
45
  # clean offline demo, concrete enough that a guesser can corner them with yes/no.
 
57
  )
58
 
59
  _GUESSER_NAME = "sprout-guesser"
60
+ _KEEPER_NAME = "secret-keeper"
61
  _WORD = re.compile(r"[a-z]+")
62
 
63
+ # A keeper's reply is an *answer*, never a question. The cheapest reliable tell that a
64
+ # small model slipped back into asking is a line that ends on a question mark — we reject
65
+ # those and re-ask (see :meth:`SecretKeeper.act`).
66
+ _ENDS_QUESTION = re.compile(r"\?\s*$")
67
+ # Lead-word classification of a yes/no answer, used to sort the guesser's dossier into
68
+ # CONFIRMED vs RULED OUT. Anything else is a flavour/hint line and stays unsorted.
69
+ _YES_LEAD = re.compile(r"^\W*(yes|yep|yeah|yup|indeed|aye|mostly|sometimes|often|usually|kind of|sort of)\b", re.I)
70
+ _NO_LEAD = re.compile(r"^\W*(no|nope|nah|nay|never|not\b|hardly|rarely)\b", re.I)
71
+ # A final guess looks like "My guess is: LANTERN" — recognised so the dossier lists it as
72
+ # a committed guess (don't repeat it) rather than as another open question.
73
+ _FINAL_GUESS = re.compile(r"\bmy guess is\b", re.I)
74
+
75
 
76
  def _word_for_seed(seed: str) -> str:
77
  """Deal a secret word as a pure function of the seed — reproducible offline."""
 
79
  return _WORDS[int(digest[:8], 16) % len(_WORDS)]
80
 
81
 
82
+ def _word_re(word: str) -> re.Pattern[str]:
83
+ """Whole-word matcher for a secret word and its commonest inflections.
84
+
85
+ Catches the bare word plus simple plural/verb endings (EMBER → embers, embered…) so the
86
+ keeper's spoken line can be checked — and, if it leaks, scrubbed — without tripping on a
87
+ word that merely *contains* the secret as a substring (e.g. RIVER inside "rivers" yes,
88
+ but not "driver")."""
89
+ return re.compile(rf"\b{re.escape(word)}(?:s|es|ed|ing)?\b", re.I)
90
+
91
+
92
+ def _contains_word(text: str, word: str) -> bool:
93
+ """True when the keeper's line spells the secret word out (any case/inflection)."""
94
+ return bool(word) and bool(_word_re(word).search(text or ""))
95
+
96
+
97
+ def _redact_word(text: str, word: str) -> str:
98
+ """Mask any spelled-out secret word so it can never reach the stage.
99
+
100
+ The guarantee of last resort: even if the model ignores every instruction and writes the
101
+ word, this replaces it with the neutral noun ``thing`` before the line is shown, so the
102
+ audience-facing feed (and the guesser's blackboard) never carry the answer — and the
103
+ masked sentence still reads ("a glowing thing can emerge"). A no-op when the word is
104
+ absent, which is always the case on the deterministic offline path."""
105
+ return re.sub(r"\s{2,}", " ", _word_re(word).sub("thing", text or "")).strip()
106
+
107
+
108
+ def _answer_violation(text: str, word: str) -> str | None:
109
+ """Why a keeper reply is unacceptable, for a corrective re-ask — or ``None`` if it's fine.
110
+
111
+ Two ways a keeper stops being an *answerer*: it spells the secret word, or it asks a
112
+ question instead of answering one."""
113
+ if _contains_word(text, word):
114
+ return "you spelled out the secret word — never write or hint its letters"
115
+ if _ENDS_QUESTION.search(text or ""):
116
+ return "you asked a question instead of answering — you must only answer"
117
+ return None
118
+
119
+
120
+ def _trim(text: str, limit: int = 120) -> str:
121
+ """One-line, length-bounded rendering of a ledger line for a dossier bullet."""
122
+ line = " ".join(str(text).split())
123
+ return line if len(line) <= limit else line[: limit - 1].rstrip() + "…"
124
+
125
+
126
+ def _classify_answer(text: str) -> str:
127
+ """Sort a keeper answer into ``"yes"`` / ``"no"`` / ``"hint"`` by its lead word."""
128
+ if _YES_LEAD.match(text or ""):
129
+ return "yes"
130
+ if _NO_LEAD.match(text or ""):
131
+ return "no"
132
+ return "hint"
133
+
134
+
135
+ def _qa_history(recent_events: tuple[Event, ...]) -> list[tuple[str, str | None]]:
136
+ """Pair each guesser line with the keeper answer that followed it, in order.
137
+
138
+ Walks the run's spoken events once: a guesser ``agent.spoke`` opens a new
139
+ ``(question, None)`` pair; the next keeper ``agent.spoke`` fills its answer. The
140
+ result is the spine of the guesser's dossier and of the keeper's "what was just
141
+ asked" — both read straight off the shared ledger, no side state.
142
+ """
143
+ pairs: list[tuple[str, str | None]] = []
144
+ for e in recent_events:
145
+ if e.kind != "agent.spoke":
146
+ continue
147
+ text = str(e.payload.get("text", "")).strip()
148
+ if not text:
149
+ continue
150
+ if e.actor == _GUESSER_NAME:
151
+ pairs.append((text, None))
152
+ elif e.actor == _KEEPER_NAME and pairs and pairs[-1][1] is None:
153
+ q, _ = pairs[-1]
154
+ pairs[-1] = (q, text)
155
+ return pairs
156
+
157
+
158
+ def _questions_since_guess(pairs: list[tuple[str, str | None]]) -> int:
159
+ """How many questions the guesser has asked since its last committed guess.
160
+
161
+ Counts guesser lines from the end back to the most recent ``My guess is: …`` (or the
162
+ start if it has never guessed). Drives the "guess at least every N questions" cadence so
163
+ the guesser commits between questions instead of interrogating forever."""
164
+ count = 0
165
+ for q, _ in reversed(pairs):
166
+ if _FINAL_GUESS.search(q):
167
+ break
168
+ count += 1
169
+ return count
170
+
171
+
172
+ def _bullets(lines: list[str]) -> str:
173
+ return "\n".join(f"- {_trim(line)}" for line in lines)
174
+
175
+
176
  @register_handler("secret-keeper")
177
  class SecretKeeper(ManifestAgent):
178
+ """Holds the dealt word and answers the guesser, never spelling it aloud, never asking.
179
 
180
  The word is stamped on every one of the keeper's events as a private ``secret``
181
+ key — visible to the judge and the UI (which read payloads off the ledger) but never
182
+ to the guesser (whose context is built from ``text`` only). The keeper's spoken
183
+ ``text`` is its yes/no answer; the secret stays out of it.
184
+
185
+ Three disciplines keep the keeper an *answerer* that never leaks (the prior version
186
+ both drifted into asking the guesser's questions back *and* spelled the word aloud):
187
+
188
+ * the prompt names the guesser's most recent question and demands a truthful
189
+ Yes/No answer about the held word;
190
+ * a reply that asks a question or spells the word is re-asked once (with the reason
191
+ fed back), then on a still-asking reply the turn is skipped; and
192
+ * as an absolute guarantee, the spoken line is scrubbed of the word before it ships,
193
+ so the secret can never reach the stage even if the model ignores every rule.
194
  """
195
 
196
  def _build_extra_prompt(self, projection: StageProjection, recent_events: tuple[Event, ...]) -> str:
197
  word = _word_for_seed(projection.seed)
198
+ pairs = _qa_history(recent_events)
199
+ # The guesser's most recent *open* question (the one awaiting this answer).
200
+ question = next((q for q, a in reversed(pairs) if a is None), "")
201
+ already = [a for _, a in pairs if a]
202
+ guard = (
203
+ "\n\nSTRICT RULES: You ANSWER, you never ask. Begin with 'Yes' or 'No' (then one short, "
204
+ "truthful, playful clause). Answer ONLY about your secret word, and answer the SAME way "
205
+ "every time about the same property — never contradict an earlier answer. Do NOT end your "
206
+ f"line with a question mark. NEVER write the word '{word}' or any form of it — describe it, "
207
+ "never name it; that instantly loses the game."
208
+ )
209
+ # On a corrective re-ask (set by ``act`` after a rejected reply), tell the model
210
+ # exactly what it did wrong so the retry actually fixes it.
211
+ reason = getattr(self, "_retry_reason", None)
212
+ correction = f"\n\nCORRECTION: your previous reply was rejected because {reason}. Try again." if reason else ""
213
+ if not question:
214
+ return (
215
+ f"YOUR SECRET WORD (never write, spell, or quote it — only answer about it): {word}\n"
216
+ "The guesser has not asked yet. In ONE short sentence, invite them to begin asking "
217
+ "yes/no questions. Do not reveal anything about the word yet." + guard + correction
218
+ )
219
+ consistency = (
220
+ ("\n\nYour earlier answers (stay consistent with these):\n" + _bullets(already[-6:])) if already else ""
221
+ )
222
  return (
223
  f"YOUR SECRET WORD (never write, spell, or quote it — only answer about it): {word}\n"
224
+ f'The guesser just asked: "{_trim(question)}"\n'
225
+ "Answer THAT question about your word, truthfully, in ONE short sentence."
226
+ + consistency
227
+ + guard
228
+ + correction
229
  )
230
 
231
  def act(
 
235
  projection: StageProjection,
236
  recent_events: tuple[Event, ...],
237
  ) -> Event:
238
+ offline = bool(getattr(self.router, "offline", False))
239
+ word = _word_for_seed(projection.seed)
240
  event = super().act(run_id, turn, projection, recent_events)
241
+ usage_total = dict(self.last_usage)
242
+ text = str(event.payload.get("text", ""))
243
+
244
+ # Live quality pass: if the keeper asked a question or spelled the word, re-ask once
245
+ # with the reason fed back. Offline lines are curated, leak-free answers, so this
246
+ # never fires there — the deterministic path is identical. The retry's tokens are
247
+ # summed so the governor still meters both calls (mirrors base ``_verify_verdict``).
248
+ if not offline and _answer_violation(text, word):
249
+ self._retry_reason = _answer_violation(text, word)
250
+ try:
251
+ retry = super().act(run_id, turn, projection, recent_events)
252
+ finally:
253
+ self._retry_reason = None
254
+ usage_total = self._sum_usage(usage_total, dict(self.last_usage))
255
+ self.last_usage = usage_total
256
+ event, text = retry, str(retry.payload.get("text", ""))
257
+
258
+ # Absolute guarantee: scrub any spelled-out word before it ships. A no-op offline
259
+ # and on a clean reply; the safety net when the model leaks anyway (as it did with
260
+ # "a glowing ember…"). Logged so a persistent leak is visible in the run.
261
+ scrubbed = _redact_word(text, word)
262
+ if scrubbed != text:
263
+ obs.log("twenty_sprouts.redacted_keeper_leak", agent=self.name, turn=turn)
264
+ text = scrubbed
265
+ event.payload["text"] = text
266
+
267
+ # The keeper must answer, not ask: a reply still ending in a question after the
268
+ # re-ask is skipped (live only) rather than shown asking. Redaction above can't fix
269
+ # an asked question, so this is the final gate.
270
+ if not offline and _ENDS_QUESTION.search(text):
271
+ raise AgentOutputError(f"{self.name}: keeper asked a question instead of answering — skipped")
272
+
273
  # Ground truth on the ledger, private (non-``text``) so it never reaches the
274
+ # guesser's prompt — the judge reads it back at the reckoning, the UI shows it
275
+ # to the human audience only.
276
+ event.payload["secret"] = word
277
  return event
278
 
279
 
280
+ @register_handler("sprout-guesser")
281
+ class SproutGuesser(ManifestAgent):
282
+ """Asks the questions — building on the ledger so it never repeats and always narrows.
283
+
284
+ The base agent already sees the blackboard, but a small model left to itself loops on
285
+ near-identical questions and forgets what it learned (the transcript that prompted this
286
+ handler). So we hand it an explicit dossier mined from the run's own Q&A:
287
+
288
+ * CONFIRMED — questions the keeper answered *yes* (the shape of the word so far);
289
+ * RULED OUT — questions answered *no* (dead ends to avoid);
290
+ * ALREADY ASKED — every prior question, which it is told never to repeat or reword.
291
+
292
+ With the facts in front of it the model is asked for ONE new splitting question — but
293
+ it must also *commit guesses between questions* rather than interrogating forever (the
294
+ failure the second transcript showed: twenty questions, never a guess). So the handler
295
+ forces a committed ``My guess is: <word>`` when the facts converge, periodically once
296
+ it has learned enough, and as a hard stop before the round runs out. A wrong guess just
297
+ rolls into the ALREADY GUESSED list and play continues.
298
+ """
299
+
300
+ _COMMIT_AFTER = 12 # hard stop: this many questions in, stop asking and commit
301
+ _GUESS_WHEN_CONFIRMED = 5 # this many YES facts ⇒ you almost certainly know it — guess now
302
+ _MIN_FACTS_TO_GUESS = 3 # never force a guess before the word has real shape
303
+ _GUESS_EVERY = 4 # once warmed up, take a guess at least every N questions
304
+
305
+ def _build_extra_prompt(self, projection: StageProjection, recent_events: tuple[Event, ...]) -> str:
306
+ pairs = _qa_history(recent_events)
307
+ if not pairs:
308
+ return (
309
+ "You are opening a game of twenty questions. Ask ONE broad yes/no question that "
310
+ "splits the space of possible woodland words roughly in half (e.g. alive vs. made, "
311
+ "natural vs. crafted). One short sentence, ending in a question mark."
312
+ )
313
+ confirmed = [q for q, a in pairs if a and _classify_answer(a) == "yes"]
314
+ ruled_out = [q for q, a in pairs if a and _classify_answer(a) == "no"]
315
+ asked = [q for q, _ in pairs if not _FINAL_GUESS.search(q)]
316
+ guessed = [q for q, _ in pairs if _FINAL_GUESS.search(q)]
317
+
318
+ blocks: list[str] = ["YOUR DOSSIER on the keeper's secret word (read it before you ask):"]
319
+ if confirmed:
320
+ blocks.append("CONFIRMED — the keeper said YES to these (build on them):\n" + _bullets(confirmed))
321
+ if ruled_out:
322
+ blocks.append("RULED OUT — the keeper said NO to these (don't go back here):\n" + _bullets(ruled_out))
323
+ if asked:
324
+ blocks.append("ALREADY ASKED — never ask any of these again, or a reworded version:\n" + _bullets(asked))
325
+ if guessed:
326
+ blocks.append("ALREADY GUESSED (wrong) — never repeat these guesses:\n" + _bullets(guessed))
327
+
328
+ if self._should_commit(asked, confirmed, pairs):
329
+ differ = " Your earlier guess was wrong, so pick a DIFFERENT word." if guessed else ""
330
+ task = (
331
+ "TIME TO GUESS. Do NOT ask a question this turn. Read the CONFIRMED facts and name the "
332
+ 'single word they point to, written EXACTLY as: "My guess is: <word>."' + differ
333
+ )
334
+ else:
335
+ task = (
336
+ "Ask ONE genuinely NEW yes/no question that builds on the CONFIRMED facts and rules out "
337
+ "more of the field. Go broad → specific. One short sentence ending in a question mark. "
338
+ "Do NOT repeat or reword anything ALREADY ASKED."
339
+ )
340
+ return "\n\n".join(blocks) + "\n\n" + task
341
+
342
+ def _should_commit(self, asked: list[str], confirmed: list[str], pairs: list[tuple[str, str | None]]) -> bool:
343
+ """Decide whether this turn must be a guess rather than another question.
344
+
345
+ Commit when any of: the round is nearly spent (``_COMMIT_AFTER`` questions), the word
346
+ has plainly taken shape (``_GUESS_WHEN_CONFIRMED`` confirmed facts), or we have learned
347
+ enough and have gone too long without guessing (``_GUESS_EVERY`` questions since the
348
+ last guess) — the last is what *interleaves* guesses so the guesser stops interrogating
349
+ forever."""
350
+ if len(asked) >= self._COMMIT_AFTER:
351
+ return True
352
+ if len(confirmed) >= self._GUESS_WHEN_CONFIRMED:
353
+ return True
354
+ return len(confirmed) >= self._MIN_FACTS_TO_GUESS and _questions_since_guess(pairs) >= self._GUESS_EVERY
355
+
356
+
357
  @register_handler("sprout-judge")
358
  class SproutJudge(JudgedCompetition):
359
  """Decides Twenty Sprouts in code: did the guesser's last line contain the word?
src/core/conductor.py CHANGED
@@ -300,6 +300,29 @@ class Conductor:
300
  self._maybe_snapshot()
301
  return True
302
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
  def force_verdict(self) -> Event | None:
304
  """Cut the show short and have the judge rule *now*, on the whole run.
305
 
@@ -441,10 +464,17 @@ class Conductor:
441
  return appended
442
 
443
  def _notify_subscribers(self, event: Event) -> None:
444
- """Queue agents that subscribe to this event kind."""
 
 
 
 
 
 
 
445
  for agent in self.scenario.agents:
446
  manifest = getattr(agent, "manifest", None)
447
- if manifest and event.kind in manifest.subscribes_to:
448
  self._trigger_queue.append((agent, event))
449
 
450
  def _tick_scheduled_agents(self) -> list["Agent"]:
 
300
  self._maybe_snapshot()
301
  return True
302
 
303
+ def peek_next_actor_name(self) -> str | None:
304
+ """Best-effort name of the agent the next :meth:`step_one` will run.
305
+
306
+ A pure read (it never mutates the queue or the turn) used by the UI to show a
307
+ "who's thinking…" hint while a model call is in flight. Mirrors ``step_one``'s
308
+ own pull order: an already-queued agent first, then a subscription-triggered
309
+ one, then — when the queue is empty and the next call would open a fresh turn —
310
+ the first tick-scheduled agent for ``turn + 1``. Returns ``None`` when nothing
311
+ is queued and no agent ticks on the next turn (the show is effectively idle)."""
312
+ if self._pending:
313
+ return getattr(self._pending[0], "name", None)
314
+ if self._trigger_queue:
315
+ return getattr(self._trigger_queue[0][0], "name", None)
316
+ next_turn = self.turn + 1
317
+ for agent in self.scenario.agents:
318
+ manifest = getattr(agent, "manifest", None)
319
+ if manifest is None:
320
+ continue
321
+ tick_every = manifest.schedule.tick_every
322
+ if tick_every is not None and (tick_every == 0 or next_turn % tick_every == 0):
323
+ return getattr(agent, "name", None)
324
+ return None
325
+
326
  def force_verdict(self) -> Event | None:
327
  """Cut the show short and have the judge rule *now*, on the whole run.
328
 
 
464
  return appended
465
 
466
  def _notify_subscribers(self, event: Event) -> None:
467
+ """Queue agents that subscribe to this event kind — but never an agent for its OWN
468
+ event.
469
+
470
+ An agent that both subscribes to and emits a kind (e.g. the devil's-advocate
471
+ subscribes to ``agent.spoke`` and now speaks one) would otherwise re-trigger itself
472
+ on its own line, cascading until the per-turn call cap trips — starving later
473
+ agents (the judge) of their turn. Self-reaction is never intended, so we skip it;
474
+ a subscriber still reacts to every *peer's* event."""
475
  for agent in self.scenario.agents:
476
  manifest = getattr(agent, "manifest", None)
477
+ if manifest and event.kind in manifest.subscribes_to and event.actor != getattr(agent, "name", None):
478
  self._trigger_queue.append((agent, event))
479
 
480
  def _tick_scheduled_agents(self) -> list["Agent"]:
src/core/context.py CHANGED
@@ -5,8 +5,12 @@ Layering order (innermost → outermost, smallest → largest prompt budget):
5
  1. IDENTITY — pinned persona (permanent cost, never drops)
6
  2. SHARED GOAL — the scenario objective (only when set; from the projection)
7
  3. CURRENT SCENE — world state from the stage projection
8
- 4. WHAT'S BEEN SAID— the shared blackboard: peers' recent lines (ADR-0023)
9
- 5. YOUR MEMORY — episodic recall from the ledger (windowed or salience-ranked)
 
 
 
 
10
  6. VISITOR — recent user injections (always salient)
11
  [7. EXTRA] — injected by ManifestAgent subclass for scenario-specific context
12
  [8. OUTPUT FORMAT] — JSON constraint block (appended by structured.py)
@@ -22,10 +26,25 @@ from src.core.events import Event
22
  from src.core.memory import EpisodicMemory
23
  from src.core.projections import StageProjection
24
 
 
 
 
 
 
25
 
26
  class ContextBuilder:
27
  """Assembles a compact, role-scoped prompt for a single agent turn."""
28
 
 
 
 
 
 
 
 
 
 
 
29
  def build(
30
  self,
31
  *,
@@ -35,6 +54,7 @@ class ContextBuilder:
35
  all_events: tuple[Event, ...],
36
  memory_window: int = 8,
37
  memory_text: str | None = None,
 
38
  ) -> str:
39
  """Build a prompt string from layered context.
40
 
@@ -42,70 +62,147 @@ class ContextBuilder:
42
  agent_name: Used to filter visible events for memory.
43
  persona: Fixed identity text (IDENTITY block).
44
  projection: Current world-state view.
45
- all_events: Full ledger tail for memory retrieval.
 
46
  memory_window: How many events to include (for EpisodicMemory).
47
  memory_text: Pre-computed memory string (pass to override the default
48
  EpisodicMemory computation, e.g. when using SalienceMemory).
 
 
49
  """
50
  if memory_text is None:
51
  memory_text = EpisodicMemory(agent_name, max_recent=memory_window).format_for_prompt(all_events)
52
 
53
- # The latest world line is shown in full under CURRENT SCENE; echoing it again as
54
- # the tail of YOUR MEMORY wastes a small model's context and primes it to repeat.
55
- memory_text = self._dedup_memory(memory_text, projection.current_scene)
 
 
 
 
 
56
 
57
  visitor_lines = "\n".join(f"- {a}" for a in projection.user_artifacts[-3:]) or "(quiet)"
58
 
59
  goal_block = f"SHARED GOAL\n{projection.goal}\n\n" if projection.goal else ""
60
 
 
 
 
 
 
 
 
 
61
  prompt = (
62
  f"IDENTITY\n{persona}\n\n"
63
  f"{goal_block}"
64
  f"CURRENT SCENE\n{projection.current_scene}\n\n"
65
- f"{self._blackboard_block(projection.agent_notes)}"
66
- f"YOUR MEMORY (recent events you witnessed)\n{memory_text}\n\n"
67
  f"VISITOR DISTURBANCES\n{visitor_lines}"
68
  )
69
  # Structure + size of the assembled context (the full prompt is logged by the
70
  # agent layer as ``agent.prompt``; here we record which sections were present).
71
- sections = ["IDENTITY", "CURRENT SCENE", "BLACKBOARD", "MEMORY", "VISITOR"]
 
72
  if goal_block:
73
  sections.insert(1, "SHARED GOAL")
74
  obs.log(
75
  "context.build",
76
  level="debug",
77
  agent=agent_name,
 
78
  sections=sections,
79
  prompt_chars=len(prompt),
80
  memory_chars=len(memory_text),
81
  )
82
  return prompt
83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  @staticmethod
85
- def _dedup_memory(memory_text: str, current_scene: str) -> str:
86
- """Drop memory lines that merely repeat the CURRENT SCENE.
87
-
88
- Memory lines are ``[turn NNN][kind] <text>``; the most-recent ``world.observed``
89
- is also the ``current_scene``, so its line appears in both blocks. We drop any
90
- memory line that ends with the current scene's text, keeping the rest of the
91
- recall intact (and leaving the block untouched if that would empty it)."""
92
- scene = (current_scene or "").strip()
93
- if not scene:
 
 
 
94
  return memory_text
95
- kept = [line for line in memory_text.splitlines() if not line.rstrip().endswith(scene)]
96
- return "\n".join(kept) if kept else memory_text
97
 
98
  @staticmethod
99
- def _blackboard_block(agent_notes: list[str], window: int = 6) -> str:
100
  """The shared blackboard: what the rest of the cast just said or did.
101
 
102
  Without this an agent sees only the world text and its own past lines, so a
103
  small model loops on the same clue and never reacts to anyone (the "shared
104
- blackboard isn't shared" gap — ADR-0023). ``agent_notes`` already carries
105
- only the public ``text`` of each peer event (never their private thought),
106
- so surfacing it shares the conversation without leaking minds.
107
  """
108
- notes = [n for n in agent_notes if n][-window:]
109
  if not notes:
110
  return "WHAT'S BEEN SAID\n(you go first — set the tone)\n\n"
111
  lines = "\n".join(f"- {n}" for n in notes)
 
5
  1. IDENTITY — pinned persona (permanent cost, never drops)
6
  2. SHARED GOAL — the scenario objective (only when set; from the projection)
7
  3. CURRENT SCENE — world state from the stage projection
8
+ 4. THE DISCUSSION — role-aware (ADR-0023 + follow-up):
9
+ · workers see WHAT'S BEEN SAID — the recent table to react to;
10
+ · judges see THE EXCHANGE TO JUDGE — the *complete* ordered
11
+ transcript, so a ruling weighs the whole discussion, not its tail.
12
+ 5. YOUR MEMORY — episodic/salience recall (the earlier arc + world beats/verdicts;
13
+ deduped against block 4 so no line is printed twice)
14
  6. VISITOR — recent user injections (always salient)
15
  [7. EXTRA] — injected by ManifestAgent subclass for scenario-specific context
16
  [8. OUTPUT FORMAT] — JSON constraint block (appended by structured.py)
 
26
  from src.core.memory import EpisodicMemory
27
  from src.core.projections import StageProjection
28
 
29
+ # Public speech — the lines that constitute "the discussion" everyone can hear. A private
30
+ # ``agent.thought`` is deliberately excluded: it rides only its own event (the mind-reader),
31
+ # so a judge rules on what was *said*, and peers never read another mind.
32
+ _PUBLIC_SPEECH_KINDS = frozenset({"agent.spoke", "oracle.spoke"})
33
+
34
 
35
  class ContextBuilder:
36
  """Assembles a compact, role-scoped prompt for a single agent turn."""
37
 
38
+ # How many recent peer lines a *worker's* "react now" blackboard shows. The longer arc
39
+ # of the discussion is carried by YOUR MEMORY (recall now includes peers' spoken lines —
40
+ # ADR-0023 follow-up), so this stays small: it's the immediate table to react to.
41
+ _BLACKBOARD_WINDOW = 8
42
+
43
+ # How many lines a *judge's* full transcript shows (most recent, if it overflows). Judges
44
+ # fire infrequently and must weigh the whole exchange, so this is generous — it comfortably
45
+ # covers every shipped scenario's discussion length.
46
+ _TRANSCRIPT_LIMIT = 80
47
+
48
  def build(
49
  self,
50
  *,
 
54
  all_events: tuple[Event, ...],
55
  memory_window: int = 8,
56
  memory_text: str | None = None,
57
+ role: str = "worker",
58
  ) -> str:
59
  """Build a prompt string from layered context.
60
 
 
62
  agent_name: Used to filter visible events for memory.
63
  persona: Fixed identity text (IDENTITY block).
64
  projection: Current world-state view.
65
+ all_events: Full ledger tail (this run) — drives memory recall AND a judge's
66
+ full transcript.
67
  memory_window: How many events to include (for EpisodicMemory).
68
  memory_text: Pre-computed memory string (pass to override the default
69
  EpisodicMemory computation, e.g. when using SalienceMemory).
70
+ role: The agent's role. ``"judge"`` gets the complete exchange to rule
71
+ on; everyone else gets the recent blackboard to react to.
72
  """
73
  if memory_text is None:
74
  memory_text = EpisodicMemory(agent_name, max_recent=memory_window).format_for_prompt(all_events)
75
 
76
+ # Block 4 is role-aware: a judge needs the WHOLE exchange to rule fairly; a worker
77
+ # needs the recent table to react to. Both return the set of discussion texts they
78
+ # already show, so YOUR MEMORY can drop those exact lines — the union an agent sees
79
+ # is unchanged, we just never print a line twice (blackboard/transcript hold the
80
+ # discussion, memory holds the earlier arc + world beats/verdicts).
81
+ discussion_block, shown_texts = self._discussion(role, projection, all_events)
82
+ shown_texts = set(shown_texts) | {(projection.current_scene or "").strip()}
83
+ memory_text = self._dedup_memory(memory_text, shown_texts)
84
 
85
  visitor_lines = "\n".join(f"- {a}" for a in projection.user_artifacts[-3:]) or "(quiet)"
86
 
87
  goal_block = f"SHARED GOAL\n{projection.goal}\n\n" if projection.goal else ""
88
 
89
+ # When dedup leaves nothing (common for a judge whose recall is fully covered by the
90
+ # transcript above), show a short pointer instead of a blank or a duplicate block.
91
+ memory_block = (
92
+ f"YOUR MEMORY (recent events you witnessed)\n{memory_text}"
93
+ if memory_text.strip()
94
+ else "YOUR MEMORY\n(nothing beyond the exchange above)"
95
+ )
96
+
97
  prompt = (
98
  f"IDENTITY\n{persona}\n\n"
99
  f"{goal_block}"
100
  f"CURRENT SCENE\n{projection.current_scene}\n\n"
101
+ f"{discussion_block}"
102
+ f"{memory_block}\n\n"
103
  f"VISITOR DISTURBANCES\n{visitor_lines}"
104
  )
105
  # Structure + size of the assembled context (the full prompt is logged by the
106
  # agent layer as ``agent.prompt``; here we record which sections were present).
107
+ discussion_section = "TRANSCRIPT" if role == "judge" else "BLACKBOARD"
108
+ sections = ["IDENTITY", "CURRENT SCENE", discussion_section, "MEMORY", "VISITOR"]
109
  if goal_block:
110
  sections.insert(1, "SHARED GOAL")
111
  obs.log(
112
  "context.build",
113
  level="debug",
114
  agent=agent_name,
115
+ role=role,
116
  sections=sections,
117
  prompt_chars=len(prompt),
118
  memory_chars=len(memory_text),
119
  )
120
  return prompt
121
 
122
+ # ── the discussion block (role-aware) ─────────────────────────────────────────
123
+
124
+ def _discussion(
125
+ self, role: str, projection: StageProjection, all_events: tuple[Event, ...]
126
+ ) -> tuple[str, set[str]]:
127
+ """The discussion block + the set of texts it shows (for memory dedup).
128
+
129
+ Judges get the full ordered transcript (rule on everything); everyone else gets the
130
+ recent blackboard (react to the table)."""
131
+ if role == "judge":
132
+ lines = self._public_lines(all_events)
133
+ return self._transcript_block(lines), {self._note_text(line) for line in lines}
134
+ shown_notes = [n for n in projection.agent_notes if n][-self._BLACKBOARD_WINDOW :]
135
+ return self._blackboard_block(shown_notes), {self._note_text(n) for n in shown_notes}
136
+
137
+ @staticmethod
138
+ def _public_lines(all_events: tuple[Event, ...]) -> list[str]:
139
+ """Every public spoken line in the run, oldest → newest, as ``"actor: text"``.
140
+
141
+ This is the discussion a judge rules on. Private thoughts are excluded (see
142
+ ``_PUBLIC_SPEECH_KINDS``); a line with no text is skipped."""
143
+ out: list[str] = []
144
+ for e in all_events:
145
+ if e.kind in _PUBLIC_SPEECH_KINDS:
146
+ text = str(e.payload.get("text", "")).strip()
147
+ if text:
148
+ out.append(f"{e.actor}: {text}")
149
+ return out
150
+
151
+ @classmethod
152
+ def _transcript_block(cls, lines: list[str]) -> str:
153
+ """A judge's view: the complete exchange, in order, with a 'weigh all of it' nudge."""
154
+ if not lines:
155
+ return "THE EXCHANGE TO JUDGE\n(no one has spoken yet)\n\n"
156
+ shown = lines[-cls._TRANSCRIPT_LIMIT :]
157
+ head = (
158
+ f"(showing the last {cls._TRANSCRIPT_LIMIT} of {len(lines)} lines)\n"
159
+ if len(lines) > cls._TRANSCRIPT_LIMIT
160
+ else ""
161
+ )
162
+ body = "\n".join(f"- {line}" for line in shown)
163
+ return (
164
+ "THE EXCHANGE TO JUDGE (every spoken line, in order — weigh ALL of it, not just the last few):\n"
165
+ f"{head}{body}\n\n"
166
+ )
167
+
168
+ @staticmethod
169
+ def _note_text(note: str) -> str:
170
+ """The spoken text of a discussion line, stripped of its ``actor:`` prefix.
171
+
172
+ Lines read ``"actor: text"``, ``"actor [kind]: text"``, or ``"💭 actor believes:
173
+ text"`` — all carry the content after the first ``": "``. Used to match the same
174
+ line where it appears in a memory entry (``[turn NNN][kind] text``)."""
175
+ _, _, text = note.partition(": ")
176
+ return (text or note).strip()
177
+
178
  @staticmethod
179
+ def _dedup_memory(memory_text: str, shown_texts: set[str]) -> str:
180
+ """Drop memory lines already shown in the discussion block or CURRENT SCENE.
181
+
182
+ Memory lines are ``[turn NNN][kind] <text>``. The most-recent ``world.observed`` is
183
+ also the CURRENT SCENE, and (now that peers' spoken lines are recallable) the
184
+ discussion lines also appear in block 4. We drop any memory line ending with one of
185
+ those already-shown texts — so the agent reads each line once: the blackboard/
186
+ transcript holds the discussion, memory holds the earlier arc + world/verdict beats.
187
+ Returns the empty string if every line was a duplicate — the caller then shows a
188
+ short pointer rather than a blank or a re-print of the discussion."""
189
+ shown = {s for s in shown_texts if s}
190
+ if not shown:
191
  return memory_text
192
+ kept = [line for line in memory_text.splitlines() if not any(line.rstrip().endswith(s) for s in shown)]
193
+ return "\n".join(kept)
194
 
195
  @staticmethod
196
+ def _blackboard_block(shown_notes: list[str]) -> str:
197
  """The shared blackboard: what the rest of the cast just said or did.
198
 
199
  Without this an agent sees only the world text and its own past lines, so a
200
  small model loops on the same clue and never reacts to anyone (the "shared
201
+ blackboard isn't shared" gap — ADR-0023). ``shown_notes`` is the already-sliced
202
+ recent tail and carries only the public ``text`` of each peer event (never their
203
+ private thought), so surfacing it shares the conversation without leaking minds.
204
  """
205
+ notes = [n for n in shown_notes if n]
206
  if not notes:
207
  return "WHAT'S BEEN SAID\n(you go first — set the tone)\n\n"
208
  lines = "\n".join(f"- {n}" for n in notes)
src/core/memory.py CHANGED
@@ -2,8 +2,10 @@
2
 
3
  Memory architecture (three layers):
4
 
5
- 1. EpisodicMemory — filtered view over the ledger; agents see only events
6
- they witnessed. This is the simplest and always-on layer.
 
 
7
 
8
  2. SalienceMemory — ranks visible events by a composite score:
9
  salience(e) = w_rel·relevance(e,query) + w_rec·recency(e,turn) + w_imp·importance(e.kind)
@@ -61,6 +63,7 @@ _KIND_IMPORTANCE: dict[str, float] = {
61
  "run.started": 0.3,
62
  "world.observed": 0.7,
63
  "agent.spoke": 0.5,
 
64
  "agent.thought": 0.4,
65
  "agent.reflected": 0.85, # reflections are high-value compact memories
66
  "judge.verdict": 0.9,
@@ -70,7 +73,33 @@ _KIND_IMPORTANCE: dict[str, float] = {
70
  "verdict.final": 1.0,
71
  }
72
 
 
 
 
 
 
 
 
 
 
73
  _GLOBALLY_VISIBLE: frozenset[str] = frozenset(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  {"world.observed", "judge.verdict", "user.injected", "run.started", "agent.reflected"}
75
  )
76
 
@@ -81,8 +110,10 @@ _GLOBALLY_VISIBLE: frozenset[str] = frozenset(
81
  class EpisodicMemory:
82
  """Per-agent filtered view over the ledger — the always-on memory layer.
83
 
84
- An agent sees its own events plus globally-visible event kinds.
85
- The window is capped at max_recent to stay within small-model context budgets.
 
 
86
  """
87
 
88
  agent_name: str
@@ -291,7 +322,7 @@ class ReflectionTracker:
291
 
292
  def observe(self, events: tuple[Event, ...]) -> bool:
293
  """Return True when a reflection should be emitted this turn."""
294
- visible_count = sum(1 for e in events if e.actor == self.agent_name or e.kind in _GLOBALLY_VISIBLE)
295
  due = visible_count > 0 and visible_count != self._seen_count and visible_count % self.threshold == 0
296
  self._seen_count = visible_count
297
  return due
 
2
 
3
  Memory architecture (three layers):
4
 
5
+ 1. EpisodicMemory — filtered view over the ledger; an agent sees its own events
6
+ plus the public record everyone witnesses: world beats, verdicts, visitor
7
+ pokes, reflections, and peers' spoken lines (``agent.spoke`` / ``oracle.spoke``).
8
+ Private ``agent.thought`` stays out — minds aren't read by peers. Always-on.
9
 
10
  2. SalienceMemory — ranks visible events by a composite score:
11
  salience(e) = w_rel·relevance(e,query) + w_rec·recency(e,turn) + w_imp·importance(e.kind)
 
63
  "run.started": 0.3,
64
  "world.observed": 0.7,
65
  "agent.spoke": 0.5,
66
+ "oracle.spoke": 0.5, # a custom public-speech kind (oracle-grove); ranks like agent.spoke
67
  "agent.thought": 0.4,
68
  "agent.reflected": 0.85, # reflections are high-value compact memories
69
  "judge.verdict": 0.9,
 
73
  "verdict.final": 1.0,
74
  }
75
 
76
+ # What an agent can RECALL of others: globally-visible kinds (plus its own events).
77
+ # The split is public vs. private SPEECH. A spoken line (``agent.spoke`` / the custom
78
+ # ``oracle.spoke``) is table talk — every mind hears it, so it must be recallable across
79
+ # the whole run, not just this round's blackboard tail. Without this a judge that fires
80
+ # late (it has no own events yet) recalls *none* of the discussion it must rule on, and a
81
+ # worker forgets every peer line older than the 6-line blackboard window. A private
82
+ # ``agent.thought`` is deliberately NOT here: it rides only its own event payload (the
83
+ # mind-reader UI), so peers never read another mind's thinking. Secrets ride non-``text``
84
+ # payload keys and ``_displayable`` shows ``text`` only, so sharing speech leaks nothing.
85
  _GLOBALLY_VISIBLE: frozenset[str] = frozenset(
86
+ {
87
+ "world.observed",
88
+ "judge.verdict",
89
+ "user.injected",
90
+ "run.started",
91
+ "agent.reflected",
92
+ "agent.spoke",
93
+ "oracle.spoke",
94
+ }
95
+ )
96
+
97
+ # What COUNTS toward an agent's reflection cadence (ReflectionTracker). Deliberately the
98
+ # narrower set *without* peer speech: reflection compacts "what I have been through" —
99
+ # my own arc plus the world beats — so its rhythm shouldn't lurch just because the table
100
+ # got chatty this round. Keeping it separate from _GLOBALLY_VISIBLE leaves reflection
101
+ # timing exactly as tuned while recall gains the shared discussion.
102
+ _REFLECTION_VISIBLE: frozenset[str] = frozenset(
103
  {"world.observed", "judge.verdict", "user.injected", "run.started", "agent.reflected"}
104
  )
105
 
 
110
  class EpisodicMemory:
111
  """Per-agent filtered view over the ledger — the always-on memory layer.
112
 
113
+ An agent sees its own events plus globally-visible kinds (the public record —
114
+ world beats, verdicts, visitor pokes, reflections, and peers' *spoken* lines;
115
+ never peers' private thoughts). The window is capped at max_recent to stay
116
+ within small-model context budgets.
117
  """
118
 
119
  agent_name: str
 
322
 
323
  def observe(self, events: tuple[Event, ...]) -> bool:
324
  """Return True when a reflection should be emitted this turn."""
325
+ visible_count = sum(1 for e in events if e.actor == self.agent_name or e.kind in _REFLECTION_VISIBLE)
326
  due = visible_count > 0 and visible_count != self._seen_count and visible_count % self.threshold == 0
327
  self._seen_count = visible_count
328
  return due
src/ui/fishbowl/app.py CHANGED
@@ -18,6 +18,7 @@ from __future__ import annotations
18
 
19
  import os
20
  import socket
 
21
 
22
  # Import `spaces` before anything that could initialise CUDA: on ZeroGPU the package
23
  # raises if torch's CUDA is touched first (our torch is lazy via mem0, so this stays
@@ -379,14 +380,34 @@ def _fishbowl(inner: str, *, role: str) -> str:
379
  return f'<div class="fishbowl {role}">{inner}</div>'
380
 
381
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
382
  def render_show_html(
383
- vm: dict, *, layout: str = "constellation", mind_reader: bool = False
384
  ) -> tuple[str, str, str, str]:
385
  """Compose the Show's four HTML panes from a view-model snapshot.
386
 
387
  Returns ``(stage, feed, meters, verdict)``. The stage honours the layout radio:
388
  *constellation* (MindCards), *split* (omniscient table), or *feed* (feed-only). Each
389
- pane is wrapped in a ``.fishbowl`` scope root so the theater stylesheet applies."""
 
390
  cards_html_by_id: dict[str, str] = {}
391
  for card in vm.get("cast", []):
392
  cards_html_by_id[card.get("id", card.get("name", ""))] = render_mindcard(card, mind_reader=mind_reader)
@@ -398,6 +419,11 @@ def render_show_html(
398
  else: # constellation (default)
399
  stage = render_constellation(vm, cards_html_by_id)
400
 
 
 
 
 
 
401
  feed = render_feed(vm, mind_reader=mind_reader)
402
  meters = render_meters(vm)
403
  # The sober ruling (banner) and its cheerful counterpart (champion celebration) share
@@ -412,10 +438,14 @@ def render_show_html(
412
  )
413
 
414
 
415
- def _render_at(session: FishbowlSession | None, k: int, *, layout: str, mind_reader: bool) -> tuple[str, str, str, str]:
416
- """Render the Show at play-head *k* (pure prefix view), or empty if no session."""
 
 
 
 
417
  vm = session.snapshot(k) if session is not None else _empty_vm()
418
- return render_show_html(vm, layout=layout, mind_reader=mind_reader)
419
 
420
 
421
  def advance_one_tick(session: FishbowlSession | None, k: int, ticks: int, *, max_auto_ticks: int = _MAX_AUTO_TICKS):
@@ -460,8 +490,8 @@ def _stopped_banner_html(reason: str) -> str:
460
 
461
  text = _html.escape(reason or "budget exceeded")
462
  return _fishbowl(
463
- '<div class="verdict banner">'
464
- '<div class="eyebrow">&#9940; Stopped</div>'
465
  f'<div class="disp vb-text">{text}</div>'
466
  "</div>",
467
  role="fb-verdict",
@@ -532,6 +562,7 @@ def build_app() -> gr.Blocks:
532
  blank_state = gr.State("") # stand-in input when a leaf widget is absent
533
  stopped_state = gr.State(False) # set once the run halts (budget/backstop/verdict)
534
  tick_count_state = gr.State(0) # consecutive autoplay ticks (the 40-tick backstop)
 
535
  # Per-user session id: resolved from the browser's localStorage on load (see
536
  # _SESSION_ID_JS) so it survives reloads. Stamped onto run.started so the
537
  # Archive can list "my sessions only"; a hidden carrier, never shown.
@@ -583,6 +614,7 @@ def build_app() -> gr.Blocks:
583
  blank_state=blank_state,
584
  stopped_state=stopped_state,
585
  tick_count_state=tick_count_state,
 
586
  session_id_box=session_id_box,
587
  )
588
 
@@ -617,6 +649,7 @@ def _wire(
617
  blank_state: gr.State,
618
  stopped_state: gr.State,
619
  tick_count_state: gr.State,
 
620
  session_id_box: gr.Textbox | None = None,
621
  ) -> None:
622
  """Connect Lab/Show component handles to session transport + HTML re-render.
@@ -630,20 +663,54 @@ def _wire(
630
  verdict_out = _h(show_handles, "verdict", "verdict_html")
631
  show_outs = [c for c in (stage_out, feed_out, meters_out, verdict_out) if c is not None]
632
 
633
- # Transport controls (looked up early so output lists can include the timer).
 
 
634
  timer = _h(show_handles, "timer")
635
- # The "halt tail" appended to advancing handlers' outputs: stop the timer (when one
636
- # exists) + record the stopped flag. ``_halt_tail``/``_run_tail`` keep the returned
637
- # tuple aligned to whichever of these outputs are wired.
638
- _tail_outs = ([timer] if timer is not None else []) + [stopped_state]
 
 
 
 
 
 
 
 
 
639
 
640
  def _halt_tail() -> tuple:
641
- """Tail values that STOP autoplay: (timer active=False?, stopped=True)."""
642
- return ((gr.update(active=False),) if timer is not None else ()) + (True,)
 
 
 
 
643
 
644
- def _run_tail(stopped: bool = False) -> tuple:
645
- """Tail values that leave the timer untouched: (timer no-op?, stopped flag)."""
646
- return ((gr.update(),) if timer is not None else ()) + (stopped,)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
647
 
648
  def _stopped_panes(session, k: int, *, layout: str, mind_reader: bool, reason: str) -> tuple:
649
  """Render the Show at *k* but swap the verdict pane for the STOPPED banner.
@@ -659,15 +726,17 @@ def _wire(
659
  panes[1] = banner
660
  return _pad_values(tuple(panes), show_outs)
661
 
662
- # Show transport controls.
 
 
663
  scrubber = _h(show_handles, "scrubber", "slider", "scrub", "step_slider")
664
- play_btn = _h(show_handles, "play", "play_btn")
665
  step_btn = _h(show_handles, "step", "step_btn", "next", "advance", "fwd_btn")
666
  back_btn = _h(show_handles, "rewind", "back", "to_start", "first", "back_btn")
667
  judge_btn = _h(show_handles, "judge_btn", "start_judging", "judge_now", "judge")
668
  speed_radio = _h(show_handles, "speed", "speed_radio")
669
  layout_radio = _h(show_handles, "layout", "layout_radio")
670
  mind_toggle = _h(show_handles, "mind_reader", "read_minds", "minds")
 
671
  poke_send = _h(show_handles, "poke_send", "poke_btn", "poke")
672
  poke_text = _h(show_handles, "poke_text", "poke_input")
673
  poke_buttons = show_handles.get("poke_buttons") or show_handles.get("poke_btns") or []
@@ -809,9 +878,22 @@ def _wire(
809
  else:
810
  k = 0
811
  out = _render_at(session, k, layout=layout, mind_reader=mind_reader)
812
- # Summon starts a fresh run: clear the stopped flag and the autoplay backstop.
813
- return (session, k, title, gr.update(selected="show"), *_pad_values(out, show_outs), False, 0)
 
 
 
 
 
 
 
 
 
 
 
814
 
 
 
815
  if summon_btn is not None:
816
  summon_inputs = [
817
  scenario_in if scenario_in is not None else scenario_state,
@@ -836,10 +918,53 @@ def _wire(
836
  mind_reader_state,
837
  session_id_box if session_id_box is not None else blank_state,
838
  ]
839
- summon_btn.click(
 
 
840
  on_summon,
841
  inputs=summon_inputs,
842
- outputs=[session_state, k_state, scenario_state, tabs, *show_outs, stopped_state, tick_count_state],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
843
  )
844
 
845
  # ── Scenario picked → re-seed the dependent Lab fields from the registry ─────
@@ -968,12 +1093,31 @@ def _wire(
968
  # ticks (the hard backstop). The cap tracks the scenario budget so a long show that
969
  # ends on a late Judge verdict is never cut off early. Returns (k, *show_outs,
970
  # *halt-tail, tick_count).
971
- def on_tick(session, k, layout, mind_reader, tick_count):
 
 
 
 
 
972
  cap = session.autoplay_tick_cap if session is not None else _MAX_AUTO_TICKS
973
- new_k, new_ticks, stop_reason = advance_one_tick(session, k, tick_count, max_auto_ticks=cap)
 
 
 
 
 
 
 
 
 
 
 
 
 
974
  if stop_reason is None:
975
  out = _render_at(session, new_k, layout=layout, mind_reader=mind_reader)
976
- return (new_k, *_pad_values(out, show_outs), *_run_tail(), new_ticks)
 
977
  # The cast's run is over (budget/turn cap, a reached verdict, or replay end). If a
978
  # judge can still rule and hasn't, bring it on — the limit is the curtain call, so
979
  # the show ends on a verdict + winner rather than a silent STOPPED banner.
@@ -987,15 +1131,16 @@ def _wire(
987
  if session is not None and session.has_verdict():
988
  # Render the ruling itself (the verdict pane), timer halted.
989
  out = _render_at(session, new_k, layout=layout, mind_reader=mind_reader)
990
- return (new_k, *_pad_values(out, show_outs), *_halt_tail(), new_ticks)
 
991
  # No judge to rule (or a replay reaching its end): halt visibly with the banner.
992
  panes = _stopped_panes(session, new_k, layout=layout, mind_reader=mind_reader, reason=stop_reason)
993
- return (new_k, *panes, *_halt_tail(), new_ticks)
994
 
995
  if timer is not None and show_outs:
996
  timer.tick(
997
  on_tick,
998
- inputs=[session_state, k_state, layout_state, mind_reader_state, tick_count_state],
999
  outputs=[k_state, *show_outs, *_tail_outs, tick_count_state],
1000
  )
1001
 
@@ -1008,16 +1153,45 @@ def _wire(
1008
  speed_radio.change(on_speed, inputs=[speed_radio], outputs=[timer])
1009
 
1010
  if play_btn is not None and timer is not None:
1011
- # A tiny state tracks timer activity so the same button toggles play/pause.
1012
- play_active = gr.State(False)
1013
-
1014
  def on_play(active):
1015
- # Toggle play/pause; each fresh ▶ resets the autoplay backstop counter so the
1016
- # next run gets a full _MAX_AUTO_TICKS budget rather than inheriting the old one.
1017
  new = not bool(active)
1018
- return new, gr.update(active=new), 0
 
 
 
 
 
 
 
1019
 
1020
- play_btn.click(on_play, inputs=[play_active], outputs=[play_active, timer, tick_count_state])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1021
 
1022
  # ── layout radio + "Read their minds" → re-render the stage HTML ─────────────
1023
  if layout_radio is not None and show_outs:
@@ -1045,29 +1219,36 @@ def _wire(
1045
  )
1046
 
1047
  # ── poke buttons / poke_send → inject then render at the new head ────────────
1048
- # Injecting steps the conductor, so a poke can trip the governor too — wrap it.
1049
- def _poke_after(session, text, label_value, layout, mind_reader):
 
 
1050
  if session is None:
1051
  out = _render_at(None, 0, layout=layout, mind_reader=mind_reader)
1052
- return (0, *_pad_values(out, show_outs), *_run_tail())
 
 
 
 
1053
  try:
1054
  session.inject((text or ""), label=label_value)
1055
  except BudgetExceeded as exc:
1056
  reason = getattr(exc, "reason", None) or str(exc)
1057
  panes = _stopped_panes(session, session.head, layout=layout, mind_reader=mind_reader, reason=reason)
1058
- return (session.head, *panes, *_halt_tail())
 
1059
  k = session.head
1060
  out = _render_at(session, k, layout=layout, mind_reader=mind_reader)
1061
- return (k, *_pad_values(out, show_outs), *_run_tail())
1062
 
1063
  if poke_send is not None and poke_text is not None and show_outs:
1064
  # Free-text poke: the textbox supplies the text; a neutral label.
1065
- def on_poke_send(session, text, layout, mind_reader):
1066
- return _poke_after(session, text, None, layout, mind_reader)
1067
 
1068
  poke_send.click(
1069
  on_poke_send,
1070
- inputs=[session_state, poke_text, layout_state, mind_reader_state],
1071
  outputs=[k_state, *show_outs, *_tail_outs],
1072
  )
1073
 
@@ -1079,14 +1260,14 @@ def _wire(
1079
  label = str(getattr(btn, "value", None) or "DISTURBANCE")
1080
 
1081
  def make_preset(label_value: str):
1082
- def _on_preset(session, layout, mind_reader):
1083
- return _poke_after(session, label_value, label_value, layout, mind_reader)
1084
 
1085
  return _on_preset
1086
 
1087
  btn.click(
1088
  make_preset(label),
1089
- inputs=[session_state, layout_state, mind_reader_state],
1090
  outputs=[k_state, *show_outs, *_tail_outs],
1091
  )
1092
 
 
18
 
19
  import os
20
  import socket
21
+ import uuid
22
 
23
  # Import `spaces` before anything that could initialise CUDA: on ZeroGPU the package
24
  # raises if torch's CUDA is touched first (our torch is lazy via mem0, so this stays
 
380
  return f'<div class="fishbowl {role}">{inner}</div>'
381
 
382
 
383
+ def _thinking_strip(label: str) -> str:
384
+ """A broadcast-style "lower third" overlaid on the stage while a model call runs.
385
+
386
+ LLM calls take seconds; without a cue the theater reads as frozen. This strip names
387
+ who we're waiting on ("scene-whisperer is thinking…") with a pulsing orb + animated
388
+ ellipsis, anchored to the *always-visible* stage so it never hides under the feed's
389
+ scroll. Pure string; rendered only while a generation is in flight."""
390
+ import html as _html
391
+
392
+ text = _html.escape(label or "the cast is thinking")
393
+ return (
394
+ '<div class="thinking-strip" role="status" aria-live="polite">'
395
+ '<span class="ts-orb"></span>'
396
+ f'<span class="ts-label">{text}</span>'
397
+ '<span class="ts-dots"><i></i><i></i><i></i></span>'
398
+ "</div>"
399
+ )
400
+
401
+
402
  def render_show_html(
403
+ vm: dict, *, layout: str = "constellation", mind_reader: bool = False, thinking: str | None = None
404
  ) -> tuple[str, str, str, str]:
405
  """Compose the Show's four HTML panes from a view-model snapshot.
406
 
407
  Returns ``(stage, feed, meters, verdict)``. The stage honours the layout radio:
408
  *constellation* (MindCards), *split* (omniscient table), or *feed* (feed-only). Each
409
+ pane is wrapped in a ``.fishbowl`` scope root so the theater stylesheet applies. When
410
+ ``thinking`` is set, a "who's thinking…" strip is overlaid on the stage pane."""
411
  cards_html_by_id: dict[str, str] = {}
412
  for card in vm.get("cast", []):
413
  cards_html_by_id[card.get("id", card.get("name", ""))] = render_mindcard(card, mind_reader=mind_reader)
 
419
  else: # constellation (default)
420
  stage = render_constellation(vm, cards_html_by_id)
421
 
422
+ if thinking:
423
+ # Prepend so the badge is the FIRST child of the stage pane: with position:sticky
424
+ # it then pins to the top and stays in view without any scrolling.
425
+ stage = _thinking_strip(thinking) + stage
426
+
427
  feed = render_feed(vm, mind_reader=mind_reader)
428
  meters = render_meters(vm)
429
  # The sober ruling (banner) and its cheerful counterpart (champion celebration) share
 
438
  )
439
 
440
 
441
+ def _render_at(
442
+ session: FishbowlSession | None, k: int, *, layout: str, mind_reader: bool, thinking: str | None = None
443
+ ) -> tuple[str, str, str, str]:
444
+ """Render the Show at play-head *k* (pure prefix view), or empty if no session.
445
+
446
+ ``thinking`` (a "who's thinking…" label) overlays the stage while a model call runs."""
447
  vm = session.snapshot(k) if session is not None else _empty_vm()
448
+ return render_show_html(vm, layout=layout, mind_reader=mind_reader, thinking=thinking)
449
 
450
 
451
  def advance_one_tick(session: FishbowlSession | None, k: int, ticks: int, *, max_auto_ticks: int = _MAX_AUTO_TICKS):
 
490
 
491
  text = _html.escape(reason or "budget exceeded")
492
  return _fishbowl(
493
+ '<div class="verdict banner stopped">'
494
+ '<div class="eyebrow">&#9209; The show has ended</div>'
495
  f'<div class="disp vb-text">{text}</div>'
496
  "</div>",
497
  role="fb-verdict",
 
562
  blank_state = gr.State("") # stand-in input when a leaf widget is absent
563
  stopped_state = gr.State(False) # set once the run halts (budget/backstop/verdict)
564
  tick_count_state = gr.State(0) # consecutive autoplay ticks (the 40-tick backstop)
565
+ play_active_state = gr.State(False) # is autoplay running? (drives the Play/Pause label)
566
  # Per-user session id: resolved from the browser's localStorage on load (see
567
  # _SESSION_ID_JS) so it survives reloads. Stamped onto run.started so the
568
  # Archive can list "my sessions only"; a hidden carrier, never shown.
 
614
  blank_state=blank_state,
615
  stopped_state=stopped_state,
616
  tick_count_state=tick_count_state,
617
+ play_active_state=play_active_state,
618
  session_id_box=session_id_box,
619
  )
620
 
 
649
  blank_state: gr.State,
650
  stopped_state: gr.State,
651
  tick_count_state: gr.State,
652
+ play_active_state: gr.State,
653
  session_id_box: gr.Textbox | None = None,
654
  ) -> None:
655
  """Connect Lab/Show component handles to session transport + HTML re-render.
 
663
  verdict_out = _h(show_handles, "verdict", "verdict_html")
664
  show_outs = [c for c in (stage_out, feed_out, meters_out, verdict_out) if c is not None]
665
 
666
+ # Transport controls (looked up early so output lists can include the timer + the
667
+ # single Play/Pause hero button — its label travels in the tails so it reverts to
668
+ # "▶ Play" the instant the show stops on its own).
669
  timer = _h(show_handles, "timer")
670
+ play_btn = _h(show_handles, "play", "play_btn")
671
+
672
+ _PLAY_LABEL = "▶ Play"
673
+ _PAUSE_LABEL = "❚❚ Pause"
674
+
675
+ # The tail appended to advancing handlers' outputs, in order:
676
+ # (timer?, stopped_state, play_active_state, play_btn?)
677
+ # ``_halt_tail``/``_run_tail`` keep the returned tuple aligned to whichever are wired.
678
+ _tail_outs = (
679
+ ([timer] if timer is not None else [])
680
+ + [stopped_state, play_active_state]
681
+ + ([play_btn] if play_btn is not None else [])
682
+ )
683
 
684
  def _halt_tail() -> tuple:
685
+ """Tail values that STOP autoplay and reset the hero button to ▶ Play."""
686
+ return (
687
+ ((gr.update(active=False),) if timer is not None else ())
688
+ + (True, False)
689
+ + ((gr.update(value=_PLAY_LABEL),) if play_btn is not None else ())
690
+ )
691
 
692
+ def _run_tail(play_active: bool = False, stopped: bool = False) -> tuple:
693
+ """Tail values that leave autoplay running: keep the timer + button as-is."""
694
+ return (
695
+ ((gr.update(),) if timer is not None else ())
696
+ + (stopped, bool(play_active))
697
+ + ((gr.update(),) if play_btn is not None else ())
698
+ )
699
+
700
+ # Outputs/values that (re)set the play state when a fresh run begins — shared by
701
+ # Summon (auto-start) and Restart. Order: (timer?, play_active_state, play_btn?).
702
+ _start_outs = (
703
+ ([timer] if timer is not None else []) + [play_active_state] + ([play_btn] if play_btn is not None else [])
704
+ )
705
+
706
+ def _start_tail(active: bool) -> tuple:
707
+ """Arm (or idle) the transport for a new run: timer, play_active, button label."""
708
+ label = _PAUSE_LABEL if active else _PLAY_LABEL
709
+ return (
710
+ ((gr.update(active=active),) if timer is not None else ())
711
+ + (active,)
712
+ + ((gr.update(value=label),) if play_btn is not None else ())
713
+ )
714
 
715
  def _stopped_panes(session, k: int, *, layout: str, mind_reader: bool, reason: str) -> tuple:
716
  """Render the Show at *k* but swap the verdict pane for the STOPPED banner.
 
726
  panes[1] = banner
727
  return _pad_values(tuple(panes), show_outs)
728
 
729
+ # Show transport controls. The scrubber/step/back handles no longer exist on the
730
+ # slimmed transport (one Play/Pause button); the lookups stay defensive so any wiring
731
+ # that references them is simply skipped.
732
  scrubber = _h(show_handles, "scrubber", "slider", "scrub", "step_slider")
 
733
  step_btn = _h(show_handles, "step", "step_btn", "next", "advance", "fwd_btn")
734
  back_btn = _h(show_handles, "rewind", "back", "to_start", "first", "back_btn")
735
  judge_btn = _h(show_handles, "judge_btn", "start_judging", "judge_now", "judge")
736
  speed_radio = _h(show_handles, "speed", "speed_radio")
737
  layout_radio = _h(show_handles, "layout", "layout_radio")
738
  mind_toggle = _h(show_handles, "mind_reader", "read_minds", "minds")
739
+ restart_btn = _h(show_handles, "restart_btn", "restart", "reset_btn")
740
  poke_send = _h(show_handles, "poke_send", "poke_btn", "poke")
741
  poke_text = _h(show_handles, "poke_text", "poke_input")
742
  poke_buttons = show_handles.get("poke_buttons") or show_handles.get("poke_btns") or []
 
878
  else:
879
  k = 0
880
  out = _render_at(session, k, layout=layout, mind_reader=mind_reader)
881
+ # Summon starts a fresh run AND begins playing immediately: clear the stopped
882
+ # flag, reset the autoplay backstop, arm the timer, and flip the hero button to
883
+ # "❚❚ Pause" so the show comes alive the moment the visitor hits Summon.
884
+ return (
885
+ session,
886
+ k,
887
+ title,
888
+ gr.update(selected="show"),
889
+ *_pad_values(out, show_outs),
890
+ False,
891
+ 0,
892
+ *_start_tail(True),
893
+ )
894
 
895
+ summon_evt = None # the Summon click event — chained to the kickoff so the show
896
+ # plays its first line immediately on Summon (set below when the button exists).
897
  if summon_btn is not None:
898
  summon_inputs = [
899
  scenario_in if scenario_in is not None else scenario_state,
 
918
  mind_reader_state,
919
  session_id_box if session_id_box is not None else blank_state,
920
  ]
921
+ # The start tail (timer?, play_active, play_btn?) mirrors on_summon's return so the
922
+ # show auto-plays the instant it is summoned.
923
+ summon_evt = summon_btn.click(
924
  on_summon,
925
  inputs=summon_inputs,
926
+ outputs=[
927
+ session_state,
928
+ k_state,
929
+ scenario_state,
930
+ tabs,
931
+ *show_outs,
932
+ stopped_state,
933
+ tick_count_state,
934
+ *_start_outs,
935
+ ],
936
+ )
937
+
938
+ # ── ↺ Restart → wipe the run and begin a *fresh context* on the same cast ────
939
+ # Unlike a scrub-to-start (pure replay), Restart opens a brand-new run: a fresh seed
940
+ # gives a genuinely different conversation (the deterministic stub is seed-driven), a
941
+ # new run_id isolates it in the ledger, and the play state re-arms so the new context
942
+ # starts playing at once. No tab switch (we're already on the Show) and no cast
943
+ # rebuild — it reuses the session the visitor composed. ``restart_evt`` is chained to
944
+ # the kickoff below so the first line lands immediately, just like Summon.
945
+ restart_evt = None
946
+ if restart_btn is not None and show_outs:
947
+
948
+ def on_restart(session, layout, mind_reader, session_id):
949
+ if session is None or getattr(session, "replay", False):
950
+ # Nothing live to restart (no Summon yet, or a read-only replay): leave
951
+ # the Show as-is and idle the transport rather than erroring.
952
+ out = _render_at(session, 0, layout=layout, mind_reader=mind_reader)
953
+ return (0, *_pad_values(out, show_outs), False, 0, *_start_tail(False))
954
+ session.reset(uuid.uuid4().hex[:8], session_id=(session_id or "").strip() or None)
955
+ k = session.head
956
+ out = _render_at(session, k, layout=layout, mind_reader=mind_reader)
957
+ return (k, *_pad_values(out, show_outs), False, 0, *_start_tail(True))
958
+
959
+ restart_evt = restart_btn.click(
960
+ on_restart,
961
+ inputs=[
962
+ session_state,
963
+ layout_state,
964
+ mind_reader_state,
965
+ session_id_box if session_id_box is not None else blank_state,
966
+ ],
967
+ outputs=[k_state, *show_outs, stopped_state, tick_count_state, *_start_outs],
968
  )
969
 
970
  # ── Scenario picked → re-seed the dependent Lab fields from the registry ─────
 
1093
  # ticks (the hard backstop). The cap tracks the scenario budget so a long show that
1094
  # ends on a late Judge verdict is never cut off early. Returns (k, *show_outs,
1095
  # *halt-tail, tick_count).
1096
+ # A *generator*: when a tick is about to GENERATE a new line (vs. cheaply replay the
1097
+ # existing prefix), it first yields a frame with a "who's thinking…" strip on the
1098
+ # stage, then runs the (seconds-long) model call, then yields the result. So the
1099
+ # theater shows who we're waiting on instead of freezing silently.
1100
+ def on_tick(session, k, layout, mind_reader, tick_count, play_active):
1101
+ kk = int(k or 0)
1102
  cap = session.autoplay_tick_cap if session is not None else _MAX_AUTO_TICKS
1103
+ will_generate = (
1104
+ session is not None
1105
+ and not getattr(session, "replay", False)
1106
+ and not session.has_verdict()
1107
+ and kk >= session.head
1108
+ and int(tick_count or 0) < cap
1109
+ )
1110
+ if will_generate and show_outs:
1111
+ who = session.peek_next_actor_name()
1112
+ phrase = f"{who} is thinking" if who else "the cast is thinking"
1113
+ hint = _render_at(session, kk, layout=layout, mind_reader=mind_reader, thinking=phrase)
1114
+ yield (kk, *_pad_values(hint, show_outs), *_run_tail(play_active), tick_count)
1115
+
1116
+ new_k, new_ticks, stop_reason = advance_one_tick(session, kk, tick_count, max_auto_ticks=cap)
1117
  if stop_reason is None:
1118
  out = _render_at(session, new_k, layout=layout, mind_reader=mind_reader)
1119
+ yield (new_k, *_pad_values(out, show_outs), *_run_tail(play_active), new_ticks)
1120
+ return
1121
  # The cast's run is over (budget/turn cap, a reached verdict, or replay end). If a
1122
  # judge can still rule and hasn't, bring it on — the limit is the curtain call, so
1123
  # the show ends on a verdict + winner rather than a silent STOPPED banner.
 
1131
  if session is not None and session.has_verdict():
1132
  # Render the ruling itself (the verdict pane), timer halted.
1133
  out = _render_at(session, new_k, layout=layout, mind_reader=mind_reader)
1134
+ yield (new_k, *_pad_values(out, show_outs), *_halt_tail(), new_ticks)
1135
+ return
1136
  # No judge to rule (or a replay reaching its end): halt visibly with the banner.
1137
  panes = _stopped_panes(session, new_k, layout=layout, mind_reader=mind_reader, reason=stop_reason)
1138
+ yield (new_k, *panes, *_halt_tail(), new_ticks)
1139
 
1140
  if timer is not None and show_outs:
1141
  timer.tick(
1142
  on_tick,
1143
+ inputs=[session_state, k_state, layout_state, mind_reader_state, tick_count_state, play_active_state],
1144
  outputs=[k_state, *show_outs, *_tail_outs, tick_count_state],
1145
  )
1146
 
 
1153
  speed_radio.change(on_speed, inputs=[speed_radio], outputs=[timer])
1154
 
1155
  if play_btn is not None and timer is not None:
1156
+ # The single hero control: the same button plays and pauses, its label flipping
1157
+ # between "▶ Play" and "❚❚ Pause". Each fresh ▶ resets the autoplay backstop so
1158
+ # the next run gets a full tick budget rather than inheriting the old one.
1159
  def on_play(active):
 
 
1160
  new = not bool(active)
1161
+ label = _PAUSE_LABEL if new else _PLAY_LABEL
1162
+ return new, gr.update(active=new), 0, gr.update(value=label)
1163
+
1164
+ play_evt = play_btn.click(
1165
+ on_play,
1166
+ inputs=[play_active_state],
1167
+ outputs=[play_active_state, timer, tick_count_state, play_btn],
1168
+ )
1169
 
1170
+ if show_outs:
1171
+ # Don't make the visitor wait a whole timer interval for the first line: the
1172
+ # instant Play is pressed (play_active just flipped True), advance one step
1173
+ # right away — reusing the tick path so the "who's thinking…" hint shows while
1174
+ # the model runs. On pause (play_active now False) we simply re-render the
1175
+ # current frame without stepping. Chained after the toggle so it reads the
1176
+ # freshly-updated play_active/tick_count.
1177
+ def kickoff(session, k, layout, mind_reader, tick_count, play_active):
1178
+ if not play_active:
1179
+ out = _render_at(session, int(k or 0), layout=layout, mind_reader=mind_reader)
1180
+ yield (int(k or 0), *_pad_values(out, show_outs), *_run_tail(False), int(tick_count or 0))
1181
+ return
1182
+ yield from on_tick(session, k, layout, mind_reader, tick_count, play_active)
1183
+
1184
+ _kickoff_io = dict(
1185
+ inputs=[session_state, k_state, layout_state, mind_reader_state, tick_count_state, play_active_state],
1186
+ outputs=[k_state, *show_outs, *_tail_outs, tick_count_state],
1187
+ )
1188
+ play_evt.then(kickoff, **_kickoff_io)
1189
+ # Summon and Restart both auto-start a fresh run; kick their first line off
1190
+ # immediately too, instead of waiting a whole timer interval.
1191
+ if summon_evt is not None:
1192
+ summon_evt.then(kickoff, **_kickoff_io)
1193
+ if restart_evt is not None:
1194
+ restart_evt.then(kickoff, **_kickoff_io)
1195
 
1196
  # ── layout radio + "Read their minds" → re-render the stage HTML ─────────────
1197
  if layout_radio is not None and show_outs:
 
1219
  )
1220
 
1221
  # ── poke buttons / poke_send → inject then render at the new head ────────────
1222
+ # Injecting steps the conductor (a full turn of model calls), so a poke can trip the
1223
+ # governor too — wrap it. A *generator*: yield a "the wood stirs…" hint first so the
1224
+ # disturbance feels alive while the cast reacts, then the result (or the STOPPED card).
1225
+ def _poke_after(session, text, label_value, layout, mind_reader, play_active):
1226
  if session is None:
1227
  out = _render_at(None, 0, layout=layout, mind_reader=mind_reader)
1228
+ yield (0, *_pad_values(out, show_outs), *_run_tail(play_active))
1229
+ return
1230
+ if show_outs:
1231
+ hint = _render_at(session, session.head, layout=layout, mind_reader=mind_reader, thinking="the wood stirs")
1232
+ yield (session.head, *_pad_values(hint, show_outs), *_run_tail(play_active))
1233
  try:
1234
  session.inject((text or ""), label=label_value)
1235
  except BudgetExceeded as exc:
1236
  reason = getattr(exc, "reason", None) or str(exc)
1237
  panes = _stopped_panes(session, session.head, layout=layout, mind_reader=mind_reader, reason=reason)
1238
+ yield (session.head, *panes, *_halt_tail())
1239
+ return
1240
  k = session.head
1241
  out = _render_at(session, k, layout=layout, mind_reader=mind_reader)
1242
+ yield (k, *_pad_values(out, show_outs), *_run_tail(play_active))
1243
 
1244
  if poke_send is not None and poke_text is not None and show_outs:
1245
  # Free-text poke: the textbox supplies the text; a neutral label.
1246
+ def on_poke_send(session, text, layout, mind_reader, play_active):
1247
+ yield from _poke_after(session, text, None, layout, mind_reader, play_active)
1248
 
1249
  poke_send.click(
1250
  on_poke_send,
1251
+ inputs=[session_state, poke_text, layout_state, mind_reader_state, play_active_state],
1252
  outputs=[k_state, *show_outs, *_tail_outs],
1253
  )
1254
 
 
1260
  label = str(getattr(btn, "value", None) or "DISTURBANCE")
1261
 
1262
  def make_preset(label_value: str):
1263
+ def _on_preset(session, layout, mind_reader, play_active):
1264
+ yield from _poke_after(session, label_value, label_value, layout, mind_reader, play_active)
1265
 
1266
  return _on_preset
1267
 
1268
  btn.click(
1269
  make_preset(label),
1270
+ inputs=[session_state, layout_state, mind_reader_state, play_active_state],
1271
  outputs=[k_state, *show_outs, *_tail_outs],
1272
  )
1273
 
src/ui/fishbowl/assets/styles.css CHANGED
@@ -654,6 +654,11 @@ footer { display: none !important; }
654
  @keyframes coreFloat { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-6px); } }
655
  .fishbowl .core-title { font-size: 16px; letter-spacing: 0.1em; color: var(--ink); }
656
  .fishbowl .core-round { font-size: 9px; color: var(--ink-dim); }
 
 
 
 
 
657
  .fishbowl .ring { display: contents; }
658
  .fishbowl .ring-slot { display: grid; place-items: center; z-index: 2; }
659
  .fishbowl .show-constellation .ring-slot:nth-child(1) { grid-area: 1 / 2; }
@@ -1433,3 +1438,118 @@ footer { display: none !important; }
1433
  line-height: 1.5;
1434
  max-width: 46ch;
1435
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
654
  @keyframes coreFloat { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-6px); } }
655
  .fishbowl .core-title { font-size: 16px; letter-spacing: 0.1em; color: var(--ink); }
656
  .fishbowl .core-round { font-size: 9px; color: var(--ink-dim); }
657
+ /* Audience-only secret peek (Twenty Sprouts) — a dashed "director's view" chip the cast
658
+ never sees. Dimmed by default so it reads as a backstage aside, brightening the held
659
+ word so the human can actually read it at a glance. */
660
+ .fishbowl .core-secret { margin-top: 4px; font-size: 9px; color: var(--ink-faint); border: 1px dashed var(--ink-faint); border-radius: 999px; padding: 3px 9px; background: color-mix(in srgb, var(--cyan) 6%, transparent); pointer-events: auto; }
661
+ .fishbowl .core-secret-word { color: var(--cyan); letter-spacing: 0.14em; text-shadow: var(--glow-hot); }
662
  .fishbowl .ring { display: contents; }
663
  .fishbowl .ring-slot { display: grid; place-items: center; z-index: 2; }
664
  .fishbowl .show-constellation .ring-slot:nth-child(1) { grid-area: 1 / 2; }
 
1438
  line-height: 1.5;
1439
  max-width: 46ch;
1440
  }
1441
+
1442
+ /* ============================================================
1443
+ ✦ LIVE-SHOW UX — auto-start cue, single-control transport, loud end-card
1444
+ ------------------------------------------------------------
1445
+ Three small surfaces, big clarity wins:
1446
+ 1. a broadcast "lower third" naming who we're waiting on — model calls
1447
+ take seconds, and silence reads as a freeze;
1448
+ 2. the lone Play/Pause hero button reads as the one thing to press;
1449
+ 3. the end-of-show card is impossible to miss (it used to inherit as ~12px
1450
+ text with no card of its own).
1451
+ ============================================================ */
1452
+
1453
+ /* ---- 1 · "who's thinking" badge — pinned to the TOP of the stage so it's in view
1454
+ the instant a model starts working, no scrolling required (it used to sit at the
1455
+ bottom of a tall stage and slip below the fold) ---- */
1456
+ .fishbowl.fb-stage { position: relative; }
1457
+ .fishbowl .thinking-strip {
1458
+ position: sticky; top: 10px; z-index: 40; pointer-events: none;
1459
+ width: fit-content; max-width: calc(100% - 24px); margin: 2px auto 12px;
1460
+ display: flex; align-items: center; gap: 11px;
1461
+ padding: 9px 17px 9px 14px;
1462
+ font-family: var(--font-display); font-size: 11.5px; letter-spacing: 0.06em;
1463
+ color: var(--cyan);
1464
+ background: rgba(6, 20, 27, 0.94);
1465
+ border: 1px solid var(--line-hot, rgba(79, 230, 210, 0.5));
1466
+ border-radius: 999px;
1467
+ box-shadow: 0 10px 34px -12px rgba(0, 0, 0, 0.85), 0 0 22px rgba(79, 230, 210, 0.3);
1468
+ backdrop-filter: blur(8px);
1469
+ animation: tsIn 0.35s ease both;
1470
+ }
1471
+ @keyframes tsIn { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: none; } }
1472
+ .fishbowl .thinking-strip .ts-orb {
1473
+ width: 9px; height: 9px; border-radius: 50%; flex: none;
1474
+ background: var(--cyan); box-shadow: 0 0 10px var(--cyan), inset 0 0 4px #fff;
1475
+ animation: tsPulse 1.1s ease-in-out infinite;
1476
+ }
1477
+ @keyframes tsPulse { 0%, 100% { opacity: 0.45; transform: scale(0.8); } 50% { opacity: 1; transform: scale(1.15); } }
1478
+ .fishbowl .thinking-strip .ts-label {
1479
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
1480
+ text-shadow: 0 0 10px rgba(79, 230, 210, 0.4);
1481
+ }
1482
+ .fishbowl .thinking-strip .ts-dots { display: inline-flex; gap: 3px; flex: none; }
1483
+ .fishbowl .thinking-strip .ts-dots i {
1484
+ width: 4px; height: 4px; border-radius: 50%; background: var(--cyan); opacity: 0.3;
1485
+ animation: tsDot 1.1s ease-in-out infinite;
1486
+ }
1487
+ .fishbowl .thinking-strip .ts-dots i:nth-child(2) { animation-delay: 0.18s; }
1488
+ .fishbowl .thinking-strip .ts-dots i:nth-child(3) { animation-delay: 0.36s; }
1489
+ @keyframes tsDot { 0%, 100% { opacity: 0.25; } 50% { opacity: 1; } }
1490
+
1491
+ /* ---- 2 · single Play/Pause hero button — the obvious thing to press, up in the
1492
+ showbar (native gr.Button → reach it via the container, not the .fishbowl scope) ---- */
1493
+ .gradio-container .play-hero,
1494
+ .gradio-container .play-hero button {
1495
+ min-width: 132px !important;
1496
+ padding: 10px 22px !important;
1497
+ font-family: var(--font-display) !important;
1498
+ font-size: 13px !important; font-weight: 800 !important;
1499
+ letter-spacing: 0.16em !important; text-transform: uppercase !important;
1500
+ color: #04221c !important;
1501
+ background: linear-gradient(180deg, var(--cyan), #2bc4b4) !important;
1502
+ border: 1px solid var(--cyan) !important; border-radius: 999px !important;
1503
+ box-shadow: 0 0 20px rgba(79, 230, 210, 0.5) !important;
1504
+ animation: playBreath 2.4s ease-in-out infinite;
1505
+ }
1506
+ @keyframes playBreath {
1507
+ 0%, 100% { box-shadow: 0 0 16px rgba(79, 230, 210, 0.38); }
1508
+ 50% { box-shadow: 0 0 28px rgba(79, 230, 210, 0.72); }
1509
+ }
1510
+ .gradio-container .play-hero:hover,
1511
+ .gradio-container .play-hero button:hover {
1512
+ box-shadow: 0 0 32px rgba(79, 230, 210, 0.85) !important;
1513
+ filter: brightness(1.1) !important;
1514
+ }
1515
+ @media (prefers-reduced-motion: reduce) {
1516
+ .gradio-container .play-hero,
1517
+ .gradio-container .play-hero button { animation: none; }
1518
+ }
1519
+
1520
+ /* ---- 3 · the end-of-show card (verdict ruling + STOPPED both reuse .verdict.banner) ---- */
1521
+ .fishbowl .verdict.banner {
1522
+ position: relative;
1523
+ display: flex; flex-direction: column; align-items: center; gap: 10px;
1524
+ margin: 14px auto; max-width: 560px; padding: 22px 30px 24px; text-align: center;
1525
+ border: 1px solid var(--lime); border-radius: 16px;
1526
+ background:
1527
+ radial-gradient(120% 90% at 50% -10%, rgba(155, 232, 107, 0.18), transparent 62%),
1528
+ rgba(6, 20, 27, 0.96);
1529
+ box-shadow: 0 0 50px rgba(155, 232, 107, 0.32), inset 0 0 30px rgba(155, 232, 107, 0.08);
1530
+ backdrop-filter: blur(10px);
1531
+ animation: endCard 0.55s cubic-bezier(0.18, 1.3, 0.35, 1) both;
1532
+ }
1533
+ @keyframes endCard { from { opacity: 0; transform: translateY(-14px) scale(0.96); } to { opacity: 1; transform: none; } }
1534
+ .fishbowl .verdict.banner .eyebrow {
1535
+ font-size: 12px; letter-spacing: 0.34em; color: var(--lime);
1536
+ text-shadow: 0 0 14px rgba(155, 232, 107, 0.6);
1537
+ }
1538
+ .fishbowl .verdict.banner .vb-text { font-size: 15.5px; line-height: 1.5; color: var(--ink); max-width: 48ch; }
1539
+ .fishbowl .verdict.banner .vb-winner { font-size: 18px; letter-spacing: 0.12em; }
1540
+ /* the STOPPED / ended-without-verdict variant: coral, not celebratory lime */
1541
+ .fishbowl .verdict.banner.stopped {
1542
+ border-color: var(--coral);
1543
+ background:
1544
+ radial-gradient(120% 90% at 50% -10%, rgba(255, 143, 125, 0.16), transparent 62%),
1545
+ rgba(6, 20, 27, 0.96);
1546
+ box-shadow: 0 0 50px rgba(255, 143, 125, 0.28), inset 0 0 30px rgba(255, 143, 125, 0.08);
1547
+ }
1548
+ .fishbowl .verdict.banner.stopped .eyebrow { color: var(--coral); text-shadow: 0 0 14px rgba(255, 143, 125, 0.6); }
1549
+
1550
+ @media (prefers-reduced-motion: reduce) {
1551
+ .fishbowl .thinking-strip,
1552
+ .fishbowl .thinking-strip .ts-orb,
1553
+ .fishbowl .thinking-strip .ts-dots i,
1554
+ .fishbowl .verdict.banner { animation: none; }
1555
+ }
src/ui/fishbowl/render/stage.py CHANGED
@@ -39,6 +39,27 @@ def _core(vm: dict) -> str:
39
  f'<div class="core-glyph disp">{glyph}</div>'
40
  f'<div class="core-title disp">{scene}</div>'
41
  f'<div class="core-round eyebrow">{html.escape(round_text)}</div>'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  "</div>"
43
  )
44
 
 
39
  f'<div class="core-glyph disp">{glyph}</div>'
40
  f'<div class="core-title disp">{scene}</div>'
41
  f'<div class="core-round eyebrow">{html.escape(round_text)}</div>'
42
+ f"{_secret_badge(vm)}"
43
+ "</div>"
44
+ )
45
+
46
+
47
+ def _secret_badge(vm: dict) -> str:
48
+ """Audience-only reveal of a hidden-word run's secret (Twenty Sprouts).
49
+
50
+ The view model carries ``secret`` only for hidden-word scenarios, and only the human
51
+ watching the show ever sees it — it is never placed in any agent's prompt (see
52
+ ``view_model.py``). Empty string for every other run, so the core is unchanged
53
+ elsewhere. The 🔒 + "only you can see this" framing tells the viewer it's a peek
54
+ behind the curtain, not something the cast knows.
55
+ """
56
+ secret = vm.get("secret")
57
+ if not secret:
58
+ return ""
59
+ return (
60
+ '<div class="core-secret eyebrow" title="The cast cannot see this — it never enters their prompts.">'
61
+ "🔒 only you can see · the keeper holds "
62
+ f'<b class="core-secret-word disp">{html.escape(str(secret))}</b>'
63
  "</div>"
64
  )
65
 
src/ui/fishbowl/session.py CHANGED
@@ -112,6 +112,13 @@ class FishbowlSession:
112
  its own (no extra token spend)."""
113
  return any(e.kind == "judge.verdict" for e in self.conductor.ledger.events_for_run(self.conductor.run_id))
114
 
 
 
 
 
 
 
 
115
  def has_judge(self) -> bool:
116
  """True when this cast has a judge that can be asked to rule.
117
 
@@ -293,6 +300,10 @@ class ReplaySession:
293
  # recorded run may already carry its verdict. Report on the recording.
294
  return self.has_verdict()
295
 
 
 
 
 
296
  def force_verdict(self) -> bool: # pragma: no cover - inert by design
297
  # A replay is read-only: nothing to judge, the recording stands as-is.
298
  return self.has_verdict()
 
112
  its own (no extra token spend)."""
113
  return any(e.kind == "judge.verdict" for e in self.conductor.ledger.events_for_run(self.conductor.run_id))
114
 
115
+ def peek_next_actor_name(self) -> str | None:
116
+ """Best-effort name of whoever the next :meth:`step_one` will run.
117
+
118
+ Feeds the Show's "who's thinking…" hint while a model call is in flight; a pure
119
+ read that never advances the run. Returns ``None`` when nothing is queued."""
120
+ return self.conductor.peek_next_actor_name()
121
+
122
  def has_judge(self) -> bool:
123
  """True when this cast has a judge that can be asked to rule.
124
 
 
300
  # recorded run may already carry its verdict. Report on the recording.
301
  return self.has_verdict()
302
 
303
+ def peek_next_actor_name(self) -> str | None: # pragma: no cover - inert by design
304
+ # A replay never generates, so nobody is ever "thinking" — no hint to show.
305
+ return None
306
+
307
  def force_verdict(self) -> bool: # pragma: no cover - inert by design
308
  # A replay is read-only: nothing to judge, the recording stands as-is.
309
  return self.has_verdict()
src/ui/fishbowl/show.py CHANGED
@@ -33,11 +33,20 @@ def build_show() -> dict[str, object]:
33
  handles: dict[str, object] = {}
34
 
35
  with gr.Column(elem_id="show", elem_classes=["show"]):
36
- # ---- SHOW BAR : back-to-lab · mind-reader · layout selector -------
 
 
 
 
37
  with gr.Row(elem_classes=["showbar"]):
38
- handles["back_to_lab"] = gr.Button(
39
- "← back to the Lab",
40
- elem_classes=["icon-btn", "back-to-lab"],
 
 
 
 
 
41
  scale=0,
42
  )
43
  handles["mind_reader"] = gr.Checkbox(
@@ -101,37 +110,10 @@ def build_show() -> dict[str, object]:
101
  scale=0,
102
  )
103
 
104
- # ---- TRANSPORT : ⏮/▶/⏭ · scrubber · speed · autoplay clock -------
105
- with gr.Row(elem_classes=["transport"]):
106
- with gr.Row(elem_classes=["tp-btns"]):
107
- handles["back_btn"] = gr.Button("⏮", elem_classes=["icon-btn"], scale=0)
108
- handles["play_btn"] = gr.Button("▶", elem_classes=["icon-btn", "play"], scale=0)
109
- handles["fwd_btn"] = gr.Button("⏭", elem_classes=["icon-btn"], scale=0)
110
- # Curtain call: stop the cast and hand the floor to the judge, which
111
- # reads the whole ledger and rules. The app shell wires it to
112
- # ``force_verdict`` and disables it for casts with no judge.
113
- handles["judge_btn"] = gr.Button(
114
- "⚖ Start judging",
115
- elem_classes=["icon-btn", "judge-now"],
116
- scale=0,
117
- )
118
- handles["step_slider"] = gr.Slider(
119
- minimum=0,
120
- maximum=1,
121
- value=0,
122
- step=1,
123
- label="step",
124
- elem_classes=["scrub"],
125
- scale=4,
126
- )
127
- handles["speed"] = gr.Radio(
128
- ["live", "1×", "fast"],
129
- value="1×",
130
- label="speed",
131
- elem_classes=["seg", "speed-seg"],
132
- )
133
-
134
  # ---- AUTOPLAY CLOCK : app shell toggles ``active`` + interval -----
 
 
 
135
  handles["timer"] = gr.Timer(value=1.9, active=False)
136
 
137
  return handles
 
33
  handles: dict[str, object] = {}
34
 
35
  with gr.Column(elem_id="show", elem_classes=["show"]):
36
+ # ---- SHOW BAR : PLAY/PAUSE · RESTART · mind-reader · layout -------
37
+ # The Play/Pause control rides up here at the top of the Show, front-and-centre:
38
+ # it is the one thing a visitor needs. Its label flips "▶ Play"⇄"❚❚ Pause"; the
39
+ # app shell owns the toggle, auto-starts it on Summon, and begins the conversation
40
+ # the instant it's pressed. Restart wipes the run and begins a fresh context.
41
  with gr.Row(elem_classes=["showbar"]):
42
+ handles["play_btn"] = gr.Button(
43
+ "▶ Play",
44
+ elem_classes=["icon-btn", "play", "play-hero"],
45
+ scale=0,
46
+ )
47
+ handles["restart_btn"] = gr.Button(
48
+ "↺ Restart",
49
+ elem_classes=["icon-btn", "restart-btn"],
50
  scale=0,
51
  )
52
  handles["mind_reader"] = gr.Checkbox(
 
110
  scale=0,
111
  )
112
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  # ---- AUTOPLAY CLOCK : app shell toggles ``active`` + interval -----
114
+ # Play/Pause + Restart live in the showbar; the manual "start judging" and speed
115
+ # controls are gone — the judge is brought on automatically at the end of a run,
116
+ # and the clock runs at a single comfortable cadence.
117
  handles["timer"] = gr.Timer(value=1.9, active=False)
118
 
119
  return handles
src/ui/fishbowl/view_model.py CHANGED
@@ -128,6 +128,14 @@ def view_model_at(
128
  competition = next((e.payload.get("competition") for e in prefix if e.kind == "run.started"), None) or {}
129
  teams = competition.get("teams") or {}
130
 
 
 
 
 
 
 
 
 
131
  verdict = None
132
  for e in prefix:
133
  if e.kind == "judge.verdict":
@@ -167,6 +175,10 @@ def view_model_at(
167
  "voice": chosen_voice,
168
  "voice_meta": {"name": voice_name, "desc": voice_desc},
169
  "speaking_id": speaking_id,
 
 
 
 
170
  "verdict": verdict,
171
  # The winning side's roster (ADR-0029) — lets the champion celebration line up a
172
  # team's members as chips. Empty for symmetric/judged/none-kind runs.
 
128
  competition = next((e.payload.get("competition") for e in prefix if e.kind == "run.started"), None) or {}
129
  teams = competition.get("teams") or {}
130
 
131
+ # Audience-only ground truth: a hidden-word scenario (Twenty Sprouts) deals its
132
+ # secret onto the keeper's events as a private ``secret`` payload key. The engine
133
+ # never surfaces it to any agent's prompt (``_displayable`` shows ``text`` only), but
134
+ # the human watching the show should see what the keeper is holding — so we lift the
135
+ # latest one into the view model for the stage to render. Empty for every other run.
136
+ secret = next((e.payload["secret"] for e in reversed(prefix) if e.payload.get("secret")), None)
137
+ secret_holder = next((e.actor for e in reversed(prefix) if e.payload.get("secret")), None) if secret else None
138
+
139
  verdict = None
140
  for e in prefix:
141
  if e.kind == "judge.verdict":
 
175
  "voice": chosen_voice,
176
  "voice_meta": {"name": voice_name, "desc": voice_desc},
177
  "speaking_id": speaking_id,
178
+ # Audience-only secret (Twenty Sprouts); None for every other scenario. Rendered on
179
+ # the stage for the human, never placed in an agent's context.
180
+ "secret": secret,
181
+ "secret_holder": secret_holder,
182
  "verdict": verdict,
183
  # The winning side's roster (ADR-0029) — lets the champion celebration line up a
184
  # team's members as chips. Empty for symmetric/judged/none-kind runs.
tests/test_conductor.py CHANGED
@@ -225,3 +225,33 @@ class TestConductorStepOne:
225
  assert c.step_one() is True
226
  kinds = {e.kind for e in c.ledger.events}
227
  assert "run.started" in kinds and "world.observed" in kinds
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  assert c.step_one() is True
226
  kinds = {e.kind for e in c.ledger.events}
227
  assert "run.started" in kinds and "world.observed" in kinds
228
+
229
+
230
+ class TestPeekNextActor:
231
+ """``peek_next_actor_name`` powers the Show's "who's thinking…" hint: a pure read
232
+ that names whoever the next ``step_one`` will run, without advancing the run."""
233
+
234
+ def test_peeks_the_first_queued_agent(self):
235
+ # Two tick-every-1 agents: after the first step_one pops the first, the second
236
+ # sits queued for this same turn — peek must name it, not re-open a turn.
237
+ scenario = Scenario(name="s", default_seed="seed", agents=(_SpeakingAgent(), _ExplodingAgent()))
238
+ c = Conductor(scenario=scenario, governor=Governor())
239
+ c.reset("seed")
240
+ c.step_one() # pops "speaker"; "boom" remains queued for turn 1
241
+ assert c.peek_next_actor_name() == "boom"
242
+
243
+ def test_peek_does_not_advance_the_run(self):
244
+ scenario = Scenario(name="s", default_seed="seed", agents=(_SpeakingAgent(),))
245
+ c = Conductor(scenario=scenario, governor=Governor())
246
+ c.reset("seed")
247
+ before_events, before_turn = len(c.ledger.events), c.turn
248
+ c.peek_next_actor_name()
249
+ c.peek_next_actor_name()
250
+ assert (len(c.ledger.events), c.turn) == (before_events, before_turn)
251
+
252
+ def test_peeks_next_turns_tick_actor_when_queue_is_empty(self):
253
+ # Queue drained between turns: peek looks ahead to who ticks on turn+1.
254
+ scenario = Scenario(name="s", default_seed="seed", agents=(_SpeakingAgent(),))
255
+ c = Conductor(scenario=scenario, governor=Governor())
256
+ c.reset("seed") # turn 0, nothing queued; "speaker" ticks every turn
257
+ assert c.peek_next_actor_name() == "speaker"
tests/test_context.py CHANGED
@@ -36,6 +36,77 @@ def test_persona_and_goal_still_lead():
36
  assert prompt.index("I am A.") < prompt.index("catch the spy") < prompt.index("WHAT'S BEEN SAID")
37
 
38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  def test_a_peer_thought_never_reaches_another_agent():
40
  # A spoken event carries a private `thought` (the mind-reader content). It must
41
  # ride only on its own payload — peers see the public `text`, never the thought.
 
36
  assert prompt.index("I am A.") < prompt.index("catch the spy") < prompt.index("WHAT'S BEEN SAID")
37
 
38
 
39
+ def _spoke(actor, text, turn=1, kind="agent.spoke"):
40
+ from src.core.events import Event
41
+
42
+ return Event(run_id="r", turn=turn, kind=kind, actor=actor, payload={"text": text})
43
+
44
+
45
+ def test_judge_gets_the_full_ordered_transcript_not_just_the_tail():
46
+ # A judge rules on the WHOLE discussion — every spoken line, in order — not the
47
+ # recency-biased blackboard tail a worker reacts to (ADR-0023 follow-up).
48
+ events = tuple(_spoke("debater-a" if i % 2 == 0 else "debater-b", f"point number {i}", turn=i) for i in range(12))
49
+ proj = StageProjection(goal="g", current_scene="scene")
50
+ prompt = ContextBuilder().build(
51
+ agent_name="debate-judge", persona="(judge)", projection=proj, all_events=events, role="judge"
52
+ )
53
+ assert "THE EXCHANGE TO JUDGE" in prompt and "WHAT'S BEEN SAID" not in prompt
54
+ # every line is present, oldest first
55
+ assert "point number 0" in prompt and "point number 11" in prompt
56
+ assert prompt.index("point number 0") < prompt.index("point number 11")
57
+
58
+
59
+ def test_judge_transcript_excludes_private_thoughts():
60
+ # Thoughts are the mind-reader's alone; a judge rules on what was SAID.
61
+ events = (_spoke("a", "said aloud"), _spoke("b", "secret scheming", kind="agent.thought"))
62
+ prompt = ContextBuilder().build(
63
+ agent_name="j",
64
+ persona="(judge)",
65
+ projection=StageProjection(current_scene="s"),
66
+ all_events=events,
67
+ role="judge",
68
+ )
69
+ assert "said aloud" in prompt
70
+ assert "secret scheming" not in prompt
71
+
72
+
73
+ def test_worker_gets_blackboard_not_transcript():
74
+ proj = StageProjection(current_scene="scene")
75
+ proj.agent_notes = ["a: hello there"]
76
+ prompt = ContextBuilder().build(
77
+ agent_name="w", persona="(w)", projection=proj, all_events=(_spoke("a", "hello there"),), role="worker"
78
+ )
79
+ assert "WHAT'S BEEN SAID" in prompt and "THE EXCHANGE TO JUDGE" not in prompt
80
+
81
+
82
+ def test_memory_does_not_repeat_a_line_already_in_the_discussion():
83
+ # A spoken line shown in the blackboard must not be printed again in YOUR MEMORY:
84
+ # the union is unchanged, we just don't duplicate (blackboard=recent, memory=earlier).
85
+ proj = StageProjection(current_scene="scene")
86
+ proj.agent_notes = ["a: the bench needs shade"]
87
+ memory_text = "[turn 003][agent.spoke] the bench needs shade\n[turn 001][world.observed] an older beat"
88
+ prompt = ContextBuilder().build(
89
+ agent_name="w", persona="(w)", projection=proj, all_events=(), memory_text=memory_text, role="worker"
90
+ )
91
+ # the duplicated line appears once (in the blackboard), the unique earlier beat survives in memory
92
+ assert prompt.count("the bench needs shade") == 1
93
+ assert "an older beat" in prompt
94
+
95
+
96
+ def test_memory_shows_a_pointer_when_fully_covered_by_the_discussion():
97
+ proj = StageProjection(current_scene="scene")
98
+ proj.agent_notes = ["a: only line"]
99
+ prompt = ContextBuilder().build(
100
+ agent_name="w",
101
+ persona="(w)",
102
+ projection=proj,
103
+ all_events=(),
104
+ memory_text="[turn 003][agent.spoke] only line",
105
+ role="worker",
106
+ )
107
+ assert "nothing beyond the exchange above" in prompt
108
+
109
+
110
  def test_a_peer_thought_never_reaches_another_agent():
111
  # A spoken event carries a private `thought` (the mind-reader content). It must
112
  # ride only on its own payload — peers see the public `text`, never the thought.
tests/test_engine.py CHANGED
@@ -31,4 +31,3 @@ def test_conductor_runs_vertical_slice() -> None:
31
 
32
  assert len(conductor.ledger.events) >= 3
33
  assert conductor.projection.current_scene != "The curtain has not risen."
34
-
 
31
 
32
  assert len(conductor.ledger.events) >= 3
33
  assert conductor.projection.current_scene != "The curtain has not risen."
 
tests/test_fishbowl_app.py CHANGED
@@ -90,6 +90,34 @@ def test_render_show_html_returns_four_panes() -> None:
90
  assert isinstance(stage, str)
91
 
92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  def test_inject_appends_a_poke() -> None:
94
  from src.ui.fishbowl.app import SCENARIOS, _new_session
95
 
 
90
  assert isinstance(stage, str)
91
 
92
 
93
+ def test_render_show_html_overlays_thinking_strip_on_stage() -> None:
94
+ # The "who's thinking…" hint rides on the always-visible stage pane (not the feed,
95
+ # which resets its scroll on every re-render). It appears only when asked.
96
+ from src.ui.fishbowl.app import SCENARIOS, _new_session, render_show_html
97
+
98
+ session = _new_session(next(iter(SCENARIOS.values())))
99
+ session.reset("")
100
+ vm = session.snapshot(session.head)
101
+
102
+ stage_plain, *_ = render_show_html(vm, layout="constellation")
103
+ assert "thinking-strip" not in stage_plain
104
+
105
+ stage_hint, *_ = render_show_html(vm, layout="constellation", thinking="scene-whisperer is thinking")
106
+ assert "thinking-strip" in stage_hint
107
+ assert "scene-whisperer is thinking" in stage_hint
108
+ # Prepended (first child) so position:sticky pins it to the TOP of the stage — in
109
+ # view without scrolling, rather than below the fold at the bottom.
110
+ assert stage_hint.index("thinking-strip") < stage_hint.index('class="constellation"')
111
+
112
+
113
+ def test_thinking_strip_escapes_its_label() -> None:
114
+ from src.ui.fishbowl.app import _thinking_strip
115
+
116
+ html = _thinking_strip("<script>alert(1)</script>")
117
+ assert "<script>" not in html
118
+ assert "&lt;script&gt;" in html
119
+
120
+
121
  def test_inject_appends_a_poke() -> None:
122
  from src.ui.fishbowl.app import SCENARIOS, _new_session
123
 
tests/test_fishbowl_loop_safety.py CHANGED
@@ -146,7 +146,8 @@ def test_autoplay_streams_one_agent_per_advance() -> None:
146
  def test_stopped_banner_reuses_verdict_chrome() -> None:
147
  html = fb_app._stopped_banner_html("Total call cap 3 reached")
148
  assert "verdict banner" in html # reuses the existing banner CSS class
149
- assert "Stopped" in html
 
150
  assert "Total call cap 3 reached" in html # the reason is shown
151
 
152
 
 
146
  def test_stopped_banner_reuses_verdict_chrome() -> None:
147
  html = fb_app._stopped_banner_html("Total call cap 3 reached")
148
  assert "verdict banner" in html # reuses the existing banner CSS class
149
+ assert "banner stopped" in html # the coral end-card variant (distinct from a lime verdict)
150
+ assert "The show has ended" in html # a loud, unmissable end-of-show headline
151
  assert "Total call cap 3 reached" in html # the reason is shown
152
 
153
 
tests/test_fishbowl_show.py CHANGED
@@ -26,16 +26,12 @@ def test_returns_expected_keys() -> None:
26
  "verdict_html",
27
  "layout",
28
  "mind_reader",
29
- "step_slider",
30
- "back_btn",
31
  "play_btn",
32
- "fwd_btn",
33
- "speed",
34
  "timer",
35
  "poke_btns",
36
  "poke_text",
37
  "poke_send",
38
- "back_to_lab",
39
  }
40
  assert expected <= set(handles)
41
 
@@ -49,7 +45,6 @@ def test_recipe_subset() -> None:
49
  "meters_html",
50
  "layout",
51
  "mind_reader",
52
- "step_slider",
53
  "play_btn",
54
  "timer",
55
  } <= set(handles)
@@ -80,26 +75,33 @@ def test_mind_reader_checkbox_defaults_sealed() -> None:
80
  assert mr.value is False
81
 
82
 
83
- def test_step_slider_is_scrubber() -> None:
 
 
 
84
  handles = _build()
85
- slider = handles["step_slider"]
86
- assert isinstance(slider, gr.Slider)
87
- assert slider.value == 0
88
- assert slider.minimum == 0
89
- assert slider.step == 1
90
 
91
 
92
- def test_speed_radio_options() -> None:
93
  handles = _build()
94
- speed = handles["speed"]
95
- assert isinstance(speed, gr.Radio)
96
- assert speed.value == "1×"
97
- assert [c[0] for c in speed.choices] == ["live", "1×", "fast"]
 
 
 
 
 
 
 
98
 
99
 
100
  def test_transport_buttons_are_buttons() -> None:
101
  handles = _build()
102
- for key in ("back_btn", "play_btn", "fwd_btn", "back_to_lab", "poke_send"):
103
  assert isinstance(handles[key], gr.Button)
104
 
105
 
 
26
  "verdict_html",
27
  "layout",
28
  "mind_reader",
 
 
29
  "play_btn",
30
+ "restart_btn",
 
31
  "timer",
32
  "poke_btns",
33
  "poke_text",
34
  "poke_send",
 
35
  }
36
  assert expected <= set(handles)
37
 
 
45
  "meters_html",
46
  "layout",
47
  "mind_reader",
 
48
  "play_btn",
49
  "timer",
50
  } <= set(handles)
 
75
  assert mr.value is False
76
 
77
 
78
+ def test_pared_back_transport_controls_removed() -> None:
79
+ # The Show is down to Play/Pause + Restart in the showbar: the step buttons,
80
+ # scrubber, speed radio, manual judge, and back-to-Lab button were all removed
81
+ # (tab nav covers Lab; the judge is now auto-summoned at the end of a run).
82
  handles = _build()
83
+ for gone in ("step_slider", "back_btn", "fwd_btn", "speed", "judge_btn", "back_to_lab"):
84
+ assert gone not in handles
 
 
 
85
 
86
 
87
+ def test_play_button_defaults_to_play_label() -> None:
88
  handles = _build()
89
+ play = handles["play_btn"]
90
+ assert isinstance(play, gr.Button)
91
+ # Starts paused: the app shell flips it to "❚❚ Pause" on Summon (auto-start).
92
+ assert play.value == "▶ Play"
93
+
94
+
95
+ def test_restart_button_present() -> None:
96
+ handles = _build()
97
+ restart = handles["restart_btn"]
98
+ assert isinstance(restart, gr.Button)
99
+ assert "Restart" in restart.value
100
 
101
 
102
  def test_transport_buttons_are_buttons() -> None:
103
  handles = _build()
104
+ for key in ("play_btn", "restart_btn", "poke_send"):
105
  assert isinstance(handles[key], gr.Button)
106
 
107
 
tests/test_fishbowl_winner.py CHANGED
@@ -44,10 +44,20 @@ class TestAgentWin:
44
  def _vm(self, **over) -> dict:
45
  vm = {
46
  "cast": [
47
- {"id": "hypothesis-former", "name": "hypothesis-former", "archetype": "the analyst",
48
- "model": "Arch-Router-1.5B", "hue": 200},
49
- {"id": "devil-advocate", "name": "devil-advocate", "archetype": "the skeptic",
50
- "model": "Qwen", "hue": 20},
 
 
 
 
 
 
 
 
 
 
51
  ],
52
  "teams": {},
53
  "verdict": {
@@ -126,6 +136,10 @@ class TestTeamWin:
126
 
127
  class TestEscaping:
128
  def test_label_is_escaped(self):
129
- vm = {"verdict": {"winner": "x", "winner_label": "<script>x</script>", "winner_kind": "agent"}, "cast": [], "teams": {}}
 
 
 
 
130
  html = render_winner(vm)
131
  assert "<script>" not in html and "&lt;script&gt;" in html
 
44
  def _vm(self, **over) -> dict:
45
  vm = {
46
  "cast": [
47
+ {
48
+ "id": "hypothesis-former",
49
+ "name": "hypothesis-former",
50
+ "archetype": "the analyst",
51
+ "model": "Arch-Router-1.5B",
52
+ "hue": 200,
53
+ },
54
+ {
55
+ "id": "devil-advocate",
56
+ "name": "devil-advocate",
57
+ "archetype": "the skeptic",
58
+ "model": "Qwen",
59
+ "hue": 20,
60
+ },
61
  ],
62
  "teams": {},
63
  "verdict": {
 
136
 
137
  class TestEscaping:
138
  def test_label_is_escaped(self):
139
+ vm = {
140
+ "verdict": {"winner": "x", "winner_label": "<script>x</script>", "winner_kind": "agent"},
141
+ "cast": [],
142
+ "teams": {},
143
+ }
144
  html = render_winner(vm)
145
  assert "<script>" not in html and "&lt;script&gt;" in html
tests/test_instructor.py CHANGED
@@ -11,6 +11,7 @@ No network and no real credentials. Three layers are covered:
11
  ``complete_structured`` (validated payload, no ``_raw_fallback``) and the
12
  tolerant-parser path with the deterministic stub.
13
  """
 
14
  from __future__ import annotations
15
 
16
  import sys
 
11
  ``complete_structured`` (validated payload, no ``_raw_fallback``) and the
12
  tolerant-parser path with the deterministic stub.
13
  """
14
+
15
  from __future__ import annotations
16
 
17
  import sys
tests/test_journal.py CHANGED
@@ -7,4 +7,3 @@ def test_journal_scripts_exist() -> None:
7
  assert (root / "scripts" / "new_journal_entry.py").exists()
8
  assert (root / "scripts" / "snapshot_progress.py").exists()
9
  assert (root / "docs" / "blog" / "building-in-public.md").exists()
10
-
 
7
  assert (root / "scripts" / "new_journal_entry.py").exists()
8
  assert (root / "scripts" / "snapshot_progress.py").exists()
9
  assert (root / "docs" / "blog" / "building-in-public.md").exists()
 
tests/test_mcp.py CHANGED
@@ -11,6 +11,7 @@ Three tiers, mirroring the optional-dependency tests elsewhere:
11
  * A guarded stdio round-trip asserts ``oracle`` returns an omen over MCP
12
  (skipped if ``mcp`` is absent or the server can't be spawned quickly).
13
  """
 
14
  from __future__ import annotations
15
 
16
  import pytest
@@ -42,6 +43,7 @@ class _SpyResolver:
42
 
43
  # ── capability enforcement wraps the transport (no mcp required) ────────────────
44
 
 
45
  class TestCapabilityWrapsTransport:
46
  def test_denied_grant_raises_before_resolver_is_touched(self):
47
  """The grant check fires before MCP dispatch, regardless of transport."""
@@ -99,6 +101,7 @@ class TestCapabilityWrapsTransport:
99
 
100
  # ── the config gate flips in-process ↔ MCP (no mcp required) ────────────────────
101
 
 
102
  class TestConfigGate:
103
  def test_default_is_in_process(self, monkeypatch):
104
  monkeypatch.delenv("MCP_SERVERS", raising=False)
@@ -123,9 +126,7 @@ class TestConfigGate:
123
  def test_mcp_servers_gate_parses_multiple(self):
124
  from src.tools.mcp_client import server_configs_from_env
125
 
126
- configs = server_configs_from_env(
127
- {"MCP_SERVERS": "python -m src.tools.mcp_server :: node other.js --flag"}
128
- )
129
  assert len(configs) == 2
130
  assert configs[0].command == "python"
131
  assert configs[1].command == "node"
@@ -134,9 +135,7 @@ class TestConfigGate:
134
  def test_mcp_servers_takes_precedence_over_oracle_flag(self):
135
  from src.tools.mcp_client import server_configs_from_env
136
 
137
- configs = server_configs_from_env(
138
- {"MCP_SERVERS": "python -m custom.server", "MCP_ORACLE": "1"}
139
- )
140
  assert len(configs) == 1
141
  assert configs[0].args == ("-m", "custom.server")
142
 
@@ -148,6 +147,7 @@ class TestConfigGate:
148
 
149
  # ── result coercion (no mcp required: pure dataclass shaping) ────────────────────
150
 
 
151
  class TestResultCoercion:
152
  def test_prefers_structured_content(self):
153
  from src.tools.mcp_client import _result_to_dict
@@ -189,6 +189,7 @@ class TestResultCoercion:
189
 
190
  # ── server module registers oracle (requires mcp) ───────────────────────────────
191
 
 
192
  class TestMCPServer:
193
  def test_server_builds_and_registers_oracle(self):
194
  pytest.importorskip("mcp")
@@ -216,15 +217,14 @@ class TestMCPServer:
216
 
217
  # ── guarded stdio round-trip (requires mcp + a spawnable server) ─────────────────
218
 
 
219
  class TestMCPStdioRoundTrip:
220
  def test_oracle_over_stdio(self):
221
  pytest.importorskip("mcp")
222
  from src.tools.builtins import oracle
223
  from src.tools.mcp_client import MCPServerConfig, MCPToolClient
224
 
225
- client = MCPToolClient(
226
- server=MCPServerConfig(command="python", args=("-m", "src.tools.mcp_server"))
227
- )
228
  try:
229
  listed = client.list_tools()
230
  except Exception as exc: # pragma: no cover - environment dependent
 
11
  * A guarded stdio round-trip asserts ``oracle`` returns an omen over MCP
12
  (skipped if ``mcp`` is absent or the server can't be spawned quickly).
13
  """
14
+
15
  from __future__ import annotations
16
 
17
  import pytest
 
43
 
44
  # ── capability enforcement wraps the transport (no mcp required) ────────────────
45
 
46
+
47
  class TestCapabilityWrapsTransport:
48
  def test_denied_grant_raises_before_resolver_is_touched(self):
49
  """The grant check fires before MCP dispatch, regardless of transport."""
 
101
 
102
  # ── the config gate flips in-process ↔ MCP (no mcp required) ────────────────────
103
 
104
+
105
  class TestConfigGate:
106
  def test_default_is_in_process(self, monkeypatch):
107
  monkeypatch.delenv("MCP_SERVERS", raising=False)
 
126
  def test_mcp_servers_gate_parses_multiple(self):
127
  from src.tools.mcp_client import server_configs_from_env
128
 
129
+ configs = server_configs_from_env({"MCP_SERVERS": "python -m src.tools.mcp_server :: node other.js --flag"})
 
 
130
  assert len(configs) == 2
131
  assert configs[0].command == "python"
132
  assert configs[1].command == "node"
 
135
  def test_mcp_servers_takes_precedence_over_oracle_flag(self):
136
  from src.tools.mcp_client import server_configs_from_env
137
 
138
+ configs = server_configs_from_env({"MCP_SERVERS": "python -m custom.server", "MCP_ORACLE": "1"})
 
 
139
  assert len(configs) == 1
140
  assert configs[0].args == ("-m", "custom.server")
141
 
 
147
 
148
  # ── result coercion (no mcp required: pure dataclass shaping) ────────────────────
149
 
150
+
151
  class TestResultCoercion:
152
  def test_prefers_structured_content(self):
153
  from src.tools.mcp_client import _result_to_dict
 
189
 
190
  # ── server module registers oracle (requires mcp) ───────────────────────────────
191
 
192
+
193
  class TestMCPServer:
194
  def test_server_builds_and_registers_oracle(self):
195
  pytest.importorskip("mcp")
 
217
 
218
  # ── guarded stdio round-trip (requires mcp + a spawnable server) ─────────────────
219
 
220
+
221
  class TestMCPStdioRoundTrip:
222
  def test_oracle_over_stdio(self):
223
  pytest.importorskip("mcp")
224
  from src.tools.builtins import oracle
225
  from src.tools.mcp_client import MCPServerConfig, MCPToolClient
226
 
227
+ client = MCPToolClient(server=MCPServerConfig(command="python", args=("-m", "src.tools.mcp_server")))
 
 
228
  try:
229
  listed = client.list_tools()
230
  except Exception as exc: # pragma: no cover - environment dependent
tests/test_memory.py CHANGED
@@ -21,11 +21,26 @@ class TestEpisodicMemory:
21
  visible = mem.visible(events)
22
  assert len(visible) == 1
23
 
24
- def test_other_agent_spoke_not_visible(self):
 
 
 
25
  mem = EpisodicMemory("pocket-actor")
26
  events = (_event("agent.spoke", actor="scene-whisperer"),)
27
- visible = mem.visible(events)
28
- assert len(visible) == 0
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
  def test_user_injected_visible_to_all(self):
31
  mem = EpisodicMemory("echo")
 
21
  visible = mem.visible(events)
22
  assert len(visible) == 1
23
 
24
+ def test_other_agent_spoke_is_visible(self):
25
+ # Peers' SPOKEN lines are the shared table — recallable across the whole run, not
26
+ # just this round's blackboard tail (ADR-0023 follow-up). A late-firing judge
27
+ # depends on this to read the discussion it rules on.
28
  mem = EpisodicMemory("pocket-actor")
29
  events = (_event("agent.spoke", actor="scene-whisperer"),)
30
+ assert len(mem.visible(events)) == 1
31
+
32
+ def test_other_agent_thought_not_visible(self):
33
+ # A private thought (the mind-reader content) rides only its own event — peers
34
+ # never read another mind's thinking, even though spoken lines are now shared.
35
+ mem = EpisodicMemory("pocket-actor")
36
+ events = (_event("agent.thought", actor="scene-whisperer"),)
37
+ assert len(mem.visible(events)) == 0
38
+
39
+ def test_oracle_spoke_visible_to_all(self):
40
+ # The custom public-speech kind (oracle-grove) is shared like agent.spoke.
41
+ mem = EpisodicMemory("scene-whisperer")
42
+ events = (_event("oracle.spoke", actor="fortune-teller"),)
43
+ assert len(mem.visible(events)) == 1
44
 
45
  def test_user_injected_visible_to_all(self):
46
  mem = EpisodicMemory("echo")
tests/test_modal_endpoint_urls.py CHANGED
@@ -48,8 +48,7 @@ def test_every_endpoint_url_is_a_valid_dns_host():
48
  url = cat.endpoint_url(e.app, e.endpoint_name, _SAMPLE_WORKSPACE)
49
  label = url.removeprefix("https://").split(".modal.run", 1)[0]
50
  assert len(label) <= _DNS_LABEL_MAX, (
51
- f"{e.key}: DNS label is {len(label)} chars (> {_DNS_LABEL_MAX}); "
52
- f"shorten endpoint_name. label={label!r}"
53
  )
54
 
55
 
 
48
  url = cat.endpoint_url(e.app, e.endpoint_name, _SAMPLE_WORKSPACE)
49
  label = url.removeprefix("https://").split(".modal.run", 1)[0]
50
  assert len(label) <= _DNS_LABEL_MAX, (
51
+ f"{e.key}: DNS label is {len(label)} chars (> {_DNS_LABEL_MAX}); shorten endpoint_name. label={label!r}"
 
52
  )
53
 
54
 
tests/test_modularity.py CHANGED
@@ -6,6 +6,7 @@ The new agent even mints its own namespaced event kind, and it still renders on
6
  stage via the generic projection fallback. If this passes, "config over code" is
7
  real, not aspirational.
8
  """
 
9
  from __future__ import annotations
10
 
11
  from collections import Counter
 
6
  stage via the generic projection fallback. If this passes, "config over code" is
7
  real, not aspirational.
8
  """
9
+
10
  from __future__ import annotations
11
 
12
  from collections import Counter
tests/test_salience_memory.py CHANGED
@@ -88,6 +88,14 @@ class TestReflectionTracker:
88
  events6 = tuple(_event("world.observed", turn=i) for i in range(6))
89
  assert tracker.observe(events6)
90
 
 
 
 
 
 
 
 
 
91
 
92
  class TestKindImportanceTable:
93
  def test_user_injected_high(self):
 
88
  events6 = tuple(_event("world.observed", turn=i) for i in range(6))
89
  assert tracker.observe(events6)
90
 
91
+ def test_peer_speech_does_not_advance_the_reflection_clock(self):
92
+ # Reflection compacts "what I've been through" — its cadence keys on world beats and
93
+ # the agent's own events, NOT how chatty the table got (peers' spoke is recallable
94
+ # but excluded from the reflection count). Three peer lines must not trip threshold 3.
95
+ tracker = ReflectionTracker("a", threshold=3)
96
+ peer_chatter = tuple(_event("agent.spoke", actor="b", turn=i) for i in range(3))
97
+ assert not tracker.observe(peer_chatter)
98
+
99
 
100
  class TestKindImportanceTable:
101
  def test_user_injected_high(self):
tests/test_twenty_sprouts.py ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Twenty Sprouts gameplay — the keeper answers, the guesser builds on the ledger.
2
+
3
+ These exercise the handlers' *pure* logic (Q&A reconstruction, dossier prompts, the
4
+ keeper's answer guard) plus the end-to-end discipline that matters most for a
5
+ hidden-word game: the secret word is dealt by code, shown to the human audience, and
6
+ never placed in any agent's prompt. All offline — zero tokens, zero mocks.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import pytest
12
+
13
+ from src.agents.twenty_sprouts import (
14
+ _WORDS,
15
+ SecretKeeper,
16
+ SproutGuesser,
17
+ _answer_violation,
18
+ _classify_answer,
19
+ _contains_word,
20
+ _qa_history,
21
+ _questions_since_guess,
22
+ _redact_word,
23
+ _word_for_seed,
24
+ )
25
+ from src.core.context import ContextBuilder
26
+ from src.core.conductor import Conductor
27
+ from src.core.events import Event
28
+ from src.core.governor import BudgetExceeded
29
+ from src.core.memory import EpisodicMemory
30
+ from src.core.projections import StageProjection, rebuild_stage
31
+ from src.core.registry import Registry
32
+ from src.models.router import ModelRouter
33
+ from src.tools.builtins import default_tool_registry
34
+ from src.ui.fishbowl.render.stage import render_constellation
35
+ from src.ui.fishbowl.view_model import view_model_at
36
+
37
+ _RUN = "r1"
38
+
39
+
40
+ def _spoke(actor: str, text: str, turn: int = 1) -> Event:
41
+ return Event(run_id=_RUN, turn=turn, kind="agent.spoke", actor=actor, payload={"text": text})
42
+
43
+
44
+ def _qa(*pairs: tuple[str, str | None]) -> tuple[Event, ...]:
45
+ """Build a guesser/keeper transcript from (question, answer|None) pairs."""
46
+ events: list[Event] = []
47
+ for i, (q, a) in enumerate(pairs, start=1):
48
+ events.append(_spoke("sprout-guesser", q, turn=i))
49
+ if a is not None:
50
+ events.append(_spoke("secret-keeper", a, turn=i))
51
+ return tuple(events)
52
+
53
+
54
+ # ── ground truth: the dealt word ────────────────────────────────────────────────
55
+
56
+
57
+ def test_word_is_a_deterministic_function_of_the_seed():
58
+ seed = "A small thing from the wood"
59
+ assert _word_for_seed(seed) == _word_for_seed(seed)
60
+ assert _word_for_seed(seed) in _WORDS
61
+
62
+
63
+ def test_empty_seed_still_deals_a_valid_word():
64
+ assert _word_for_seed("") in _WORDS
65
+
66
+
67
+ # ── Q&A reconstruction off the ledger ────────────────────────────────────────────
68
+
69
+
70
+ def test_qa_history_pairs_each_question_with_the_following_answer():
71
+ events = _qa(("alive?", "No, never alive."), ("made by hands?", "Yes, crafted."))
72
+ assert _qa_history(events) == [("alive?", "No, never alive."), ("made by hands?", "Yes, crafted.")]
73
+
74
+
75
+ def test_qa_history_leaves_an_unanswered_trailing_question_open():
76
+ events = _qa(("alive?", "No."), ("does it glow?", None))
77
+ pairs = _qa_history(events)
78
+ assert pairs[-1] == ("does it glow?", None)
79
+
80
+
81
+ def test_qa_history_ignores_a_keeper_line_with_no_open_question():
82
+ # A stray keeper line before any question must not attach to nothing or crash.
83
+ events = (_spoke("secret-keeper", "Ask me anything."), _spoke("sprout-guesser", "alive?"))
84
+ assert _qa_history(events) == [("alive?", None)]
85
+
86
+
87
+ @pytest.mark.parametrize(
88
+ "answer,expected",
89
+ [
90
+ ("Yes, you could hold it.", "yes"),
91
+ ("No, never alive.", "no"),
92
+ ("Never — not in a hundred winters.", "no"),
93
+ ("Mostly, on cold nights.", "yes"),
94
+ ("Warmer now — it belongs to the wood.", "hint"),
95
+ ],
96
+ )
97
+ def test_classify_answer_reads_the_lead_word(answer: str, expected: str):
98
+ assert _classify_answer(answer) == expected
99
+
100
+
101
+ # ── the keeper: answers, never asks ──────────────────────────────────────────────
102
+
103
+
104
+ def _keeper() -> SecretKeeper:
105
+ agent = SecretKeeper(ModelRouter(offline=True, specs={}))
106
+ agent.manifest = Registry.from_dir().agents["secret-keeper"]
107
+ return agent
108
+
109
+
110
+ def _guesser() -> SproutGuesser:
111
+ agent = SproutGuesser(ModelRouter(offline=True, specs={}))
112
+ agent.manifest = Registry.from_dir().agents["sprout-guesser"]
113
+ return agent
114
+
115
+
116
+ def test_keeper_prompt_carries_the_word_and_the_open_question():
117
+ proj = StageProjection(seed="seed-A")
118
+ events = _qa(("Is it alive?", None))
119
+ prompt = _keeper()._build_extra_prompt(proj, events)
120
+ assert _word_for_seed("seed-A") in prompt # the keeper knows its word
121
+ assert "Is it alive?" in prompt # and the exact question to answer
122
+ assert "never ask" in prompt.lower() # and the discipline to only answer
123
+
124
+
125
+ def test_keeper_first_turn_invites_the_guesser_to_begin():
126
+ prompt = _keeper()._build_extra_prompt(StageProjection(seed="seed-A"), ())
127
+ assert "not asked yet" in prompt.lower() or "invite" in prompt.lower()
128
+
129
+
130
+ def test_keeper_prompt_forbids_writing_the_actual_word():
131
+ word = _word_for_seed("seed-A")
132
+ prompt = _keeper()._build_extra_prompt(StageProjection(seed="seed-A"), _qa(("Is it alive?", None)))
133
+ assert f"NEVER write the word '{word}'" in prompt # the word it must never spell
134
+
135
+
136
+ def test_keeper_question_guard_is_a_passthrough_offline():
137
+ # Offline the curated keeper lines are answers, so a full offline turn never trips
138
+ # the live-only question/leak guard — proving the deterministic path is unchanged.
139
+ keeper = _keeper()
140
+ event = keeper.act(_RUN, 1, StageProjection(seed="seed-A"), _qa(("Is it alive?", None)))
141
+ assert event.kind == "agent.spoke"
142
+ assert event.payload["secret"] == _word_for_seed("seed-A") # ground truth stamped on
143
+
144
+
145
+ # ── the keeper never leaks the word (the worst bug: it spelled "ember" aloud) ─────
146
+
147
+
148
+ @pytest.mark.parametrize(
149
+ "text,word,leaks",
150
+ [
151
+ ("Yes, a glowing ember can emerge from the fire.", "EMBER", True),
152
+ ("Yes, embers like it glow.", "EMBER", True), # simple inflection
153
+ ("No, it never drives anywhere.", "RIVER", False), # substring, not the word
154
+ ("Yes, you could hold it in one hand.", "EMBER", False),
155
+ ],
156
+ )
157
+ def test_contains_word_is_whole_word_and_inflection_aware(text: str, word: str, leaks: bool):
158
+ assert _contains_word(text, word) is leaks
159
+
160
+
161
+ def test_redact_word_scrubs_the_secret_before_it_ships():
162
+ scrubbed = _redact_word("Yes, a glowing ember can emerge from the fire.", "EMBER")
163
+ assert "ember" not in scrubbed.lower()
164
+ assert scrubbed.startswith("Yes,") # the rest of the answer survives
165
+
166
+
167
+ def test_redact_word_is_a_noop_when_the_word_is_absent():
168
+ line = "No, it was never alive, though plenty have leaned on it."
169
+ assert _redact_word(line, "EMBER") == line
170
+
171
+
172
+ @pytest.mark.parametrize(
173
+ "text,word,reason_substr",
174
+ [
175
+ ("Yes, an ember glows.", "EMBER", "spelled"), # leak
176
+ ("Is it warm to hold?", "EMBER", "asked a question"), # asking, not answering
177
+ ("Yes, you could hold it patiently.", "EMBER", None), # clean answer
178
+ ],
179
+ )
180
+ def test_answer_violation_flags_leaks_and_questions(text: str, word: str, reason_substr: str | None):
181
+ reason = _answer_violation(text, word)
182
+ if reason_substr is None:
183
+ assert reason is None
184
+ else:
185
+ assert reason and reason_substr in reason
186
+
187
+
188
+ # ── the guesser: builds on the ledger, never repeats ─────────────────────────────
189
+
190
+
191
+ def test_guesser_dossier_sorts_confirmed_and_ruled_out():
192
+ events = _qa(
193
+ ("Is it made by hands?", "Yes, crafted by someone patient."),
194
+ ("Is it alive?", "No, never alive."),
195
+ )
196
+ prompt = _guesser()._build_extra_prompt(StageProjection(seed="seed-A"), events)
197
+ assert "CONFIRMED" in prompt and "Is it made by hands?" in prompt
198
+ assert "RULED OUT" in prompt and "Is it alive?" in prompt
199
+ assert "ALREADY ASKED" in prompt # every prior question, never to be repeated
200
+
201
+
202
+ def test_guesser_opening_prompt_when_nothing_asked_yet():
203
+ prompt = _guesser()._build_extra_prompt(StageProjection(seed="seed-A"), ())
204
+ assert "opening" in prompt.lower() or "broad" in prompt.lower()
205
+
206
+
207
+ def test_guesser_hard_commits_after_the_question_cap():
208
+ # All "No" answers (no confirmed facts), so only the hard _COMMIT_AFTER stop can fire.
209
+ asked = tuple((f"Is it property number {i}?", "No, not that.") for i in range(SproutGuesser._COMMIT_AFTER + 1))
210
+ prompt = _guesser()._build_extra_prompt(StageProjection(seed="seed-A"), _qa(*asked))
211
+ assert "time to guess" in prompt.lower() and "my guess is" in prompt.lower()
212
+
213
+
214
+ def test_guesser_commits_once_the_word_has_clearly_taken_shape():
215
+ # _GUESS_WHEN_CONFIRMED yes-answers ⇒ commit even though few questions were asked.
216
+ yes = tuple((f"Is it trait {i}?", "Yes, that's right.") for i in range(SproutGuesser._GUESS_WHEN_CONFIRMED))
217
+ prompt = _guesser()._build_extra_prompt(StageProjection(seed="seed-A"), _qa(*yes))
218
+ assert "time to guess" in prompt.lower()
219
+
220
+
221
+ def test_guesser_interleaves_a_guess_after_too_many_questions_without_one():
222
+ # Enough facts to guess, and _GUESS_EVERY questions since the last guess ⇒ forced guess,
223
+ # so the guesser commits *between* questions instead of interrogating forever.
224
+ pairs = [("Is it made by hands?", "Yes."), ("Is it warm?", "Yes."), ("Is it small?", "Yes.")]
225
+ pairs += [(f"Is it trait {i}?", "No.") for i in range(SproutGuesser._GUESS_EVERY)]
226
+ prompt = _guesser()._build_extra_prompt(StageProjection(seed="seed-A"), _qa(*pairs))
227
+ assert "time to guess" in prompt.lower()
228
+
229
+
230
+ def test_guesser_keeps_asking_when_it_has_too_little_and_guessed_recently():
231
+ # One confirmed fact, just asked a couple questions: not enough to force a guess yet.
232
+ prompt = _guesser()._build_extra_prompt(
233
+ StageProjection(seed="seed-A"), _qa(("Is it warm?", "Yes."), ("Is it loud?", "No."))
234
+ )
235
+ assert "time to guess" not in prompt.lower()
236
+ assert "yes/no question" in prompt.lower()
237
+
238
+
239
+ def test_questions_since_guess_counts_back_to_the_last_commit():
240
+ pairs = _qa_history(_qa(("q1?", "No."), ("My guess is: ACORN", "No."), ("q2?", "No."), ("q3?", "No.")))
241
+ assert _questions_since_guess(pairs) == 2 # two questions after the guess
242
+ assert _questions_since_guess(_qa_history(_qa(("q1?", "No."), ("q2?", "No.")))) == 2 # never guessed
243
+
244
+
245
+ def test_guesser_lists_a_prior_wrong_guess_so_it_is_not_repeated():
246
+ events = _qa(("Is it alive?", "No."), ("My guess is: ACORN", "No, that's not it."))
247
+ prompt = _guesser()._build_extra_prompt(StageProjection(seed="seed-A"), events)
248
+ assert "ALREADY GUESSED" in prompt and "ACORN" in prompt
249
+
250
+
251
+ # ── the whole game: secret to the audience, never to the cast ────────────────────
252
+
253
+
254
+ @pytest.fixture(scope="module")
255
+ def played():
256
+ """Run Twenty Sprouts to a verdict on the deterministic stub; return (events, word)."""
257
+ registry = Registry.from_dir()
258
+ scenario = registry.build_scenario(
259
+ "twenty-sprouts", router=ModelRouter(offline=True, specs={}), tools=default_tool_registry()
260
+ )
261
+ conductor = Conductor(scenario, governor=registry.governor_for("twenty-sprouts"))
262
+ conductor.reset(scenario.default_seed)
263
+ for _ in range(80):
264
+ try:
265
+ conductor.step()
266
+ except BudgetExceeded:
267
+ break
268
+ if any(e.kind == "judge.verdict" for e in conductor.ledger.events_for_run(conductor.run_id)):
269
+ break
270
+ events = conductor.ledger.events_for_run(conductor.run_id)
271
+ return events, _word_for_seed(scenario.default_seed), [a.manifest for a in scenario.agents]
272
+
273
+
274
+ def test_secret_never_enters_the_guessers_context(played):
275
+ events, word, _cast = played
276
+ proj = rebuild_stage(events)
277
+ memory = EpisodicMemory("sprout-guesser", max_recent=40).format_for_prompt(events)
278
+ context = ContextBuilder().build(
279
+ agent_name="sprout-guesser",
280
+ persona="P",
281
+ projection=proj,
282
+ all_events=events,
283
+ memory_window=40,
284
+ memory_text=memory,
285
+ )
286
+ assert word not in context
287
+ assert word not in memory
288
+ assert word not in "\n".join(proj.agent_notes)
289
+
290
+
291
+ def test_secret_reaches_the_audience_view_model_and_stage(played):
292
+ events, word, cast = played
293
+ vm = view_model_at(events, len(events), cast, scenario_name="twenty-sprouts")
294
+ assert vm["secret"] == word
295
+ assert vm["secret_holder"] == "secret-keeper"
296
+ stage_html = render_constellation(vm, {})
297
+ assert word in stage_html and "only you can see" in stage_html
298
+
299
+
300
+ def test_secret_does_not_leak_into_the_narrator_feed(played):
301
+ events, word, cast = played
302
+ vm = view_model_at(events, len(events), cast, scenario_name="twenty-sprouts")
303
+ feed_text = " ".join(str(item.get("said") or item.get("text") or "") for item in vm["feed"])
304
+ assert word not in feed_text
305
+
306
+
307
+ def test_non_secret_scenarios_have_no_audience_badge():
308
+ # A run with no dealt secret leaves the field None and renders no badge.
309
+ cast = list(Registry.from_dir().agents.values())[:1]
310
+ vm = view_model_at((), 0, cast, scenario_name="debate-duel")
311
+ assert vm["secret"] is None and vm["secret_holder"] is None
312
+ assert "only you can see" not in render_constellation(vm, {})