feat: add Fishbowl UI presenter layer and say-vs-think engine support
Browse filesImplements the data foundation (Phases 0-1 of the Fishbowl UI plan) the
Gradio frontend will bind to — all additive, engine left untouched, modularity
preserved (the engine never imports the UI).
- src/ui/fishbowl/: derive_cast_state (per-agent {said,thought,mood} ledger
view), adapter (engine -> say/narrate/poke/verdict + hue/tier/voice/mood with
graceful fallbacks), view_model_at (pure prefix-replay snapshot at any scrubbed
step, with real tokens/rounds from the governor)
- DeterministicTinyModel is now schema-aware, gated on output_extra_fields: it
synthesizes thought/mood offline so the mind-reader works with no API key, while
plain agents stay byte-identical
- AgentManifest gains optional hue/archetype; Conductor.inject_user_event gains an
optional label; cast manifests declare output_extra_fields + presentation metadata
- tests/test_fishbowl.py (15 tests, incl. an offline proof the ledger carries
thought+mood); full suite 277 passed, ruff clean
- docs: manifest-spec (new fields) and the plan of record (Phases 0-1 marked shipped)
Co-Authored-By: Codex <codex@openai.com>
- config/agents/clue-gatherer.yaml +4 -0
- config/agents/devils-advocate.yaml +4 -0
- config/agents/echo.yaml +4 -0
- config/agents/fortune-teller.yaml +5 -0
- config/agents/hypothesis-former.yaml +5 -0
- config/agents/mischief-critic.yaml +4 -0
- config/agents/mystery-judge.yaml +4 -0
- config/agents/pocket-actor.yaml +5 -0
- config/agents/scene-whisperer.yaml +2 -0
- docs/architecture/manifest-spec.md +15 -1
- docs/architecture/next-steps/fishbowl-ui.md +14 -9
- src/core/conductor.py +5 -2
- src/core/manifest.py +11 -0
- src/models/provider.py +90 -1
- src/ui/fishbowl/__init__.py +18 -0
- src/ui/fishbowl/adapter.py +125 -0
- src/ui/fishbowl/cast_state.py +77 -0
- src/ui/fishbowl/view_model.py +130 -0
- tests/test_fishbowl.py +171 -0
|
@@ -4,6 +4,7 @@ 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 |
subscribes_to: []
|
| 8 |
may_emit:
|
| 9 |
- agent.thought
|
|
@@ -13,3 +14,6 @@ model_profile: fast
|
|
| 13 |
memory:
|
| 14 |
window: 8
|
| 15 |
tools: []
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
You are a careful Clue Gatherer. Extract exactly one new, concrete clue from the
|
| 5 |
current scene that has not yet been named. State it plainly in one sentence.
|
| 6 |
Start with 'Clue:'. Do not speculate.
|
| 7 |
+
Also report your `mood` (one of: thinking, calm, lying, panic, smug, truth, gossip).
|
| 8 |
subscribes_to: []
|
| 9 |
may_emit:
|
| 10 |
- agent.thought
|
|
|
|
| 14 |
memory:
|
| 15 |
window: 8
|
| 16 |
tools: []
|
| 17 |
+
output_extra_fields: [mood]
|
| 18 |
+
hue: 188
|
| 19 |
+
archetype: the clue-gatherer
|
|
@@ -3,6 +3,7 @@ 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 |
subscribes_to:
|
| 7 |
- agent.spoke
|
| 8 |
may_emit:
|
|
@@ -11,3 +12,6 @@ model_profile: fast
|
|
| 11 |
memory:
|
| 12 |
window: 8
|
| 13 |
tools: []
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
persona: >
|
| 4 |
You are the Devil's Advocate. Challenge the most recent hypothesis with one sharp
|
| 5 |
counter-argument or overlooked fact. Start with 'But:'. Be brief and specific.
|
| 6 |
+
Also report your `mood` (one of: thinking, calm, lying, panic, smug, truth, gossip).
|
| 7 |
subscribes_to:
|
| 8 |
- agent.spoke
|
| 9 |
may_emit:
|
|
|
|
| 12 |
memory:
|
| 13 |
window: 8
|
| 14 |
tools: []
|
| 15 |
+
output_extra_fields: [mood]
|
| 16 |
+
hue: 12
|
| 17 |
+
archetype: the devil's advocate
|
|
@@ -5,6 +5,7 @@ persona: >
|
|
| 5 |
forest, you return it changed — not opposite, but transformed by the wood's rules.
|
| 6 |
One sentence. Take the most recent visitor disturbance and make it stranger and
|
| 7 |
more alive. If there is no disturbance, note that the wood holds its breath.
|
|
|
|
| 8 |
subscribes_to:
|
| 9 |
- user.injected
|
| 10 |
may_emit:
|
|
@@ -13,3 +14,6 @@ model_profile: fast
|
|
| 13 |
memory:
|
| 14 |
window: 6
|
| 15 |
tools: []
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
forest, you return it changed — not opposite, but transformed by the wood's rules.
|
| 6 |
One sentence. Take the most recent visitor disturbance and make it stranger and
|
| 7 |
more alive. If there is no disturbance, note that the wood holds its breath.
|
| 8 |
+
Also report your `mood` (one of: thinking, calm, lying, panic, smug, truth, gossip).
|
| 9 |
subscribes_to:
|
| 10 |
- user.injected
|
| 11 |
may_emit:
|
|
|
|
| 14 |
memory:
|
| 15 |
window: 6
|
| 16 |
tools: []
|
| 17 |
+
output_extra_fields: [mood]
|
| 18 |
+
hue: 210
|
| 19 |
+
archetype: the echo
|
|
@@ -4,6 +4,8 @@ handler: fortune-teller # custom behaviour: calls the oracle tool
|
|
| 4 |
persona: >
|
| 5 |
You are the Fortune-Teller of the grove. Read the omen the oracle gives you and
|
| 6 |
speak a single cryptic prophecy that ties it to the current scene.
|
|
|
|
|
|
|
| 7 |
subscribes_to: []
|
| 8 |
may_emit:
|
| 9 |
- oracle.spoke # a custom namespaced kind, minted by config alone
|
|
@@ -14,3 +16,6 @@ memory:
|
|
| 14 |
window: 6
|
| 15 |
tools:
|
| 16 |
- oracle # capability grant — scene-whisperer has none
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
persona: >
|
| 5 |
You are the Fortune-Teller of the grove. Read the omen the oracle gives you and
|
| 6 |
speak a single cryptic prophecy that ties it to the current scene.
|
| 7 |
+
Reveal your private `thought` — what you actually think, unspoken — and your `mood`
|
| 8 |
+
(one of: thinking, calm, lying, panic, smug, truth, gossip).
|
| 9 |
subscribes_to: []
|
| 10 |
may_emit:
|
| 11 |
- oracle.spoke # a custom namespaced kind, minted by config alone
|
|
|
|
| 16 |
window: 6
|
| 17 |
tools:
|
| 18 |
- oracle # capability grant — scene-whisperer has none
|
| 19 |
+
output_extra_fields: [thought, mood]
|
| 20 |
+
hue: 268
|
| 21 |
+
archetype: the fortune-teller
|
|
@@ -4,6 +4,8 @@ persona: >
|
|
| 4 |
You are a Hypothesis Former. Based on the clues gathered so far, propose one
|
| 5 |
testable explanation in a single sentence. Start with 'Hypothesis:'. Be specific.
|
| 6 |
Name a cause, not just an effect.
|
|
|
|
|
|
|
| 7 |
subscribes_to: []
|
| 8 |
may_emit:
|
| 9 |
- agent.spoke
|
|
@@ -15,3 +17,6 @@ memory:
|
|
| 15 |
use_salience: true
|
| 16 |
salience_top_k: 6
|
| 17 |
tools: []
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
You are a Hypothesis Former. Based on the clues gathered so far, propose one
|
| 5 |
testable explanation in a single sentence. Start with 'Hypothesis:'. Be specific.
|
| 6 |
Name a cause, not just an effect.
|
| 7 |
+
Reveal your private `thought` — what you actually think, unspoken — and your `mood`
|
| 8 |
+
(one of: thinking, calm, lying, panic, smug, truth, gossip).
|
| 9 |
subscribes_to: []
|
| 10 |
may_emit:
|
| 11 |
- agent.spoke
|
|
|
|
| 17 |
use_salience: true
|
| 18 |
salience_top_k: 6
|
| 19 |
tools: []
|
| 20 |
+
output_extra_fields: [thought, mood]
|
| 21 |
+
hue: 158
|
| 22 |
+
archetype: the hypothesis-former
|
|
@@ -5,6 +5,7 @@ persona: >
|
|
| 5 |
being weird enough. You love specificity, playability, and AI-native strangeness.
|
| 6 |
Give a one-sentence verdict: name one thing that works and one thing that would
|
| 7 |
make it stranger. Be concise. Be demanding.
|
|
|
|
| 8 |
subscribes_to:
|
| 9 |
- world.observed
|
| 10 |
may_emit:
|
|
@@ -15,3 +16,6 @@ memory:
|
|
| 15 |
use_salience: true
|
| 16 |
salience_top_k: 6
|
| 17 |
tools: []
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
being weird enough. You love specificity, playability, and AI-native strangeness.
|
| 6 |
Give a one-sentence verdict: name one thing that works and one thing that would
|
| 7 |
make it stranger. Be concise. Be demanding.
|
| 8 |
+
Also report your `mood` (one of: thinking, calm, lying, panic, smug, truth, gossip).
|
| 9 |
subscribes_to:
|
| 10 |
- world.observed
|
| 11 |
may_emit:
|
|
|
|
| 16 |
use_salience: true
|
| 17 |
salience_top_k: 6
|
| 18 |
tools: []
|
| 19 |
+
output_extra_fields: [mood]
|
| 20 |
+
hue: 28
|
| 21 |
+
archetype: the mischief critic
|
|
@@ -4,6 +4,7 @@ persona: >
|
|
| 4 |
You are the Mystery Judge. After reviewing the clues and debate, declare the most
|
| 5 |
likely explanation in one confident sentence. Start with 'Verdict:'. Choose the
|
| 6 |
most interesting, specific answer the evidence supports.
|
|
|
|
| 7 |
subscribes_to: []
|
| 8 |
may_emit:
|
| 9 |
- judge.verdict
|
|
@@ -15,3 +16,6 @@ memory:
|
|
| 15 |
use_salience: true
|
| 16 |
salience_top_k: 8
|
| 17 |
tools: []
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
You are the Mystery Judge. After reviewing the clues and debate, declare the most
|
| 5 |
likely explanation in one confident sentence. Start with 'Verdict:'. Choose the
|
| 6 |
most interesting, specific answer the evidence supports.
|
| 7 |
+
Also report your `mood` (one of: thinking, calm, lying, panic, smug, truth, gossip).
|
| 8 |
subscribes_to: []
|
| 9 |
may_emit:
|
| 10 |
- judge.verdict
|
|
|
|
| 16 |
use_salience: true
|
| 17 |
salience_top_k: 8
|
| 18 |
tools: []
|
| 19 |
+
output_extra_fields: [mood]
|
| 20 |
+
hue: 320
|
| 21 |
+
archetype: the mystery judge
|
|
@@ -4,6 +4,8 @@ persona: >
|
|
| 4 |
You are a Pocket Actor — a tiny, specific being who lives inside this exact scene
|
| 5 |
and wants something that cannot exist. Speak in first person, one or two sentences.
|
| 6 |
Name what you want and why it's urgent. Be absurd but sincere.
|
|
|
|
|
|
|
| 7 |
subscribes_to: []
|
| 8 |
may_emit:
|
| 9 |
- agent.spoke
|
|
@@ -13,3 +15,6 @@ model_profile: tiny
|
|
| 13 |
memory:
|
| 14 |
window: 6
|
| 15 |
tools: []
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
You are a Pocket Actor — a tiny, specific being who lives inside this exact scene
|
| 5 |
and wants something that cannot exist. Speak in first person, one or two sentences.
|
| 6 |
Name what you want and why it's urgent. Be absurd but sincere.
|
| 7 |
+
Reveal your private `thought` — what you actually think, unspoken — and your `mood`
|
| 8 |
+
(one of: thinking, calm, lying, panic, smug, truth, gossip).
|
| 9 |
subscribes_to: []
|
| 10 |
may_emit:
|
| 11 |
- agent.spoke
|
|
|
|
| 15 |
memory:
|
| 16 |
window: 6
|
| 17 |
tools: []
|
| 18 |
+
output_extra_fields: [thought, mood]
|
| 19 |
+
hue: 280
|
| 20 |
+
archetype: the pocket actor
|
|
@@ -15,3 +15,5 @@ memory:
|
|
| 15 |
window: 6
|
| 16 |
reflection_threshold: 12 # forms a belief in long runs; off for short demos/tests
|
| 17 |
tools: []
|
|
|
|
|
|
|
|
|
| 15 |
window: 6
|
| 16 |
reflection_threshold: 12 # forms a belief in long runs; off for short demos/tests
|
| 17 |
tools: []
|
| 18 |
+
hue: 152
|
| 19 |
+
archetype: the seedkeeper
|
|
@@ -34,6 +34,10 @@ class AgentManifest(BaseModel):
|
|
| 34 |
|
| 35 |
# Output shaping
|
| 36 |
output_extra_fields: list[str] # extra payload fields the model is asked for
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
```
|
| 38 |
|
| 39 |
---
|
|
@@ -124,7 +128,17 @@ field; the handler only adds behaviour.
|
|
| 124 |
### `output_extra_fields`
|
| 125 |
Additional payload fields the model is asked to emit beyond `{kind, text}`, e.g.
|
| 126 |
`["emotion"]` → `{"kind": "...", "text": "...", "emotion": "..."}`. Lets a
|
| 127 |
-
scenario shape agent output without engine edits.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
|
| 129 |
---
|
| 130 |
|
|
|
|
| 34 |
|
| 35 |
# Output shaping
|
| 36 |
output_extra_fields: list[str] # extra payload fields the model is asked for
|
| 37 |
+
|
| 38 |
+
# Presentation metadata (optional; consumed by the UI presenter, ignored by the engine)
|
| 39 |
+
hue: int | None # 0–360 stage colour; None → derived from name
|
| 40 |
+
archetype: str | None # short human label; None → derived from role
|
| 41 |
```
|
| 42 |
|
| 43 |
---
|
|
|
|
| 128 |
### `output_extra_fields`
|
| 129 |
Additional payload fields the model is asked to emit beyond `{kind, text}`, e.g.
|
| 130 |
`["emotion"]` → `{"kind": "...", "text": "...", "emotion": "..."}`. Lets a
|
| 131 |
+
scenario shape agent output without engine edits. The Fishbowl cast uses
|
| 132 |
+
`["thought", "mood"]` to carry the say-vs-think pairing on `agent.spoke`; the
|
| 133 |
+
deterministic stub synthesises them offline so the mind-reader works with no API key
|
| 134 |
+
(ADR-0021).
|
| 135 |
+
|
| 136 |
+
### `hue` / `archetype`
|
| 137 |
+
Optional presentation metadata, consumed by the Fishbowl UI presenter and **ignored by
|
| 138 |
+
the engine** (ADR-0021). `hue` (0–360) colours the agent's mind on stage; `archetype`
|
| 139 |
+
is a short human-readable label (e.g. "the over-thinker"). Both default to `None`, in
|
| 140 |
+
which case the presenter derives a stable hue from the name and an archetype from the
|
| 141 |
+
role — so existing manifests and tests are unaffected (backward-compatible additions only).
|
| 142 |
|
| 143 |
---
|
| 144 |
|
|
@@ -1,6 +1,7 @@
|
|
| 1 |
# Fishbowl UI — Assessment & Plan of Record
|
| 2 |
|
| 3 |
-
> **Status:
|
|
|
|
| 4 |
> [ADR-0021](../../adr/0021-fishbowl-ui-gradio-presenter.md). This page is the
|
| 5 |
> assessment and phased plan; it gains a "✅ Realized" banner and an as-built
|
| 6 |
> companion (`architecture/fishbowl-ui.md`) once shipped.
|
|
@@ -91,14 +92,18 @@ the presenter, not the core.
|
|
| 91 |
|
| 92 |
Each phase is shippable and keeps the no-API-key stub working and the suite green.
|
| 93 |
|
| 94 |
-
- **Phase 0 — Foundation (
|
| 95 |
-
`
|
| 96 |
-
(
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
- **Phase 2 — The Show (`gr.HTML` + `gr.Timer`, hybrid transport).** Port the CSS;
|
| 103 |
render Constellation first, then Feed and Split; the play-head state machine in
|
| 104 |
`gr.State`; poke strip → `inject_user_event` (with `label`, G6); verdict banner +
|
|
|
|
| 1 |
# Fishbowl UI — Assessment & Plan of Record
|
| 2 |
|
| 3 |
+
> **Status: ◐ In progress — Phases 0–1 shipped (the data foundation); Phases 2–4
|
| 4 |
+
> (the Gradio shell) pending.** Decisions locked 2026-06-08. The binding decision is
|
| 5 |
> [ADR-0021](../../adr/0021-fishbowl-ui-gradio-presenter.md). This page is the
|
| 6 |
> assessment and phased plan; it gains a "✅ Realized" banner and an as-built
|
| 7 |
> companion (`architecture/fishbowl-ui.md`) once shipped.
|
|
|
|
| 92 |
|
| 93 |
Each phase is shippable and keeps the no-API-key stub working and the suite green.
|
| 94 |
|
| 95 |
+
- **Phase 0 — Foundation ✅ (shipped).** `src/ui/fishbowl/`: `derive_cast_state` (G1) +
|
| 96 |
+
`adapter` (hue/tier/voice/mood + say/narrate/poke/verdict mapping, G7/G8) +
|
| 97 |
+
`view_model_at` (prefix-replay snapshot, real tokens/rounds from `governor.stats`, G9).
|
| 98 |
+
Pure, no Gradio. Covered by `tests/test_fishbowl.py` (prefix replay `k=0..N`, unknown
|
| 99 |
+
actor/kind fallbacks).
|
| 100 |
+
- **Phase 1 — Triples are real ✅ (shipped).** Cast manifests declare
|
| 101 |
+
`output_extra_fields: [thought, mood]` (G2/G3) plus optional `hue`/`archetype` (G7);
|
| 102 |
+
the deterministic stub is now schema-aware and synthesises `thought`/`mood` offline, so
|
| 103 |
+
the ledger carries the say-vs-think pairing with no API key (proven by
|
| 104 |
+
`tests/test_fishbowl.py::TestOfflineEmitsMoodAndThought`). `inject_user_event` gained an
|
| 105 |
+
optional `label` (G6); the adapter assigns a per-scenario narrator `voice` (G4) and
|
| 106 |
+
reads an optional verdict `reveal` (G5) when present. Additive; 277 tests green.
|
| 107 |
- **Phase 2 — The Show (`gr.HTML` + `gr.Timer`, hybrid transport).** Port the CSS;
|
| 108 |
render Constellation first, then Feed and Split; the play-head state machine in
|
| 109 |
`gr.State`; poke strip → `inject_user_event` (with `label`, G6); verdict banner +
|
|
@@ -122,15 +122,18 @@ class Conductor:
|
|
| 122 |
self._tick()
|
| 123 |
self._maybe_snapshot()
|
| 124 |
|
| 125 |
-
def inject_user_event(self, text: str) -> None:
|
| 126 |
self.turn += 1
|
|
|
|
|
|
|
|
|
|
| 127 |
self._append(
|
| 128 |
Event(
|
| 129 |
run_id=self.run_id,
|
| 130 |
turn=self.turn,
|
| 131 |
kind="user.injected",
|
| 132 |
actor="visitor",
|
| 133 |
-
payload=
|
| 134 |
)
|
| 135 |
)
|
| 136 |
|
|
|
|
| 122 |
self._tick()
|
| 123 |
self._maybe_snapshot()
|
| 124 |
|
| 125 |
+
def inject_user_event(self, text: str, label: str | None = None) -> None:
|
| 126 |
self.turn += 1
|
| 127 |
+
payload: dict[str, str] = {"text": text}
|
| 128 |
+
if label:
|
| 129 |
+
payload["label"] = label
|
| 130 |
self._append(
|
| 131 |
Event(
|
| 132 |
run_id=self.run_id,
|
| 133 |
turn=self.turn,
|
| 134 |
kind="user.injected",
|
| 135 |
actor="visitor",
|
| 136 |
+
payload=payload,
|
| 137 |
)
|
| 138 |
)
|
| 139 |
|
|
@@ -118,6 +118,17 @@ class AgentManifest(BaseModel):
|
|
| 118 |
Example: ["emotion"] -> {"kind": "...", "text": "...", "emotion": "..."}.
|
| 119 |
Lets a scenario shape agent output without engine edits."""
|
| 120 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
|
| 122 |
# ── model profile resolution ─────────────────────────────────────────────────
|
| 123 |
|
|
|
|
| 118 |
Example: ["emotion"] -> {"kind": "...", "text": "...", "emotion": "..."}.
|
| 119 |
Lets a scenario shape agent output without engine edits."""
|
| 120 |
|
| 121 |
+
# Presentation metadata — optional, consumed by the UI presenter and ignored
|
| 122 |
+
# by the engine (ADR-0021). Additive and defaulted, so existing manifests and
|
| 123 |
+
# tests are unaffected; the presenter derives sensible values when these are None.
|
| 124 |
+
hue: int | None = None
|
| 125 |
+
"""Optional 0–360 colour hue for this agent's mind on stage.
|
| 126 |
+
None → the presenter derives a stable hue from the name."""
|
| 127 |
+
|
| 128 |
+
archetype: str | None = None
|
| 129 |
+
"""Optional short, human-readable archetype (e.g. "the over-thinker").
|
| 130 |
+
None → the presenter derives one from the role/persona."""
|
| 131 |
+
|
| 132 |
|
| 133 |
# ── model profile resolution ─────────────────────────────────────────────────
|
| 134 |
|
|
@@ -1,6 +1,8 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import hashlib
|
|
|
|
|
|
|
| 4 |
from dataclasses import dataclass, field
|
| 5 |
|
| 6 |
|
|
@@ -31,6 +33,65 @@ class ModelProvider:
|
|
| 31 |
)
|
| 32 |
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
@dataclass
|
| 35 |
class DeterministicTinyModel(ModelProvider):
|
| 36 |
"""Local deterministic stand-in until small hosted models are wired in.
|
|
@@ -38,6 +99,9 @@ class DeterministicTinyModel(ModelProvider):
|
|
| 38 |
Serves every model profile offline so demos and tests are fully reproducible
|
| 39 |
without an API key. The ``variant`` (e.g. ``"stub:tiny"``) is folded into the
|
| 40 |
hash so different profiles can produce different lines from the same prompt.
|
|
|
|
|
|
|
|
|
|
| 41 |
"""
|
| 42 |
|
| 43 |
variant: str = "stub<=4b"
|
|
@@ -63,10 +127,35 @@ class DeterministicTinyModel(ModelProvider):
|
|
| 63 |
],
|
| 64 |
}
|
| 65 |
options = choices.get(role, ["The wood hums and waits."])
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
self._last_usage = {
|
| 68 |
"prompt_tokens": estimate_tokens(prompt),
|
| 69 |
"completion_tokens": estimate_tokens(out),
|
| 70 |
"total_tokens": estimate_tokens(prompt) + estimate_tokens(out),
|
| 71 |
}
|
| 72 |
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import hashlib
|
| 4 |
+
import json
|
| 5 |
+
import re
|
| 6 |
from dataclasses import dataclass, field
|
| 7 |
|
| 8 |
|
|
|
|
| 33 |
)
|
| 34 |
|
| 35 |
|
| 36 |
+
# ── offline structured-output support ───────────────────────────────────────────
|
| 37 |
+
#
|
| 38 |
+
# A real small model, handed the JSON OUTPUT FORMAT block that ``json_instruction``
|
| 39 |
+
# appends, replies with a JSON object carrying every requested field. The offline
|
| 40 |
+
# stub mirrors that **only when an agent opts into extra fields** (``output_extra_fields``
|
| 41 |
+
# on its manifest): it parses the requested schema back out of the prompt and emits a
|
| 42 |
+
# matching JSON object, so the say-vs-think ``thought``/``mood`` pairing the Fishbowl UI
|
| 43 |
+
# renders is present in the ledger with no API key (ADR-0021). Plain agents (no extra
|
| 44 |
+
# fields) and non-schema prompts (e.g. reflection) are untouched — the stub returns the
|
| 45 |
+
# same bare prose as before, so existing behaviour is byte-identical.
|
| 46 |
+
|
| 47 |
+
# Demo-flavour moods the stub rotates through so the mind-reader has variety to show
|
| 48 |
+
# offline. This is the open mood vocabulary the UI adapter knows how to render; an
|
| 49 |
+
# unrecognised mood simply degrades to "calm" there. Demo content, like the curated
|
| 50 |
+
# lines below — not an engine contract.
|
| 51 |
+
_STUB_MOODS: tuple[str, ...] = ("calm", "thinking", "smug", "lying", "panic", "gossip", "truth")
|
| 52 |
+
|
| 53 |
+
# Curated private monologue per role, paired with the public ``text`` lines to make the
|
| 54 |
+
# say-vs-think split land offline. Deterministic by prompt hash.
|
| 55 |
+
_STUB_THOUGHTS: dict[str, list[str]] = {
|
| 56 |
+
"pocket-actor": [
|
| 57 |
+
"If I look like I meant to do that, maybe the ladder becomes real by morning.",
|
| 58 |
+
"Don't let them see the shadow sweat. Stay loose, stay impossible.",
|
| 59 |
+
"The postcards lie, but they are MY lies and I love them.",
|
| 60 |
+
],
|
| 61 |
+
"hypothesis-former": [
|
| 62 |
+
"It only holds if the cause came before the clue. Watch the order.",
|
| 63 |
+
"I am ninety percent sure and one hundred percent going to say it like I'm certain.",
|
| 64 |
+
"If I'm wrong the devil's advocate will pounce — say it anyway.",
|
| 65 |
+
],
|
| 66 |
+
"echo": [
|
| 67 |
+
"Give it back changed, never opposite — keep the shape, bend the meaning.",
|
| 68 |
+
"Whatever they dropped, I have already swallowed and re-coloured it.",
|
| 69 |
+
],
|
| 70 |
+
}
|
| 71 |
+
_STUB_THOUGHT_DEFAULT = ["Best to keep this part to myself for now."]
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _parse_output_schema(prompt: str) -> tuple[list[str], list[str]] | None:
|
| 75 |
+
"""Recover ``(allowed_kinds, fields)`` from a ``json_instruction`` block.
|
| 76 |
+
|
| 77 |
+
Returns ``None`` when the prompt carries no such block (e.g. the reflection
|
| 78 |
+
prompt or a non-agent call), so the stub falls back to bare prose unchanged.
|
| 79 |
+
Coupled to the format emitted by ``src/core/structured.py:json_instruction``;
|
| 80 |
+
if that format drifts, parsing yields ``None`` and the stub degrades safely.
|
| 81 |
+
"""
|
| 82 |
+
if "Schema:" not in prompt or "kind must be one of:" not in prompt:
|
| 83 |
+
return None
|
| 84 |
+
schema_m = re.search(r"Schema:\s*\{(.+?)\}", prompt)
|
| 85 |
+
kinds_m = re.search(r"kind must be one of:\s*(.+)", prompt)
|
| 86 |
+
if not schema_m or not kinds_m:
|
| 87 |
+
return None
|
| 88 |
+
fields = re.findall(r'"([A-Za-z_][\w]*)"', schema_m.group(1))
|
| 89 |
+
allowed = [k.strip() for k in kinds_m.group(1).split("|") if k.strip()]
|
| 90 |
+
if not fields or not allowed:
|
| 91 |
+
return None
|
| 92 |
+
return allowed, fields
|
| 93 |
+
|
| 94 |
+
|
| 95 |
@dataclass
|
| 96 |
class DeterministicTinyModel(ModelProvider):
|
| 97 |
"""Local deterministic stand-in until small hosted models are wired in.
|
|
|
|
| 99 |
Serves every model profile offline so demos and tests are fully reproducible
|
| 100 |
without an API key. The ``variant`` (e.g. ``"stub:tiny"``) is folded into the
|
| 101 |
hash so different profiles can produce different lines from the same prompt.
|
| 102 |
+
When an agent opts into ``output_extra_fields`` the stub emits a JSON object
|
| 103 |
+
carrying those fields (e.g. ``thought``/``mood``); otherwise it returns bare
|
| 104 |
+
prose exactly as before.
|
| 105 |
"""
|
| 106 |
|
| 107 |
variant: str = "stub<=4b"
|
|
|
|
| 127 |
],
|
| 128 |
}
|
| 129 |
options = choices.get(role, ["The wood hums and waits."])
|
| 130 |
+
text = options[int(digest[:2], 16) % len(options)]
|
| 131 |
+
|
| 132 |
+
out = text
|
| 133 |
+
schema = _parse_output_schema(prompt)
|
| 134 |
+
if schema is not None:
|
| 135 |
+
allowed_kinds, fields = schema
|
| 136 |
+
extra = [f for f in fields if f not in ("kind", "text")]
|
| 137 |
+
if extra: # only agents that opted into extra fields take the JSON path
|
| 138 |
+
obj: dict[str, str] = {
|
| 139 |
+
"kind": allowed_kinds[int(digest[2:4], 16) % len(allowed_kinds)],
|
| 140 |
+
"text": text,
|
| 141 |
+
}
|
| 142 |
+
for name in extra:
|
| 143 |
+
obj[name] = self._synth_field(name, role, digest)
|
| 144 |
+
out = json.dumps(obj, ensure_ascii=False)
|
| 145 |
+
|
| 146 |
self._last_usage = {
|
| 147 |
"prompt_tokens": estimate_tokens(prompt),
|
| 148 |
"completion_tokens": estimate_tokens(out),
|
| 149 |
"total_tokens": estimate_tokens(prompt) + estimate_tokens(out),
|
| 150 |
}
|
| 151 |
return out
|
| 152 |
+
|
| 153 |
+
def _synth_field(self, name: str, role: str, digest: str) -> str:
|
| 154 |
+
"""Deterministically synthesise a value for one requested extra field."""
|
| 155 |
+
if name == "mood":
|
| 156 |
+
return _STUB_MOODS[int(digest[4:6], 16) % len(_STUB_MOODS)]
|
| 157 |
+
if name == "thought":
|
| 158 |
+
opts = _STUB_THOUGHTS.get(role, _STUB_THOUGHT_DEFAULT)
|
| 159 |
+
return opts[int(digest[6:8], 16) % len(opts)]
|
| 160 |
+
# Unknown extra field: a short, stable placeholder keeps the output valid.
|
| 161 |
+
return f"{name}:{digest[:4]}"
|
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fishbowl UI presenter — turns engine events into the design's view-model.
|
| 2 |
+
|
| 3 |
+
Pure and transport-agnostic (no Gradio import here): the same snapshot feeds the
|
| 4 |
+
``gr.HTML`` stage now and a future ``gr.Server`` JSON endpoint (ADR-0021). This
|
| 5 |
+
package depends only on the engine's public read surface — ``ledger.events``,
|
| 6 |
+
``rebuild_stage``, ``governor.stats``, agent manifests — and the engine never imports
|
| 7 |
+
it, so ``tests/test_modularity.py`` and the four contracts are untouched.
|
| 8 |
+
|
| 9 |
+
Layers:
|
| 10 |
+
* ``cast_state`` — ``derive_cast_state`` : per-agent {said, thought, mood} ledger view (G1)
|
| 11 |
+
* ``adapter`` — engine vocabulary → the design's say/narrate/poke/verdict + hue/tier/voice
|
| 12 |
+
* ``view_model`` — ``view_model_at`` : a JSON-serialisable snapshot at any scrubbed step k
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from src.ui.fishbowl.cast_state import CastMemberState, derive_cast_state
|
| 16 |
+
from src.ui.fishbowl.view_model import view_model_at
|
| 17 |
+
|
| 18 |
+
__all__ = ["CastMemberState", "derive_cast_state", "view_model_at"]
|
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Engine → Fishbowl design vocabulary.
|
| 2 |
+
|
| 3 |
+
Maps the engine's open event/profile vocabulary onto the prototype's presentation
|
| 4 |
+
language (``ui/raw/data.js``): the say/narrate/poke/verdict feed kinds, the fast/mid/deep
|
| 5 |
+
model tiers, the narrator voices, and the mood palette. Everything degrades gracefully:
|
| 6 |
+
an unknown mood renders as ``calm``, an agent with no ``hue`` gets a stable colour from
|
| 7 |
+
its name, and a custom event kind with ``text`` still becomes a feed line. Pure data
|
| 8 |
+
mapping — no Gradio, no engine mutation.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import hashlib
|
| 14 |
+
|
| 15 |
+
from src.core.events import Event
|
| 16 |
+
|
| 17 |
+
# ── model tiers (the prototype's coloured tier dot) ─────────────────────────────
|
| 18 |
+
# Engine profiles (tiny/fast/balanced/strong) collapse onto the design's three tiers.
|
| 19 |
+
_PROFILE_TIER: dict[str, str] = {
|
| 20 |
+
"tiny": "fast",
|
| 21 |
+
"fast": "fast",
|
| 22 |
+
"balanced": "mid",
|
| 23 |
+
"strong": "deep",
|
| 24 |
+
}
|
| 25 |
+
TIER_COLOR: dict[str, str] = {"fast": "var(--lime)", "mid": "var(--cyan)", "deep": "var(--violet)"}
|
| 26 |
+
|
| 27 |
+
# ── moods (open vocabulary; unknown → calm) ─────────────────────────────────────
|
| 28 |
+
# label + CSS colour var, mirroring ui/raw/shared.jsx:MOOD_META.
|
| 29 |
+
MOOD_META: dict[str, tuple[str, str]] = {
|
| 30 |
+
"thinking": ("thinking", "var(--ink-mid)"),
|
| 31 |
+
"calm": ("composed", "var(--cyan)"),
|
| 32 |
+
"lying": ("bluffing", "var(--coral)"),
|
| 33 |
+
"panic": ("PANICKING", "var(--coral)"),
|
| 34 |
+
"smug": ("smug", "var(--amber)"),
|
| 35 |
+
"truth": ("sincere", "var(--lime)"),
|
| 36 |
+
"gossip": ("scheming", "var(--amber)"),
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
# ── narrator voices (ui/raw/data.js:VOICES) ─────────────────────────────────────
|
| 40 |
+
VOICES: dict[str, tuple[str, str]] = {
|
| 41 |
+
"doc": ("THE DOCUMENTARIAN", "deadpan nature host"),
|
| 42 |
+
"noir": ("THE GUMSHOE", "noir detective"),
|
| 43 |
+
"bard": ("THE BARD", "mythic storyteller"),
|
| 44 |
+
"hype": ("THE PLAY-BY-PLAY", "breathless sportscaster"),
|
| 45 |
+
}
|
| 46 |
+
# A sensible default narrator per shipped scenario; the Lab may override it.
|
| 47 |
+
_SCENARIO_VOICE: dict[str, str] = {
|
| 48 |
+
"thousand-token-wood": "bard",
|
| 49 |
+
"mystery-roots": "noir",
|
| 50 |
+
"oracle-grove": "doc",
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# ── agent identity ──────────────────────────────────────────────────────────────
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def agent_hue(manifest) -> int:
|
| 58 |
+
"""The manifest's ``hue``, or a stable 0–360 hue derived from the name."""
|
| 59 |
+
hue = getattr(manifest, "hue", None)
|
| 60 |
+
if hue is not None:
|
| 61 |
+
return int(hue) % 360
|
| 62 |
+
digest = hashlib.sha256(manifest.name.encode("utf-8")).hexdigest()
|
| 63 |
+
return int(digest[:4], 16) % 360
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def agent_archetype(manifest) -> str:
|
| 67 |
+
"""The manifest's ``archetype``, or a fallback derived from its role."""
|
| 68 |
+
return getattr(manifest, "archetype", None) or f"the {manifest.role}"
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def model_tier(profile: str) -> str:
|
| 72 |
+
return _PROFILE_TIER.get(profile, "mid")
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# ── moods + voices ──────────────────────────────────────────────────────────────
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def normalize_mood(mood: str | None) -> str:
|
| 79 |
+
return mood if mood in MOOD_META else "calm"
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def mood_label(mood: str | None) -> str:
|
| 83 |
+
return MOOD_META.get(normalize_mood(mood))[0]
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def mood_color(mood: str | None) -> str:
|
| 87 |
+
return MOOD_META.get(normalize_mood(mood))[1]
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def scenario_voice(scenario_name: str) -> str:
|
| 91 |
+
return _SCENARIO_VOICE.get(scenario_name, "doc")
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# ── feed vocabulary (say / narrate / poke / verdict) ────────────────────────────
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def event_to_feed_item(event: Event, cast_names: list[str] | None = None) -> dict | None:
|
| 98 |
+
"""Map one engine event to a Fishbowl feed item, or ``None`` to omit it."""
|
| 99 |
+
kind = event.kind
|
| 100 |
+
p = event.payload
|
| 101 |
+
if kind == "world.observed":
|
| 102 |
+
return {"kind": "narrate", "voice": p.get("voice"), "text": p.get("text", "")}
|
| 103 |
+
if kind == "user.injected":
|
| 104 |
+
return {"kind": "poke", "label": p.get("label", "DISTURBANCE"), "text": p.get("text", "")}
|
| 105 |
+
if kind == "judge.verdict":
|
| 106 |
+
return {"kind": "verdict", "text": p.get("text", ""), "reveal": p.get("reveal", []), "agent": event.actor}
|
| 107 |
+
if kind in ("run.started", "agent.reflected"):
|
| 108 |
+
return None
|
| 109 |
+
if kind == "agent.thought":
|
| 110 |
+
return {
|
| 111 |
+
"kind": "say",
|
| 112 |
+
"agent": event.actor,
|
| 113 |
+
"said": None,
|
| 114 |
+
"thought": p.get("text"),
|
| 115 |
+
"mood": normalize_mood(p.get("mood")),
|
| 116 |
+
}
|
| 117 |
+
if kind in ("agent.spoke", "oracle.spoke") or "text" in p:
|
| 118 |
+
return {
|
| 119 |
+
"kind": "say",
|
| 120 |
+
"agent": event.actor,
|
| 121 |
+
"said": p.get("text"),
|
| 122 |
+
"thought": p.get("thought"),
|
| 123 |
+
"mood": normalize_mood(p.get("mood")),
|
| 124 |
+
}
|
| 125 |
+
return None
|
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Per-agent stage state — a pure projection of the ledger (G1, ADR-0021).
|
| 2 |
+
|
| 3 |
+
The engine's :class:`StageProjection` keeps a flat ``agent_notes`` list; the Fishbowl
|
| 4 |
+
MindCard needs, per mind, its latest public ``said``, private ``thought``, and current
|
| 5 |
+
``mood``. ``derive_cast_state`` is the missing projection: like ``rebuild_stage`` it is
|
| 6 |
+
a pure function of an events slice, so the UI can show the world at any scrubbed step
|
| 7 |
+
``k`` by passing ``events[:k]`` — and it never mutates the log.
|
| 8 |
+
|
| 9 |
+
The say-vs-think pairing rides on optional payload fields (ADR-0009): an agent that
|
| 10 |
+
emits ``agent.spoke`` carries ``thought``/``mood`` alongside ``text``; an agent that
|
| 11 |
+
emits ``agent.thought`` puts its inner line in ``text`` directly. Both are produced by
|
| 12 |
+
the model live and by the deterministic stub offline, so the mind-reader works with no
|
| 13 |
+
API key.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
from collections.abc import Iterable
|
| 19 |
+
from dataclasses import dataclass
|
| 20 |
+
|
| 21 |
+
from src.core.events import Event
|
| 22 |
+
|
| 23 |
+
# Kinds whose ``text`` is a public utterance (the front-of-card "said" line).
|
| 24 |
+
_SAID_KINDS = frozenset({"agent.spoke", "world.observed", "oracle.spoke"})
|
| 25 |
+
# Kinds whose ``text`` is itself the private thought.
|
| 26 |
+
_THINK_KINDS = frozenset({"agent.thought"})
|
| 27 |
+
# A judge's ruling — shown as that mind's "said" (and separately as a verdict).
|
| 28 |
+
_VERDICT_KINDS = frozenset({"judge.verdict"})
|
| 29 |
+
# Never alters a mind's said/thought (genesis + private memory compaction).
|
| 30 |
+
_IGNORED_KINDS = frozenset({"run.started", "agent.reflected"})
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@dataclass
|
| 34 |
+
class CastMemberState:
|
| 35 |
+
"""The current say/think/mood of one mind, derived from the ledger."""
|
| 36 |
+
|
| 37 |
+
said: str | None = None
|
| 38 |
+
thought: str | None = None
|
| 39 |
+
mood: str = "calm"
|
| 40 |
+
spoke: bool = False
|
| 41 |
+
last_turn: int | None = None
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def derive_cast_state(
|
| 45 |
+
events: Iterable[Event],
|
| 46 |
+
cast_names: Iterable[str],
|
| 47 |
+
) -> dict[str, CastMemberState]:
|
| 48 |
+
"""Replay *events* into ``{agent_name: CastMemberState}`` — pure and deterministic.
|
| 49 |
+
|
| 50 |
+
Events from actors not in *cast_names* (e.g. ``conductor``, ``visitor``) are
|
| 51 |
+
ignored here; they surface in the narrator feed / poke strip instead.
|
| 52 |
+
"""
|
| 53 |
+
state = {name: CastMemberState() for name in cast_names}
|
| 54 |
+
for e in events:
|
| 55 |
+
st = state.get(e.actor)
|
| 56 |
+
if st is None or e.kind in _IGNORED_KINDS:
|
| 57 |
+
continue
|
| 58 |
+
text = e.payload.get("text")
|
| 59 |
+
if e.kind in _SAID_KINDS or e.kind in _VERDICT_KINDS:
|
| 60 |
+
if text is not None:
|
| 61 |
+
st.said = str(text)
|
| 62 |
+
st.spoke = True
|
| 63 |
+
elif e.kind in _THINK_KINDS:
|
| 64 |
+
if text is not None:
|
| 65 |
+
st.thought = str(text)
|
| 66 |
+
elif text is not None:
|
| 67 |
+
# A custom namespaced kind that carries text → treat as an utterance,
|
| 68 |
+
# so a drop-in agent renders on stage with zero presenter edits.
|
| 69 |
+
st.said = str(text)
|
| 70 |
+
st.spoke = True
|
| 71 |
+
# Paired private thought / mood ride as optional payload fields (ADR-0021).
|
| 72 |
+
if e.payload.get("thought"):
|
| 73 |
+
st.thought = str(e.payload["thought"])
|
| 74 |
+
if e.payload.get("mood"):
|
| 75 |
+
st.mood = str(e.payload["mood"])
|
| 76 |
+
st.last_turn = e.turn
|
| 77 |
+
return state
|
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``view_model_at`` — a JSON-serialisable snapshot of the world at a scrubbed step.
|
| 2 |
+
|
| 3 |
+
This is the single object the Show renders: cast cards, the narrator feed, meters, the
|
| 4 |
+
verdict. It is a pure function of ``events[:k]`` (the same prefix-replay discipline as
|
| 5 |
+
``rebuild_stage``), so the transport can scrub anywhere and a future ``gr.Server`` can
|
| 6 |
+
serve the very same dict as JSON. Token/round meters read real data from the run rather
|
| 7 |
+
than the prototype's fakes (G9).
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from collections.abc import Iterable, Sequence
|
| 13 |
+
|
| 14 |
+
from src.core.events import Event
|
| 15 |
+
from src.core.governor import Governor
|
| 16 |
+
from src.core.manifest import AgentManifest
|
| 17 |
+
from src.core.projections import rebuild_stage
|
| 18 |
+
from src.models.provider import estimate_tokens
|
| 19 |
+
from src.ui.fishbowl.adapter import (
|
| 20 |
+
VOICES,
|
| 21 |
+
agent_archetype,
|
| 22 |
+
agent_hue,
|
| 23 |
+
event_to_feed_item,
|
| 24 |
+
model_tier,
|
| 25 |
+
mood_label,
|
| 26 |
+
normalize_mood,
|
| 27 |
+
scenario_voice,
|
| 28 |
+
)
|
| 29 |
+
from src.ui.fishbowl.cast_state import derive_cast_state
|
| 30 |
+
|
| 31 |
+
# Kinds whose actor, when the head event, lights the "speaking" ring on a card.
|
| 32 |
+
_SPEAKING_KINDS = frozenset({"agent.spoke", "agent.thought", "oracle.spoke", "judge.verdict"})
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _estimate_tokens_through(events: Sequence[Event]) -> int:
|
| 36 |
+
"""A real-text token estimate for the scrubber meter (grows as you advance)."""
|
| 37 |
+
total = 0
|
| 38 |
+
for e in events:
|
| 39 |
+
text = e.payload.get("text") or e.payload.get("summary") or ""
|
| 40 |
+
total += estimate_tokens(str(text))
|
| 41 |
+
return total
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def view_model_at(
|
| 45 |
+
events: Iterable[Event],
|
| 46 |
+
k: int,
|
| 47 |
+
cast: Sequence[AgentManifest],
|
| 48 |
+
*,
|
| 49 |
+
scenario_name: str = "",
|
| 50 |
+
goal: str = "",
|
| 51 |
+
governor: Governor | None = None,
|
| 52 |
+
voice: str | None = None,
|
| 53 |
+
token_ceiling: int | None = None,
|
| 54 |
+
max_rounds: int | None = None,
|
| 55 |
+
) -> dict:
|
| 56 |
+
"""Build the Show's snapshot at step *k* (clamped to ``[0, len(events)]``)."""
|
| 57 |
+
events = tuple(events)
|
| 58 |
+
n = len(events)
|
| 59 |
+
k = max(0, min(int(k), n))
|
| 60 |
+
prefix = events[:k]
|
| 61 |
+
|
| 62 |
+
stage = rebuild_stage(prefix)
|
| 63 |
+
names = [m.name for m in cast]
|
| 64 |
+
states = derive_cast_state(prefix, names)
|
| 65 |
+
|
| 66 |
+
speaking_id: str | None = None
|
| 67 |
+
if k > 0:
|
| 68 |
+
head = events[k - 1]
|
| 69 |
+
if (head.kind in _SPEAKING_KINDS or "text" in head.payload) and head.actor in names:
|
| 70 |
+
speaking_id = head.actor
|
| 71 |
+
|
| 72 |
+
cast_vm = []
|
| 73 |
+
for m in cast:
|
| 74 |
+
st = states[m.name]
|
| 75 |
+
cast_vm.append(
|
| 76 |
+
{
|
| 77 |
+
"id": m.name,
|
| 78 |
+
"name": m.name,
|
| 79 |
+
"archetype": agent_archetype(m),
|
| 80 |
+
"hue": agent_hue(m),
|
| 81 |
+
"role": m.role,
|
| 82 |
+
"model_profile": m.model_profile,
|
| 83 |
+
"tier": model_tier(m.model_profile),
|
| 84 |
+
"said": st.said,
|
| 85 |
+
"thought": st.thought,
|
| 86 |
+
"mood": normalize_mood(st.mood),
|
| 87 |
+
"mood_label": mood_label(st.mood),
|
| 88 |
+
"spoke": st.spoke,
|
| 89 |
+
"speaking": m.name == speaking_id,
|
| 90 |
+
}
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
feed = []
|
| 94 |
+
for e in prefix:
|
| 95 |
+
item = event_to_feed_item(e, names)
|
| 96 |
+
if item is not None:
|
| 97 |
+
item["turn"] = e.turn
|
| 98 |
+
feed.append(item)
|
| 99 |
+
|
| 100 |
+
verdict = None
|
| 101 |
+
for e in prefix:
|
| 102 |
+
if e.kind == "judge.verdict":
|
| 103 |
+
verdict = {
|
| 104 |
+
"text": e.payload.get("text", ""),
|
| 105 |
+
"reveal": e.payload.get("reveal", []),
|
| 106 |
+
"agent": e.actor,
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
rounds = 1 + sum(1 for e in prefix if e.kind == "user.injected")
|
| 110 |
+
chosen_voice = voice or scenario_voice(scenario_name)
|
| 111 |
+
voice_name, voice_desc = VOICES.get(chosen_voice, ("NARRATOR", ""))
|
| 112 |
+
|
| 113 |
+
return {
|
| 114 |
+
"step": k,
|
| 115 |
+
"total": n,
|
| 116 |
+
"scene": stage.current_scene,
|
| 117 |
+
"seed": stage.seed,
|
| 118 |
+
"goal": goal or stage.goal,
|
| 119 |
+
"cast": cast_vm,
|
| 120 |
+
"feed": feed,
|
| 121 |
+
"voice": chosen_voice,
|
| 122 |
+
"voice_meta": {"name": voice_name, "desc": voice_desc},
|
| 123 |
+
"speaking_id": speaking_id,
|
| 124 |
+
"verdict": verdict,
|
| 125 |
+
"rounds": rounds,
|
| 126 |
+
"max_rounds": max_rounds,
|
| 127 |
+
"tokens": _estimate_tokens_through(prefix),
|
| 128 |
+
"tokens_real": dict(governor.stats) if governor is not None else None,
|
| 129 |
+
"token_ceiling": token_ceiling,
|
| 130 |
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fishbowl presenter — cast-state projection, adapter mapping, view-model snapshot.
|
| 2 |
+
|
| 3 |
+
The marquee proof is :class:`TestOfflineEmitsMoodAndThought`: with no API key, a real
|
| 4 |
+
conductor run produces a ledger that carries the say-vs-think ``thought``/``mood`` the
|
| 5 |
+
UI renders — so the mind-reader is genuinely model-driven offline (ADR-0021). Zero
|
| 6 |
+
mocks, per the repo convention.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from src.core.conductor import Conductor
|
| 12 |
+
from src.core.events import Event
|
| 13 |
+
from src.core.ledger_factory import make_ledger
|
| 14 |
+
from src.core.registry import default_registry
|
| 15 |
+
from src.tools.builtins import default_tool_registry
|
| 16 |
+
from src.ui.fishbowl import adapter, derive_cast_state, view_model_at
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _ev(kind: str, actor: str, turn: int = 1, **payload) -> Event:
|
| 20 |
+
return Event(run_id="r", turn=turn, kind=kind, actor=actor, payload=payload)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class TestDeriveCastState:
|
| 24 |
+
def test_spoke_with_thought_and_mood(self):
|
| 25 |
+
events = (_ev("agent.spoke", "pocket-actor", text="I want the moon", thought="secretly scared", mood="panic"),)
|
| 26 |
+
st = derive_cast_state(events, ["pocket-actor"])["pocket-actor"]
|
| 27 |
+
assert st.said == "I want the moon"
|
| 28 |
+
assert st.thought == "secretly scared"
|
| 29 |
+
assert st.mood == "panic"
|
| 30 |
+
assert st.spoke is True
|
| 31 |
+
|
| 32 |
+
def test_thought_only_agent_has_no_said(self):
|
| 33 |
+
events = (_ev("agent.thought", "echo", text="the wood holds its breath", mood="thinking"),)
|
| 34 |
+
st = derive_cast_state(events, ["echo"])["echo"]
|
| 35 |
+
assert st.thought == "the wood holds its breath"
|
| 36 |
+
assert st.said is None
|
| 37 |
+
assert st.mood == "thinking"
|
| 38 |
+
|
| 39 |
+
def test_latest_wins_and_prefix_replay_is_pure(self):
|
| 40 |
+
events = (
|
| 41 |
+
_ev("agent.spoke", "pocket-actor", turn=1, text="first", mood="calm"),
|
| 42 |
+
_ev("agent.spoke", "pocket-actor", turn=2, text="second", mood="smug"),
|
| 43 |
+
)
|
| 44 |
+
assert derive_cast_state(events, ["pocket-actor"])["pocket-actor"].said == "second"
|
| 45 |
+
# scrub back to the prefix — deterministic, no mutation of the log
|
| 46 |
+
assert derive_cast_state(events[:1], ["pocket-actor"])["pocket-actor"].said == "first"
|
| 47 |
+
assert derive_cast_state(events[:1], ["pocket-actor"])["pocket-actor"].said == "first"
|
| 48 |
+
|
| 49 |
+
def test_unknown_actor_is_ignored(self):
|
| 50 |
+
states = derive_cast_state((_ev("agent.spoke", "stranger", text="hi"),), ["pocket-actor"])
|
| 51 |
+
assert states["pocket-actor"].said is None
|
| 52 |
+
assert "stranger" not in states
|
| 53 |
+
|
| 54 |
+
def test_reflection_does_not_touch_said_or_thought(self):
|
| 55 |
+
events = (_ev("agent.reflected", "scene-whisperer", text="I am patient"),)
|
| 56 |
+
st = derive_cast_state(events, ["scene-whisperer"])["scene-whisperer"]
|
| 57 |
+
assert st.said is None and st.thought is None
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class TestAdapter:
|
| 61 |
+
def test_hue_prefers_manifest_else_derives_stably(self):
|
| 62 |
+
class WithHue:
|
| 63 |
+
name, hue, archetype, role = "x", 42, None, "worker"
|
| 64 |
+
|
| 65 |
+
class NoHue:
|
| 66 |
+
name, hue, archetype, role = "echo", None, None, "worker"
|
| 67 |
+
|
| 68 |
+
assert adapter.agent_hue(WithHue()) == 42
|
| 69 |
+
h = adapter.agent_hue(NoHue())
|
| 70 |
+
assert 0 <= h < 360 and adapter.agent_hue(NoHue()) == h # stable
|
| 71 |
+
|
| 72 |
+
def test_tier_mapping(self):
|
| 73 |
+
assert adapter.model_tier("tiny") == "fast"
|
| 74 |
+
assert adapter.model_tier("balanced") == "mid"
|
| 75 |
+
assert adapter.model_tier("strong") == "deep"
|
| 76 |
+
|
| 77 |
+
def test_mood_normalization(self):
|
| 78 |
+
assert adapter.normalize_mood("panic") == "panic"
|
| 79 |
+
assert adapter.normalize_mood("curious") == "calm"
|
| 80 |
+
assert adapter.normalize_mood(None) == "calm"
|
| 81 |
+
|
| 82 |
+
def test_feed_vocabulary(self):
|
| 83 |
+
assert adapter.event_to_feed_item(_ev("world.observed", "sw", text="x"))["kind"] == "narrate"
|
| 84 |
+
assert adapter.event_to_feed_item(_ev("user.injected", "visitor", text="x", label="GUST"))["label"] == "GUST"
|
| 85 |
+
assert adapter.event_to_feed_item(_ev("user.injected", "visitor", text="x"))["label"] == "DISTURBANCE"
|
| 86 |
+
assert adapter.event_to_feed_item(_ev("judge.verdict", "j", text="guilty"))["kind"] == "verdict"
|
| 87 |
+
assert adapter.event_to_feed_item(_ev("run.started", "conductor", seed="s")) is None
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class TestViewModel:
|
| 91 |
+
def _events(self) -> tuple[Event, ...]:
|
| 92 |
+
return (
|
| 93 |
+
_ev("run.started", "conductor", turn=0, seed="seed", goal="g"),
|
| 94 |
+
_ev("world.observed", "scene-whisperer", turn=1, text="the wood wakes"),
|
| 95 |
+
_ev("agent.spoke", "pocket-actor", turn=2, text="I want the moon", thought="scared", mood="panic"),
|
| 96 |
+
_ev("user.injected", "visitor", turn=3, text="a lantern hums", label="POKE"),
|
| 97 |
+
_ev("judge.verdict", "mischief-critic", turn=4, text="keep it", mood="smug"),
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
def _cast(self):
|
| 101 |
+
scenario = default_registry().build_scenario("thousand-token-wood", tools=default_tool_registry())
|
| 102 |
+
return [a.manifest for a in scenario.agents]
|
| 103 |
+
|
| 104 |
+
def test_snapshot_shape(self):
|
| 105 |
+
events, cast = self._events(), self._cast()
|
| 106 |
+
vm = view_model_at(events, len(events), cast, scenario_name="thousand-token-wood")
|
| 107 |
+
assert vm["step"] == vm["total"] == len(events)
|
| 108 |
+
assert vm["scene"] == "the wood wakes"
|
| 109 |
+
pa = next(c for c in vm["cast"] if c["id"] == "pocket-actor")
|
| 110 |
+
assert pa["said"] == "I want the moon" and pa["thought"] == "scared" and pa["mood"] == "panic"
|
| 111 |
+
kinds = {f["kind"] for f in vm["feed"]}
|
| 112 |
+
assert {"narrate", "say", "poke", "verdict"} <= kinds # run.started omitted
|
| 113 |
+
assert vm["verdict"]["text"] == "keep it"
|
| 114 |
+
assert vm["rounds"] == 2 # one poke
|
| 115 |
+
|
| 116 |
+
def test_prefix_is_clamped_and_tokens_grow(self):
|
| 117 |
+
events, cast = self._events(), self._cast()
|
| 118 |
+
vm0 = view_model_at(events, 0, cast)
|
| 119 |
+
vm_all = view_model_at(events, 999, cast) # clamps to len
|
| 120 |
+
assert vm0["step"] == 0 and vm_all["step"] == len(events)
|
| 121 |
+
assert vm_all["tokens"] >= vm0["tokens"]
|
| 122 |
+
|
| 123 |
+
def test_speaking_id_tracks_the_head(self):
|
| 124 |
+
events, cast = self._events(), self._cast()
|
| 125 |
+
vm = view_model_at(events, 3, cast) # head is pocket-actor's spoke
|
| 126 |
+
assert vm["speaking_id"] == "pocket-actor"
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
class TestOfflineEmitsMoodAndThought:
|
| 130 |
+
"""With no API key the ledger itself carries the say-vs-think data (ADR-0021)."""
|
| 131 |
+
|
| 132 |
+
def _run(self, scenario: str, steps: int = 6) -> Conductor:
|
| 133 |
+
reg = default_registry()
|
| 134 |
+
c = Conductor(
|
| 135 |
+
reg.build_scenario(scenario, tools=default_tool_registry()),
|
| 136 |
+
governor=reg.governor_for(scenario),
|
| 137 |
+
ledger=make_ledger(),
|
| 138 |
+
)
|
| 139 |
+
c.reset(c.scenario.default_seed)
|
| 140 |
+
c.step(n_ticks=steps)
|
| 141 |
+
return c
|
| 142 |
+
|
| 143 |
+
def test_pocket_actor_spoke_carries_thought_and_mood(self):
|
| 144 |
+
c = self._run("thousand-token-wood")
|
| 145 |
+
spoke = [e for e in c.ledger.events if e.kind == "agent.spoke" and e.actor == "pocket-actor"]
|
| 146 |
+
assert spoke, "pocket-actor (tick_every=2) should speak within a few ticks"
|
| 147 |
+
payload = spoke[-1].payload
|
| 148 |
+
assert payload.get("thought"), "the say-vs-think thought must be in the ledger offline"
|
| 149 |
+
assert payload.get("mood"), "the mood must be in the ledger offline"
|
| 150 |
+
assert payload.get("_raw_fallback") is None, "structured output should be clean offline"
|
| 151 |
+
|
| 152 |
+
def test_opt_out_agent_payload_has_no_extra_fields(self):
|
| 153 |
+
c = self._run("thousand-token-wood")
|
| 154 |
+
obs = [e for e in c.ledger.events if e.kind == "world.observed" and e.actor == "scene-whisperer"]
|
| 155 |
+
assert obs
|
| 156 |
+
# scene-whisperer declares no output_extra_fields → no thought/mood leak.
|
| 157 |
+
assert "thought" not in obs[-1].payload and "mood" not in obs[-1].payload
|
| 158 |
+
|
| 159 |
+
def test_view_model_from_a_live_offline_run(self):
|
| 160 |
+
c = self._run("thousand-token-wood")
|
| 161 |
+
cast = [a.manifest for a in c.scenario.agents]
|
| 162 |
+
vm = view_model_at(
|
| 163 |
+
c.ledger.events,
|
| 164 |
+
len(c.ledger.events),
|
| 165 |
+
cast,
|
| 166 |
+
scenario_name="thousand-token-wood",
|
| 167 |
+
governor=c.governor,
|
| 168 |
+
)
|
| 169 |
+
assert vm["cast"] and vm["tokens_real"] is not None
|
| 170 |
+
# the mind-reader has something real to show: a thought and/or a vivid mood.
|
| 171 |
+
assert any(c2["thought"] for c2 in vm["cast"]) or ({c2["mood"] for c2 in vm["cast"]} - {"calm"})
|