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 +8 -5
- config/agents/devils-advocate.yaml +6 -3
- config/agents/secret-keeper.yaml +5 -4
- config/agents/sprout-guesser.yaml +6 -4
- docs/adr/0023-resilient-loop-and-shared-blackboard.md +57 -0
- docs/architecture/fishbowl-ui.md +2 -0
- docs/architecture/scenario-authoring.md +63 -34
- src/agents/base.py +3 -0
- src/agents/twenty_sprouts.py +281 -10
- src/core/conductor.py +32 -2
- src/core/context.py +122 -25
- src/core/memory.py +36 -5
- src/ui/fishbowl/app.py +228 -47
- src/ui/fishbowl/assets/styles.css +120 -0
- src/ui/fishbowl/render/stage.py +21 -0
- src/ui/fishbowl/session.py +11 -0
- src/ui/fishbowl/show.py +16 -34
- src/ui/fishbowl/view_model.py +12 -0
- tests/test_conductor.py +30 -0
- tests/test_context.py +71 -0
- tests/test_engine.py +0 -1
- tests/test_fishbowl_app.py +28 -0
- tests/test_fishbowl_loop_safety.py +2 -1
- tests/test_fishbowl_show.py +20 -18
- tests/test_fishbowl_winner.py +19 -5
- tests/test_instructor.py +1 -0
- tests/test_journal.py +0 -1
- tests/test_mcp.py +9 -9
- tests/test_memory.py +18 -3
- tests/test_modal_endpoint_urls.py +1 -2
- tests/test_modularity.py +1 -0
- tests/test_salience_memory.py +8 -0
- tests/test_twenty_sprouts.py +312 -0
|
@@ -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 |
-
|
|
|
|
| 8 |
subscribes_to: []
|
|
|
|
|
|
|
| 9 |
may_emit:
|
| 10 |
-
- agent.
|
| 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
|
|
@@ -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 |
-
|
|
|
|
| 7 |
subscribes_to:
|
| 8 |
- agent.spoke
|
|
|
|
|
|
|
| 9 |
may_emit:
|
| 10 |
-
- agent.
|
| 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
|
|
@@ -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.
|
| 7 |
-
truthfully and helpfully in ONE short sentence
|
| 8 |
-
|
| 9 |
-
|
|
|
|
| 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
|
|
@@ -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.
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
| 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
|
|
@@ -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).
|
|
@@ -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 |
|
|
@@ -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 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
**cannot see** — it embellishes a void.
|
| 266 |
|
| 267 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
|
| 269 |
-
**(a)
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
and each whisper overwrites `current_scene`.
|
| 274 |
|
| 275 |
-
**(b)
|
| 276 |
-
|
| 277 |
-
custom kinds collaborate:
|
| 278 |
|
| 279 |
```python
|
| 280 |
-
# src/core/memory.py —
|
| 281 |
-
#
|
| 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 |
-
…
|
| 287 |
-
|
| 288 |
-
|
| 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 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 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
|
| 419 |
-
|
| 420 |
-
globally
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -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()
|
|
@@ -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.
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 60 |
-
guesser (whose context is built from ``text`` only). The keeper's spoken
|
| 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 |
-
|
| 69 |
-
"
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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?
|
|
@@ -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"]:
|
|
@@ -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.
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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 |
-
#
|
| 54 |
-
# the
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"{
|
| 66 |
-
f"
|
| 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 |
-
|
|
|
|
| 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,
|
| 86 |
-
"""Drop memory lines
|
| 87 |
-
|
| 88 |
-
Memory lines are ``[turn NNN][kind] <text>``
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
if
|
|
|
|
|
|
|
|
|
|
| 94 |
return memory_text
|
| 95 |
-
kept = [line for line in memory_text.splitlines() if not line.rstrip().endswith(
|
| 96 |
-
return "\n".join(kept)
|
| 97 |
|
| 98 |
@staticmethod
|
| 99 |
-
def _blackboard_block(
|
| 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). ``
|
| 105 |
-
only the public ``text`` of each peer event (never their
|
| 106 |
-
so surfacing it shares the conversation without leaking minds.
|
| 107 |
"""
|
| 108 |
-
notes = [n for n in
|
| 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)
|
|
@@ -2,8 +2,10 @@
|
|
| 2 |
|
| 3 |
Memory architecture (three layers):
|
| 4 |
|
| 5 |
-
1. EpisodicMemory — filtered view over the ledger;
|
| 6 |
-
|
|
|
|
|
|
|
| 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
|
| 85 |
-
|
|
|
|
|
|
|
| 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
|
| 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
|
|
@@ -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(
|
| 416 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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">&#
|
| 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 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 639 |
|
| 640 |
def _halt_tail() -> tuple:
|
| 641 |
-
"""Tail values that STOP autoplay
|
| 642 |
-
return (
|
|
|
|
|
|
|
|
|
|
|
|
|
| 643 |
|
| 644 |
-
def _run_tail(stopped: bool = False) -> tuple:
|
| 645 |
-
"""Tail values that leave
|
| 646 |
-
return (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 813 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 840 |
on_summon,
|
| 841 |
inputs=summon_inputs,
|
| 842 |
-
outputs=[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 972 |
cap = session.autoplay_tick_cap if session is not None else _MAX_AUTO_TICKS
|
| 973 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 974 |
if stop_reason is None:
|
| 975 |
out = _render_at(session, new_k, layout=layout, mind_reader=mind_reader)
|
| 976 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 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 |
-
#
|
| 1012 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1019 |
|
| 1020 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 1049 |
-
|
|
|
|
|
|
|
| 1050 |
if session is None:
|
| 1051 |
out = _render_at(None, 0, layout=layout, mind_reader=mind_reader)
|
| 1052 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 1059 |
k = session.head
|
| 1060 |
out = _render_at(session, k, layout=layout, mind_reader=mind_reader)
|
| 1061 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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">⏹ 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 |
|
|
@@ -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 |
+
}
|
|
@@ -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 |
|
|
@@ -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()
|
|
@@ -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 :
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
with gr.Row(elem_classes=["showbar"]):
|
| 38 |
-
handles["
|
| 39 |
-
"
|
| 40 |
-
elem_classes=["icon-btn", "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -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.
|
|
@@ -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"
|
|
@@ -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.
|
|
@@ -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."
|
|
|
|
@@ -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 "<script>" in html
|
| 119 |
+
|
| 120 |
+
|
| 121 |
def test_inject_appends_a_poke() -> None:
|
| 122 |
from src.ui.fishbowl.app import SCENARIOS, _new_session
|
| 123 |
|
|
@@ -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 "
|
|
|
|
| 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 |
|
|
@@ -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 |
-
"
|
| 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
|
|
|
|
|
|
|
|
|
|
| 84 |
handles = _build()
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
assert slider.value == 0
|
| 88 |
-
assert slider.minimum == 0
|
| 89 |
-
assert slider.step == 1
|
| 90 |
|
| 91 |
|
| 92 |
-
def
|
| 93 |
handles = _build()
|
| 94 |
-
|
| 95 |
-
assert isinstance(
|
| 96 |
-
|
| 97 |
-
assert
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
|
| 100 |
def test_transport_buttons_are_buttons() -> None:
|
| 101 |
handles = _build()
|
| 102 |
-
for key in ("
|
| 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 |
|
|
@@ -44,10 +44,20 @@ class TestAgentWin:
|
|
| 44 |
def _vm(self, **over) -> dict:
|
| 45 |
vm = {
|
| 46 |
"cast": [
|
| 47 |
-
{
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
],
|
| 52 |
"teams": {},
|
| 53 |
"verdict": {
|
|
@@ -126,6 +136,10 @@ class TestTeamWin:
|
|
| 126 |
|
| 127 |
class TestEscaping:
|
| 128 |
def test_label_is_escaped(self):
|
| 129 |
-
vm = {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
html = render_winner(vm)
|
| 131 |
assert "<script>" not in html and "<script>" 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 "<script>" in html
|
|
@@ -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
|
|
@@ -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()
|
|
|
|
@@ -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
|
|
@@ -21,11 +21,26 @@ class TestEpisodicMemory:
|
|
| 21 |
visible = mem.visible(events)
|
| 22 |
assert len(visible) == 1
|
| 23 |
|
| 24 |
-
def
|
|
|
|
|
|
|
|
|
|
| 25 |
mem = EpisodicMemory("pocket-actor")
|
| 26 |
events = (_event("agent.spoke", actor="scene-whisperer"),)
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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")
|
|
@@ -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 |
|
|
@@ -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
|
|
@@ -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):
|
|
@@ -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, {})
|