agharsallah Codex commited on
Commit
32a601f
·
1 Parent(s): 42e32ed

feat: phase 1 — memory layer, governor, second scenario, 70 passing tests

Browse files

Engine additions (zero scenario edits required):
- EpisodicMemory: per-agent filtered view over the ledger; agents see only their
own actions and globally-visible event kinds, capped at 8 events for small-model
context windows
- ContextBuilder: single point of prompt assembly (persona → scene → memory →
visitor disturbances); changing prompt strategy is now a one-file edit
- Governor: enforces max_turns / max_calls_per_turn / max_total_calls; raises
BudgetExceeded before a runaway cascade can burn quota

Model provider:
- OpenAICompatProvider: works with any OpenAI-compatible endpoint; config via
OPENAI_API_KEY / OPENAI_BASE_URL / MODEL_NAME; falls back to deterministic stub
when no key is present

Agents + scenarios:
- All four Tiny Wood agents use ContextBuilder with rich personas and episodic memory
- New EchoAgent transforms visitor injections through the wood's logic
- Mystery Roots (src/scenarios/mystery_roots.py): convergent blackboard swarm with
ClueGatherer / HypothesisFormer / DevilsAdvocate / MysteryJudge — zero engine edits

UI:
- Two-scenario dropdown with per-scenario seed gallery
- Governor stats surfaced in the run-stats panel
- Custom CSS with variables, stage gradient, monospace ledger

Tests: 14 → 70 passing (test_memory, test_governor, test_mystery_roots,
test_events, test_projections, test_scenario, test_ledger, test_conductor)

Docs: ADR-0005 through ADR-0008, engine-architecture blog post, phase-1 journal

Co-Authored-By: Codex <codex@openai.com>

.env.example ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copy to .env and fill in your values.
2
+ # Without OPENAI_API_KEY the app runs on a deterministic local stub.
3
+
4
+ # Required for live model inference
5
+ OPENAI_API_KEY=sk-...
6
+
7
+ # Optional: point at any OpenAI-compatible endpoint
8
+ # Examples:
9
+ # Together AI: https://api.together.xyz/v1
10
+ # Groq: https://api.groq.com/openai/v1
11
+ # Ollama local: http://localhost:11434/v1
12
+ # NVIDIA NIM: https://integrate.api.nvidia.com/v1
13
+ OPENAI_BASE_URL=
14
+
15
+ # Model name — any model the endpoint supports
16
+ # Default: gpt-4o-mini (fast, cheap, under 32B effective param count for tasks)
17
+ # Tiny Titan (<=4B): e.g. Qwen2.5-3B-Instruct via Together or Ollama
18
+ MODEL_NAME=gpt-4o-mini
19
+
20
+ # Set to 1 to activate <=4B Tiny Titan profile
21
+ TINY_TITAN_MODE=0
22
+
23
+ # Gradio server port (auto-detects a free port in 7960-8059 if unset)
24
+ GRADIO_SERVER_PORT=
.gitignore CHANGED
@@ -4,4 +4,5 @@ __pycache__/
4
  .env
5
  .DS_Store
6
  .gradio/
7
-
 
 
4
  .env
5
  .DS_Store
6
  .gradio/
7
+ .claude
8
+ scripts/pre-commit
README.md CHANGED
@@ -1,63 +1,165 @@
1
  # Multi-Agent Land
2
 
3
- Hackathon project for the **Thousand Token Wood** trail: a small-model, multi-agent interactive story engine where the AI is load-bearing for the experience.
 
4
 
5
- The working thesis is:
 
 
 
6
 
7
- > One tiny event-sourced engine can power many delightful worlds. The first world is a whimsical forest theater where small specialist agents write, judge, remember, and render strange interactive scenes.
8
 
9
- ## Hackathon Targets
10
-
11
- - Delight first: strange, joyful interactions worth showing a friend.
12
- - AI is load-bearing: agents create the evolving story, not just labels around static UI.
13
- - Small models: keep every runtime model under the 32B parameter cap, with an optional <=4B Tiny Titan mode.
14
- - Polished Gradio app: custom layout, live ledger, visible agent activity, and demo-friendly defaults.
15
- - Prize stacking: aim for Thousand Token Wood, Community Choice, OpenAI Track, Tiny Titan, Best Agent, Off-Brand UI, Best Demo, and Judges' Wildcard.
16
-
17
- ## Quick Start
18
 
19
  ```bash
20
  python -m venv .venv
21
- source .venv/bin/activate
22
  pip install -r requirements.txt
 
 
 
 
23
  python app.py
24
  ```
25
 
26
- The app is intentionally usable with a deterministic local model stub first. Swap in real small-model providers through `src/models/provider.py` as the build matures.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
- ## Repository Map
 
29
 
30
- ```text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  app.py Gradio composition root
32
  src/
33
- core/ Event ledger, schemas, projections, conductor
34
- agents/ Manifest-driven specialist agents
35
- scenarios/ Scenario configuration and seeds
36
- models/ Small-model provider abstraction
37
- ui/ Gradio rendering helpers
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  docs/
39
  vision.md One-page product and technical vision
40
- architecture/ System design, C4-style notes, sequence diagrams
41
- adr/ Append-only Architecture Decision Records
42
  schema/ Event and manifest contracts
43
- runbooks/ Local dev, demo, recovery, observability
44
- strategy/ Hackathon prize strategy and judging rubric
45
  blog/ Technical blog posts built along the way
46
  journal/ Daily build log entries
47
  scripts/
48
  new_journal_entry.py Creates dated build log entries
49
- snapshot_progress.py Updates docs/blog/building-in-public.md from journal
50
  ```
51
 
52
- ## Development Loop
53
 
54
- 1. Build the thinnest vertical slice.
55
- 2. Record decisions in `docs/adr/`.
56
- 3. Capture learnings with `python scripts/new_journal_entry.py "What changed"`.
57
- 4. Regenerate the living technical blog with `python scripts/snapshot_progress.py`.
58
- 5. Keep scenarios modular: new worlds should be config and plugin files, not engine rewrites.
59
 
60
- ## Current Status
 
 
 
 
 
61
 
62
- Phase 0 foundation is scaffolded. The next milestone is a polished vertical slice: three agents, one judge, one observer projection, and a Gradio experience that feels playful immediately.
63
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  # Multi-Agent Land
2
 
3
+ Hackathon project for the **Thousand Token Wood** trail: a small-model, multi-agent
4
+ interactive story engine where the AI is load-bearing for the experience.
5
 
6
+ > One tiny event-sourced engine can power many delightful worlds. The first world is
7
+ > a whimsical forest theater where small specialist agents write, judge, remember, and
8
+ > render strange interactive scenes. The second is a mystery-solving blackboard swarm.
9
+ > Both run on the same four abstractions.
10
 
11
+ ---
12
 
13
+ ## Quickstart
 
 
 
 
 
 
 
 
14
 
15
  ```bash
16
  python -m venv .venv
17
+ source .venv/bin/activate # Windows: .venv\Scripts\activate
18
  pip install -r requirements.txt
19
+
20
+ # Optional: add your API key for live inference
21
+ cp .env.example .env # then fill in OPENAI_API_KEY
22
+
23
  python app.py
24
  ```
25
 
26
+ The app runs on a **deterministic local stub** with no API key great for testing
27
+ and demos that need to be fully reproducible. Add an `OPENAI_API_KEY` to switch to
28
+ live inference. Any OpenAI-compatible endpoint works (Together AI, Groq, Ollama,
29
+ NVIDIA NIM) — set `OPENAI_BASE_URL` in `.env`.
30
+
31
+ ### Run tests
32
+
33
+ ```bash
34
+ python -m pytest tests/ -v
35
+ ```
36
+
37
+ ---
38
+
39
+ ## What It Is
40
+
41
+ A **tiny theater engine** powered by specialist small-model agents. Agents never
42
+ call each other directly — they post typed events to a shared append-only ledger,
43
+ and every view (the stage, the memory, the UI) is a projection derived from that log.
44
+
45
+ The user can:
46
+ - **Start** a run from a seed — any weird premise works.
47
+ - **Advance** one turn and watch the agents react.
48
+ - **Drop** a disturbance into the world — the agents absorb and transform it.
49
+ - **Switch** between scenarios without reloading.
50
+
51
+ ### Scenarios
52
+
53
+ | Name | Cognitive task | Agents |
54
+ |---|---|---|
55
+ | 🍄 Thousand Token Wood | Divergent world-growth | Seedkeeper, Critic, Pocket Actor, Echo |
56
+ | 🔍 Mystery Roots | Convergent mystery-solving | Clue Gatherer, Hypothesis Former, Devil's Advocate, Judge |
57
 
58
+ Adding a third scenario requires one new file and one two-line registry entry.
59
+ **Zero engine edits.**
60
 
61
+ ---
62
+
63
+ ## Architecture in 90 seconds
64
+
65
+ ```
66
+ Visitor seed or disturbance
67
+
68
+ Conductor ← Governor (budget guard)
69
+
70
+ schedule(turn) → [Agent₁, Agent₂, ...]
71
+
72
+ ContextBuilder
73
+ ├── Pinned persona
74
+ ├── Current scene (projection)
75
+ ├── Episodic memory (ledger view)
76
+ └── Visitor disturbances
77
+
78
+ ModelProvider.complete(role, prompt)
79
+
80
+ Typed Event → Ledger.append()
81
+
82
+ Projections update
83
+
84
+ Gradio UI renders stage + ledger + stats
85
+ ```
86
+
87
+ ### Key decisions (see `docs/adr/` for full reasoning)
88
+
89
+ | # | Decision |
90
+ |---|---|
91
+ | 0001 | Append-only event ledger as the sole source of truth |
92
+ | 0002 | Gradio as the UI layer |
93
+ | 0003 | Small specialist agents over one large model |
94
+ | 0004 | Document every architectural decision as we build |
95
+ | 0005 | Agent memory is a ledger view, not a separate store |
96
+ | 0006 | `ContextBuilder` owns prompt assembly; agents own only persona + action |
97
+ | 0007 | `Governor` is injected into the conductor to enforce call budgets |
98
+ | 0008 | Zero engine edits required to add a second scenario |
99
+
100
+ ---
101
+
102
+ ## Repository map
103
+
104
+ ```
105
  app.py Gradio composition root
106
  src/
107
+ core/
108
+ events.py Event schema (Pydantic, strict)
109
+ ledger.py Append-only in-memory ledger
110
+ projections.py Pure-function stage projection
111
+ conductor.py Turn scheduler + reset + inject
112
+ memory.py EpisodicMemory — per-agent ledger view
113
+ context.py ContextBuilder — prompt assembly
114
+ governor.py Budget guard (turns, calls per turn, total calls)
115
+ agents/
116
+ base.py Abstract Agent protocol
117
+ tiny_wood.py Thousand Token Wood cast
118
+ scenarios/
119
+ base.py Scenario dataclass + default schedule
120
+ thousand_token_wood.py First scenario config
121
+ mystery_roots.py Second scenario config — modularity proof
122
+ models/
123
+ provider.py ModelProvider ABC + DeterministicTinyModel stub
124
+ openai_compat.py OpenAI-compatible provider + env-aware factory
125
+ ui/
126
+ render.py Gradio rendering helpers
127
+ tests/ 70 passing tests, zero mocks
128
  docs/
129
  vision.md One-page product and technical vision
130
+ architecture/ System design and turn lifecycle
131
+ adr/ Append-only Architecture Decision Records (0001–0008)
132
  schema/ Event and manifest contracts
133
+ runbooks/ Local dev, demo, recovery
134
+ strategy/ Hackathon prize strategy
135
  blog/ Technical blog posts built along the way
136
  journal/ Daily build log entries
137
  scripts/
138
  new_journal_entry.py Creates dated build log entries
139
+ snapshot_progress.py Updates the living blog from journal
140
  ```
141
 
142
+ ---
143
 
144
+ ## Hackathon targets
 
 
 
 
145
 
146
+ - **Genuinely delightful** — strange, joyful, worth showing a friend
147
+ - **AI is load-bearing** — agents generate the evolving scene; the user does not author it
148
+ - **Small models** — every runtime model ≤ 32B, with an optional ≤ 4B Tiny Titan mode
149
+ - **Polished Gradio** — custom theme, live ledger, visible agent trace, demo-ready seeds
150
+ - **Prize stacking** — Thousand Token Wood, Community Choice, OpenAI Track, Tiny Titan,
151
+ Best Agent, Off-Brand UI, Best Demo, Judges' Wildcard
152
 
153
+ ---
154
 
155
+ ## Development loop
156
+
157
+ ```bash
158
+ # 1. Build the thinnest slice
159
+ # 2. Record the decision
160
+ python -c "from scripts.new_journal_entry import main; main()" "What changed today"
161
+ # 3. Regenerate the living blog
162
+ python scripts/snapshot_progress.py
163
+ # 4. Confirm nothing broke
164
+ python -m pytest tests/ -q
165
+ ```
app.py CHANGED
@@ -6,112 +6,209 @@ import socket
6
  import gradio as gr
7
 
8
  from src.core.conductor import Conductor
9
- from src.scenarios.thousand_token_wood import build_scenario
10
  from src.ui.render import render_event_log, render_stage, render_stats
11
 
 
12
 
13
- scenario = build_scenario()
14
- conductor = Conductor(scenario=scenario)
 
 
 
 
 
 
15
 
16
  APP_CSS = """
17
- body { background: #10130f; }
18
- .gradio-container { max-width: 1180px !important; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  #stage {
20
- border: 1px solid #3e4a36;
21
- background: linear-gradient(180deg, #172017 0%, #11160f 100%);
22
- color: #f4f0df;
23
- padding: 18px;
24
- border-radius: 8px;
25
- min-height: 420px;
 
 
26
  }
 
 
 
 
27
  #events textarea, #stats textarea {
28
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
29
- font-size: 12px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  }
31
- .wood-title h1 { margin-bottom: 0; }
32
- .wood-title p { margin-top: 6px; color: #b9c3a7; }
33
  """
34
 
 
35
 
36
- def dev_server_port() -> int:
37
- configured = os.getenv("GRADIO_SERVER_PORT")
38
- if configured:
39
- return int(configured)
40
- for port in range(7960, 8060):
41
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
42
- try:
43
- sock.bind(("127.0.0.1", port))
44
- return port
45
- except OSError:
46
- continue
47
- raise RuntimeError("No free development port found in range 7960-8059.")
48
 
49
 
50
- def start(seed: str):
51
- conductor.reset(seed.strip() or scenario.default_seed)
52
  return (
53
- render_stage(conductor.projection),
54
- render_event_log(conductor.ledger.events),
55
- render_stats(conductor.ledger.events),
56
  )
57
 
58
 
59
- def step():
60
- conductor.step()
61
- return (
62
- render_stage(conductor.projection),
63
- render_event_log(conductor.ledger.events),
64
- render_stats(conductor.ledger.events),
65
- )
66
 
 
 
 
 
67
 
68
- def inject(user_event: str):
 
 
69
  if user_event.strip():
70
- conductor.inject_user_event(user_event.strip())
71
- conductor.step()
72
- return (
73
- render_stage(conductor.projection),
74
- render_event_log(conductor.ledger.events),
75
- render_stats(conductor.ledger.events),
76
- )
77
 
 
 
 
 
 
 
78
 
79
- with gr.Blocks(title="Multi-Agent Land") as demo:
 
 
 
80
  gr.Markdown(
81
  """
82
  # Multi-Agent Land
83
- Tiny agents wander a strange wood, writing an interactive story through an append-only ledger.
84
  """,
85
- elem_classes=["wood-title"],
86
  )
87
 
88
  with gr.Row():
89
- seed = gr.Textbox(
 
 
 
 
 
 
 
 
 
 
 
 
90
  label="World seed",
91
- value=scenario.default_seed,
92
- lines=3,
 
93
  )
94
- with gr.Column(scale=0):
95
- start_button = gr.Button("Start run", variant="primary")
96
- step_button = gr.Button("Advance one turn")
97
 
98
- stage = gr.Markdown(value="Start a run to raise the curtain.", elem_id="stage")
 
 
 
99
 
100
  with gr.Row():
101
  user_event = gr.Textbox(
102
- label="Drop something into the wood",
103
  placeholder="Example: A lantern starts whispering recipes.",
104
  lines=2,
 
 
105
  )
106
- inject_button = gr.Button("Inject and advance")
107
 
108
  with gr.Row():
109
- events = gr.Textbox(label="Ledger", lines=18, elem_id="events")
110
- stats = gr.Textbox(label="Run stats", lines=18, elem_id="stats")
 
 
 
 
 
 
 
 
111
 
112
- start_button.click(start, inputs=[seed], outputs=[stage, events, stats])
113
- step_button.click(step, outputs=[stage, events, stats])
114
- inject_button.click(inject, inputs=[user_event], outputs=[stage, events, stats])
 
 
 
 
 
 
 
 
 
115
 
116
 
117
  if __name__ == "__main__":
 
6
  import gradio as gr
7
 
8
  from src.core.conductor import Conductor
9
+ from src.scenarios import mystery_roots, thousand_token_wood
10
  from src.ui.render import render_event_log, render_stage, render_stats
11
 
12
+ # ── scenario registry ─────────────────────────────────────────────────────────
13
 
14
+ SCENARIOS = {
15
+ "🍄 Thousand Token Wood": thousand_token_wood.build_scenario(),
16
+ "🔍 Mystery Roots": mystery_roots.build_scenario(),
17
+ }
18
+
19
+ _conductors: dict[str, Conductor] = {name: Conductor(s) for name, s in SCENARIOS.items()}
20
+
21
+ # ── CSS ───────────────────────────────────────────────────────────────────────
22
 
23
  APP_CSS = """
24
+ :root {
25
+ --bg: #0e1209;
26
+ --surface: #141a0f;
27
+ --border: #2e3d25;
28
+ --text: #e8e2cc;
29
+ --muted: #8a9c7a;
30
+ --accent: #6db56d;
31
+ --accent2: #c9a84c;
32
+ --danger: #c96b6b;
33
+ }
34
+ body { background: var(--bg); color: var(--text); }
35
+ .gradio-container { max-width: 1200px !important; font-family: 'Georgia', serif; }
36
+ footer { display: none !important; }
37
+
38
+ /* Header */
39
+ .wood-header h1 {
40
+ font-size: 2rem;
41
+ color: var(--accent);
42
+ letter-spacing: .04em;
43
+ margin-bottom: 2px;
44
+ }
45
+ .wood-header p { color: var(--muted); margin-top: 0; font-style: italic; }
46
+
47
+ /* Stage */
48
  #stage {
49
+ border: 1px solid var(--border);
50
+ background: linear-gradient(180deg, #172017 0%, var(--surface) 100%);
51
+ color: var(--text);
52
+ padding: 20px 24px;
53
+ border-radius: 10px;
54
+ min-height: 380px;
55
+ font-size: 0.97rem;
56
+ line-height: 1.7;
57
  }
58
+ #stage h2 { color: var(--accent); font-size: 1.1rem; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
59
+ #stage h3 { color: var(--accent2); font-size: 0.95rem; margin-top: 16px; margin-bottom: 6px; }
60
+
61
+ /* Ledger + stats */
62
  #events textarea, #stats textarea {
63
+ font-family: ui-monospace, 'Cascadia Code', 'Fira Mono', monospace;
64
+ font-size: 11px;
65
+ background: var(--surface);
66
+ color: var(--muted);
67
+ border-color: var(--border);
68
+ }
69
+
70
+ /* Buttons */
71
+ button.primary { background: var(--accent) !important; color: var(--bg) !important; font-weight: 700; }
72
+ button.secondary { border-color: var(--border) !important; color: var(--muted) !important; }
73
+
74
+ /* Scenario selector */
75
+ .scenario-selector label { color: var(--accent2) !important; }
76
+
77
+ /* Seed input */
78
+ #seed-box textarea { font-style: italic; }
79
+
80
+ /* Inject row */
81
+ #inject-box textarea { border-color: var(--accent2) !important; }
82
+
83
+ /* Status pill */
84
+ .status-pill {
85
+ display: inline-block;
86
+ padding: 2px 10px;
87
+ border-radius: 999px;
88
+ font-size: 0.75rem;
89
+ font-family: monospace;
90
+ background: var(--surface);
91
+ border: 1px solid var(--border);
92
+ color: var(--muted);
93
  }
 
 
94
  """
95
 
96
+ # ── helpers ───────────────────────────────────────────────────────────────────
97
 
98
+
99
+ def _conductor(scenario_name: str) -> Conductor:
100
+ return _conductors[scenario_name]
 
 
 
 
 
 
 
 
 
101
 
102
 
103
+ def _outputs(c: Conductor):
 
104
  return (
105
+ render_stage(c.projection),
106
+ render_event_log(c.ledger.events),
107
+ render_stats(c.ledger.events, c.governor),
108
  )
109
 
110
 
111
+ def start(scenario_name: str, seed: str):
112
+ c = _conductor(scenario_name)
113
+ c.reset(seed.strip() or c.scenario.default_seed)
114
+ return _outputs(c)
115
+
 
 
116
 
117
+ def step(scenario_name: str):
118
+ c = _conductor(scenario_name)
119
+ c.step()
120
+ return _outputs(c)
121
 
122
+
123
+ def inject(scenario_name: str, user_event: str):
124
+ c = _conductor(scenario_name)
125
  if user_event.strip():
126
+ c.inject_user_event(user_event.strip())
127
+ c.step()
128
+ return _outputs(c)
129
+
 
 
 
130
 
131
+ def change_scenario(scenario_name: str):
132
+ c = _conductor(scenario_name)
133
+ seeds = c.scenario.example_seeds
134
+ choices = [(s, s) for s in seeds]
135
+ default = seeds[0] if seeds else c.scenario.default_seed
136
+ return gr.update(choices=choices, value=default)
137
 
138
+
139
+ # ── layout ────────────────────────────────────────────────────────────────────
140
+
141
+ with gr.Blocks(title="Multi-Agent Land · Thousand Token Wood", css=APP_CSS) as demo:
142
  gr.Markdown(
143
  """
144
  # Multi-Agent Land
145
+ *Tiny specialist agents share a ledger and build a living world turn by turn.*
146
  """,
147
+ elem_classes=["wood-header"],
148
  )
149
 
150
  with gr.Row():
151
+ scenario_select = gr.Dropdown(
152
+ choices=list(SCENARIOS.keys()),
153
+ value=list(SCENARIOS.keys())[0],
154
+ label="Scenario",
155
+ elem_classes=["scenario-selector"],
156
+ scale=1,
157
+ )
158
+
159
+ with gr.Row():
160
+ seed_examples = SCENARIOS[list(SCENARIOS.keys())[0]].example_seeds
161
+ seed = gr.Dropdown(
162
+ choices=[(s, s) for s in seed_examples],
163
+ value=seed_examples[0],
164
  label="World seed",
165
+ allow_custom_value=True,
166
+ elem_id="seed-box",
167
+ scale=4,
168
  )
169
+ with gr.Column(scale=1, min_width=160):
170
+ start_btn = gr.Button(" Start", variant="primary")
171
+ step_btn = gr.Button("Advance one turn", variant="secondary")
172
 
173
+ stage = gr.Markdown(
174
+ value="> Select a scenario and press **Start** to raise the curtain.",
175
+ elem_id="stage",
176
+ )
177
 
178
  with gr.Row():
179
  user_event = gr.Textbox(
180
+ label="Drop something into the world",
181
  placeholder="Example: A lantern starts whispering recipes.",
182
  lines=2,
183
+ elem_id="inject-box",
184
+ scale=4,
185
  )
186
+ inject_btn = gr.Button("💬 Inject & advance", scale=1, min_width=160)
187
 
188
  with gr.Row():
189
+ events_box = gr.Textbox(label="Event ledger (append-only)", lines=18, elem_id="events")
190
+ stats_box = gr.Textbox(label="Run stats", lines=18, elem_id="stats")
191
+
192
+ # ── wiring ────────────────────────────────────────────────────────────────
193
+ scenario_select.change(change_scenario, inputs=[scenario_select], outputs=[seed])
194
+
195
+ start_btn.click(start, inputs=[scenario_select, seed], outputs=[stage, events_box, stats_box])
196
+ step_btn.click(step, inputs=[scenario_select], outputs=[stage, events_box, stats_box])
197
+ inject_btn.click(inject, inputs=[scenario_select, user_event], outputs=[stage, events_box, stats_box])
198
+
199
 
200
+ def dev_server_port() -> int:
201
+ configured = os.getenv("GRADIO_SERVER_PORT")
202
+ if configured:
203
+ return int(configured)
204
+ for port in range(7960, 8060):
205
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
206
+ try:
207
+ sock.bind(("127.0.0.1", port))
208
+ return port
209
+ except OSError:
210
+ continue
211
+ raise RuntimeError("No free development port found in range 7960-8059.")
212
 
213
 
214
  if __name__ == "__main__":
docs/adr/0005-episodic-memory-as-ledger-view.md ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ADR-0005: Episodic Memory as a Ledger View
2
+
3
+ ## Status
4
+
5
+ Accepted
6
+
7
+ ## Context
8
+
9
+ Small models have small context windows and drift out of character quickly.
10
+ Each agent needs "memory" but storing separate per-agent state creates two
11
+ sources of truth and makes crash recovery harder.
12
+
13
+ ## Decision
14
+
15
+ Agent memory is not a separate store. It is a **filtered view over the shared
16
+ ledger**, computed fresh each turn by `EpisodicMemory`. Each agent sees only
17
+ events it emitted itself plus globally-visible event kinds (`world.observed`,
18
+ `judge.verdict`, `user.injected`, `run.started`). The window is capped (default
19
+ 8 events) to stay within small-model context budgets.
20
+
21
+ ## Consequences
22
+
23
+ - Memory is always consistent with the ledger — no sync bugs possible.
24
+ - Crash recovery is free: reload the ledger, rebuild the view.
25
+ - Memory "recall" is a pure function of events (trivial to test, deterministic).
26
+ - Agents cannot see each other's private thoughts, enforcing cognitive privacy.
27
+ - Richer retrieval (semantic search, salience scoring) can be added later as an
28
+ upgraded `EpisodicMemory` implementation without changing the agent protocol.
docs/adr/0006-context-builder-separates-prompt-assembly.md ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ADR-0006: ContextBuilder Separates Prompt Assembly from Agent Logic
2
+
3
+ ## Status
4
+
5
+ Accepted
6
+
7
+ ## Context
8
+
9
+ Without a dedicated prompt-assembly layer, each agent hard-codes how it blends
10
+ persona, world state, and memory into a prompt string. That makes it impossible
11
+ to iterate on the prompt strategy without touching every agent, and impossible
12
+ to enforce a consistent structure across scenarios.
13
+
14
+ ## Decision
15
+
16
+ Introduce `ContextBuilder` as a standalone collaborator. Agents call
17
+ `ctx.build(agent_name=..., persona=..., projection=..., all_events=...)` and
18
+ receive a fully-formatted string. The builder owns the layering order:
19
+ persona → current scene → episodic memory → visitor disturbances.
20
+
21
+ Agents are responsible only for the **persona string** and the **action they
22
+ emit**. The builder is responsible for everything in between.
23
+
24
+ ## Consequences
25
+
26
+ - Prompt structure is a single point of change.
27
+ - Adding a new memory layer (e.g. reflection summaries) touches only
28
+ `ContextBuilder`, not every agent.
29
+ - Agents remain testable with a stub builder.
30
+ - The layering order is a documented, reviewable decision rather than implicit
31
+ per-agent convention.
docs/adr/0007-governor-as-runtime-safety-valve.md ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ADR-0007: Governor as Runtime Safety Valve
2
+
3
+ ## Status
4
+
5
+ Accepted
6
+
7
+ ## Context
8
+
9
+ A multi-agent scenario where many small models can post events indefinitely
10
+ is exactly the topology that produces runaway inference cascades and surprise
11
+ bills. The conductor needs a mechanism to enforce budgets without those limits
12
+ being hard-coded inside scenario or agent code.
13
+
14
+ ## Decision
15
+
16
+ `Governor` is a stateful collaborator injected into the conductor. It tracks
17
+ calls-per-turn, total calls, and turn count, and raises `BudgetExceeded` if any
18
+ cap is exceeded. The conductor calls `governor.begin_turn()` + `governor.check()`
19
+ before each scheduled agent and `governor.record_call()` after.
20
+
21
+ Caps are configuration: `Governor(max_turns=100, max_calls_per_turn=8,
22
+ max_total_calls=500)`. The defaults are generous for interactive demo use and
23
+ can be tightened for cost-controlled production runs.
24
+
25
+ ## Consequences
26
+
27
+ - Runaway scenarios cannot accidentally exhaust an API quota.
28
+ - Budget enforcement is decoupled from scenario logic.
29
+ - The governor is injectable and testable in isolation.
30
+ - `BudgetExceeded` is a named exception the UI can catch and surface gracefully.
docs/adr/0008-second-scenario-proves-modularity.md ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ADR-0008: Second Scenario Proves Modularity
2
+
3
+ ## Status
4
+
5
+ Accepted
6
+
7
+ ## Context
8
+
9
+ A "modular engine" claim is only proven when it is reused. Adding Mystery
10
+ Roots is not just a feature — it is the test that confirms the engine/scenario
11
+ boundary is correct.
12
+
13
+ ## Decision
14
+
15
+ Add `src/scenarios/mystery_roots.py` as a structurally distinct second scenario:
16
+ - Different cognitive task (convergent mystery-solving vs. divergent world-growth)
17
+ - Different agent cast (ClueGatherer, HypothesisFormer, DevilsAdvocate, MysteryJudge)
18
+ - Different scheduling policy (4-phase cycle vs. even/odd/triple turns)
19
+ - Same engine: Conductor, Ledger, Governor, ContextBuilder, EpisodicMemory
20
+
21
+ The rule: **zero engine edits to add the scenario**. Only new files and a
22
+ two-line addition to `scenarios/__init__.py`.
23
+
24
+ ## Consequences
25
+
26
+ - The engine/scenario boundary is verified, not assumed.
27
+ - Adding a third scenario (illustrated serial, blackboard swarm, etc.) has a
28
+ demonstrated path: one new file, one registry line.
29
+ - The test `test_mystery_roots.py` acts as a regression guard for the contract.
30
+
31
+ ## Result
32
+
33
+ Mystery Roots shipped with **zero engine edits**. The modularity claim holds.
docs/blog/building-in-public.md CHANGED
@@ -4,9 +4,19 @@ This living technical blog is generated from `docs/journal/`.
4
 
5
  ## Throughline
6
 
7
- We are building a tiny multi-agent theater for the Thousand Token Wood hackathon: event-sourced, small-model friendly, Gradio-first, and intentionally whimsical.
 
 
 
8
 
9
  ## Entries
10
 
 
 
 
11
  - [Foundation Scaffold](../journal/2026-06-07-foundation-scaffold.md)
12
  Source: `docs/journal/2026-06-07-foundation-scaffold.md`
 
 
 
 
 
4
 
5
  ## Throughline
6
 
7
+ We are building a tiny multi-agent theater for the Thousand Token Wood hackathon:
8
+ event-sourced, small-model friendly, Gradio-first, and intentionally whimsical.
9
+
10
+ One engine, many worlds. The engine is plumbing. The worlds are config.
11
 
12
  ## Entries
13
 
14
+ - [Phase 1: Memory, Governor, Second Scenario](../journal/2026-06-07-phase-1-memory-second-scenario.md)
15
+ Source: `docs/journal/2026-06-07-phase-1-memory-second-scenario.md`
16
+
17
  - [Foundation Scaffold](../journal/2026-06-07-foundation-scaffold.md)
18
  Source: `docs/journal/2026-06-07-foundation-scaffold.md`
19
+
20
+ ## Deep Dives
21
+
22
+ - [How We Built One Engine and Let It Wear Three Costumes](engine-architecture.md)
docs/blog/engine-architecture.md ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # How We Built One Engine and Let It Wear Three Costumes
2
+
3
+ *Technical blog — Multi-Agent Land, Thousand Token Wood hackathon*
4
+
5
+ ---
6
+
7
+ ## The Thesis
8
+
9
+ When you look at a village of quirky AI characters, a murder-mystery swarm, and a
10
+ collaborative illustrated serial side-by-side, you might think you're looking at three
11
+ different systems. You're not. They are **the same four abstractions** wearing different
12
+ configurations:
13
+
14
+ 1. An **append-only event ledger** — the one source of truth
15
+ 2. A **conductor** — schedules who acts, enforces budgets, drives the loop
16
+ 3. **Agents** — stateless functions that read context and emit a single typed event
17
+ 4. **Projections** — pure functions that fold the event stream into any view you need
18
+
19
+ Everything else is configuration.
20
+
21
+ ---
22
+
23
+ ## The Event Ledger: Why Append-Only?
24
+
25
+ The ledger is the spine. Agents don't call each other — they append events
26
+ (`world.observed`, `agent.spoke`, `judge.verdict`) and subscribe to the event types they
27
+ care about. No direct coupling. No shared mutable state. No race conditions.
28
+
29
+ ```
30
+ [run.started ] conductor {"seed": "A village of stage props..."}
31
+ [world.observed] seedkeeper {"text": "A mossy ticket booth opens in a tree root."}
32
+ [judge.verdict ] critic {"text": "Keep it — specific and playable."}
33
+ [agent.spoke ] pocket-actor {"text": "I am collecting echoes to knit a ladder to the moon."}
34
+ [user.injected ] visitor {"text": "A lantern starts whispering recipes."}
35
+ ```
36
+
37
+ Every row is immutable. The UI, the stats panel, the agent memory, and the judge's
38
+ analysis are all **projections derived from this log**. That means:
39
+
40
+ - **Crash recovery is free**: reload the ledger, rebuild every projection from scratch.
41
+ - **Testing is trivial**: projections are pure functions. Give them a list of events,
42
+ assert the output. No mocks, no shared state.
43
+ - **The system is observable by default**: the ledger *is* the audit trail.
44
+
45
+ ---
46
+
47
+ ## Memory Without a Memory Store
48
+
49
+ The most common question we get: where does each agent store its memory?
50
+
51
+ Nowhere new. Agent memory is a **filtered view over the shared ledger**, recomputed
52
+ each turn by `EpisodicMemory`:
53
+
54
+ ```python
55
+ class EpisodicMemory:
56
+ def visible(self, events: tuple[Event, ...]) -> list[Event]:
57
+ result = []
58
+ for e in events:
59
+ if e.actor == self.agent_name or e.kind in self._visible_kinds:
60
+ result.append(e)
61
+ return result[-self.max_recent:]
62
+ ```
63
+
64
+ The Seedkeeper sees its own actions plus world events. The Pocket Actor sees world
65
+ events plus visitor injections. Neither can read the other's private thoughts.
66
+ The window is capped at 8 events to stay within small-model context budgets.
67
+
68
+ This is event sourcing plus CQRS in its simplest form: one write side (the ledger),
69
+ many read sides (each agent's memory projection, the UI's stage view, the stats panel).
70
+
71
+ ---
72
+
73
+ ## The Context Builder: Prompt Assembly as a Separate Concern
74
+
75
+ Before this pattern, each agent owned its own prompt string. After ten agents, the
76
+ variation was unmanageable and inconsistent. Now there is one place:
77
+
78
+ ```python
79
+ class ContextBuilder:
80
+ def build(self, *, agent_name, persona, projection, all_events) -> str:
81
+ memory = EpisodicMemory(agent_name).format_for_prompt(all_events)
82
+ return (
83
+ f"IDENTITY\n{persona}\n\n"
84
+ f"CURRENT SCENE\n{projection.current_scene}\n\n"
85
+ f"YOUR MEMORY (recent events you witnessed)\n{memory}\n\n"
86
+ f"VISITOR DISTURBANCES\n{visitor_lines}"
87
+ )
88
+ ```
89
+
90
+ Agents are responsible for the **persona string** and the **event they emit**.
91
+ The builder owns the layering order. Adding a new memory layer (reflection summaries,
92
+ salience scoring) touches one file, not every agent.
93
+
94
+ ---
95
+
96
+ ## The Governor: Budget Before It Bites You
97
+
98
+ Small models are cheap per call. Many agents calling many times for many hours is not.
99
+ The `Governor` enforces three caps:
100
+
101
+ - `max_turns` — the conductor raises the curtain at most this many times
102
+ - `max_calls_per_turn` — no single turn can trigger more than N model calls
103
+ - `max_total_calls` — the whole run cannot exceed M calls
104
+
105
+ The conductor checks the governor before every scheduled agent. `BudgetExceeded` is a
106
+ named exception the UI surfaces gracefully rather than burning quota silently.
107
+
108
+ ---
109
+
110
+ ## Two Scenarios, Zero Engine Edits
111
+
112
+ The proof that the abstraction works is the second scenario.
113
+
114
+ **Thousand Token Wood** is world-growth: the scene gets stranger turn by turn, a
115
+ judge critiques it, a character speaks their impossible want, an echo transforms
116
+ visitor injections. The scheduling is round-robin with variation.
117
+
118
+ **Mystery Roots** is convergence: a mystery is stated, a clue-gatherer extracts
119
+ evidence, a hypothesis-former proposes an explanation, a devil's advocate challenges
120
+ it, and a judge declares the verdict. The scheduling is a 4-phase cycle.
121
+
122
+ Same conductor. Same ledger. Same governor. Same context builder. Same memory.
123
+ **Different cast, different schedule, different cognitive shape.**
124
+
125
+ The engine is just plumbing. The scenario is pure config.
126
+
127
+ ---
128
+
129
+ ## What's Next
130
+
131
+ - **Reflection events**: periodic `agent.reflected` events that compact episodic
132
+ memories into high-level beliefs, shrinking the context window cost over long runs.
133
+ - **Illustrated serial**: a third scenario that introduces an image-generation tool
134
+ via MCP and a wall-clock cadence (one episode per hour).
135
+ - **Persistent ledger**: swap the in-memory `Ledger` for a SQLite backend without
136
+ changing a single scenario or agent.
137
+ - **Salience-scored retrieval**: replace the recency window with importance × recency
138
+ scoring so agents surface the most meaningful memories, not just the most recent.
139
+
140
+ ---
141
+
142
+ ## The Stack
143
+
144
+ | Layer | Choice | Why |
145
+ |---|---|---|
146
+ | UI | Gradio | Required by hackathon; good enough for a toy |
147
+ | Event schema | Pydantic v2 | Strict validation, zero extra fields |
148
+ | Model | Any OpenAI-compatible API | `OPENAI_BASE_URL` lets you point at Ollama, Together, Groq, NVIDIA NIM |
149
+ | Memory | Ledger view (no separate store) | Consistency, simplicity, crash recovery |
150
+ | Orchestration | In-process, synchronous | Right size for a demo; async and durable execution available when needed |
151
+
152
+ ---
153
+
154
+ *This blog is generated from `docs/journal/` as the build progresses.*
docs/journal/2026-06-07-phase-1-memory-second-scenario.md ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 2026-06-07 — Phase 1: Memory, Governor, Second Scenario
2
+
3
+ ## What changed
4
+
5
+ Phase 0 had a working skeleton: ledger, conductor, three deterministic stub agents, and
6
+ a Gradio UI. Phase 1 adds the cognitive infrastructure that makes agents feel like
7
+ inhabitants rather than random sentence generators.
8
+
9
+ ### New engine modules
10
+
11
+ - **`src/core/memory.py`** — `EpisodicMemory`: per-agent filtered view over the ledger.
12
+ Agents see their own actions plus globally-visible events. Capped at 8 events to
13
+ fit small-model windows.
14
+
15
+ - **`src/core/context.py`** — `ContextBuilder`: one place where persona, world state,
16
+ memory, and visitor disturbances are assembled into a prompt string. Changing prompt
17
+ structure is now a one-file edit.
18
+
19
+ - **`src/core/governor.py`** — `Governor`: enforces turn, per-turn call, and total call
20
+ budgets. `BudgetExceeded` is a named exception. Injected into the conductor.
21
+
22
+ ### New model provider
23
+
24
+ - **`src/models/openai_compat.py`** — `OpenAICompatProvider`: works with any
25
+ OpenAI-compatible API. Config via `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `MODEL_NAME`.
26
+ Falls back to `DeterministicTinyModel` when no key is present.
27
+
28
+ ### Enhanced agents
29
+
30
+ All four Tiny Wood agents now use `ContextBuilder` with rich personas and the full
31
+ episodic memory window. The `EchoAgent` is new — it transforms visitor injections
32
+ through the wood's logic.
33
+
34
+ ### Second scenario: Mystery Roots
35
+
36
+ `src/scenarios/mystery_roots.py` demonstrates the modularity claim: same engine,
37
+ zero engine edits, different cognitive task. Four agents (ClueGatherer,
38
+ HypothesisFormer, DevilsAdvocate, MysteryJudge) work a 4-phase convergence cycle.
39
+
40
+ ### Tests
41
+
42
+ Grew from 14 to 70 passing tests. New suites: `test_memory`, `test_governor`,
43
+ `test_mystery_roots`, `test_events`, `test_projections`, `test_scenario`.
44
+
45
+ ### UI
46
+
47
+ - Two-scenario dropdown with seed gallery per scenario
48
+ - Governor stats in the run-stats panel
49
+ - Richer custom CSS with CSS variables, stage gradient, monospace ledger
50
+
51
+ ## Key decisions
52
+
53
+ - Memory is a ledger view, not a separate store (ADR-0005)
54
+ - Prompt assembly lives in `ContextBuilder`, not agent code (ADR-0006)
55
+ - Governor is injected into conductor, not embedded in agents (ADR-0007)
56
+ - Zero engine edits for second scenario — modularity claim verified (ADR-0008)
57
+
58
+ ## Next
59
+
60
+ - Reflection events (compact old memories into beliefs)
61
+ - Persistent SQLite ledger backend
62
+ - Illustrated serial scenario (image-gen via MCP)
63
+ - Demo-mode auto-run with gallery of frozen interesting seeds
pyproject.toml CHANGED
@@ -7,6 +7,7 @@ dependencies = [
7
  "gradio>=4.44.0",
8
  "pydantic>=2.8.0",
9
  "python-dotenv>=1.0.1",
 
10
  ]
11
 
12
  [project.optional-dependencies]
 
7
  "gradio>=4.44.0",
8
  "pydantic>=2.8.0",
9
  "python-dotenv>=1.0.1",
10
+ "openai>=1.40.0",
11
  ]
12
 
13
  [project.optional-dependencies]
requirements.txt CHANGED
@@ -1,4 +1,4 @@
1
  gradio>=4.44.0
2
  pydantic>=2.8.0
3
  python-dotenv>=1.0.1
4
-
 
1
  gradio>=4.44.0
2
  pydantic>=2.8.0
3
  python-dotenv>=1.0.1
4
+ openai>=1.40.0
src/agents/tiny_wood.py CHANGED
@@ -1,49 +1,110 @@
1
  from __future__ import annotations
2
 
3
  from src.agents.base import Agent
 
4
  from src.core.events import Event
5
  from src.core.projections import StageProjection
6
  from src.models.provider import ModelProvider
7
 
 
 
8
 
9
  class SceneWhisperer(Agent):
 
 
10
  name = "scene-whisperer"
 
 
 
 
 
 
11
 
12
  def __init__(self, model: ModelProvider) -> None:
13
  self.model = model
14
 
15
  def act(self, run_id: str, turn: int, projection: StageProjection, recent_events: tuple[Event, ...]) -> Event:
16
- text = self.model.complete(
17
- role="scene-whisperer",
18
- prompt=f"Seed: {projection.seed}\nCurrent scene: {projection.current_scene}\nMake the wood stranger in one vivid sentence.",
 
 
19
  )
 
20
  return Event(run_id=run_id, turn=turn, kind="world.observed", actor=self.name, payload={"text": text})
21
 
22
 
23
  class MischiefCritic(Agent):
 
 
24
  name = "mischief-critic"
 
 
 
 
 
 
25
 
26
  def __init__(self, model: ModelProvider) -> None:
27
  self.model = model
28
 
29
  def act(self, run_id: str, turn: int, projection: StageProjection, recent_events: tuple[Event, ...]) -> Event:
30
- text = self.model.complete(
31
- role="mischief-critic",
32
- prompt=f"Current scene: {projection.current_scene}\nJudge whether it is delightful, specific, and AI-load-bearing.",
 
 
33
  )
 
34
  return Event(run_id=run_id, turn=turn, kind="judge.verdict", actor=self.name, payload={"text": text})
35
 
36
 
37
  class PocketActor(Agent):
 
 
38
  name = "pocket-actor"
 
 
 
 
 
39
 
40
  def __init__(self, model: ModelProvider) -> None:
41
  self.model = model
42
 
43
  def act(self, run_id: str, turn: int, projection: StageProjection, recent_events: tuple[Event, ...]) -> Event:
44
- text = self.model.complete(
45
- role="pocket-actor",
46
- prompt=f"Scene: {projection.current_scene}\nSpeak as a tiny character who wants something impossible.",
 
 
47
  )
 
48
  return Event(run_id=run_id, turn=turn, kind="agent.spoke", actor=self.name, payload={"text": text})
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
  from src.agents.base import Agent
4
+ from src.core.context import ContextBuilder
5
  from src.core.events import Event
6
  from src.core.projections import StageProjection
7
  from src.models.provider import ModelProvider
8
 
9
+ _ctx = ContextBuilder()
10
+
11
 
12
  class SceneWhisperer(Agent):
13
+ """Grows the world: one vivid observation per turn."""
14
+
15
  name = "scene-whisperer"
16
+ _persona = (
17
+ "You are the Seedkeeper of Thousand Token Wood — ancient, patient, and "
18
+ "delighted by small impossible things. You notice what no one else does. "
19
+ "Your job this turn: describe how the wood has changed in one specific sentence. "
20
+ "Do not repeat the current scene verbatim. Make it stranger or more alive."
21
+ )
22
 
23
  def __init__(self, model: ModelProvider) -> None:
24
  self.model = model
25
 
26
  def act(self, run_id: str, turn: int, projection: StageProjection, recent_events: tuple[Event, ...]) -> Event:
27
+ prompt = _ctx.build(
28
+ agent_name=self.name,
29
+ persona=self._persona,
30
+ projection=projection,
31
+ all_events=recent_events,
32
  )
33
+ text = self.model.complete("scene-whisperer", prompt)
34
  return Event(run_id=run_id, turn=turn, kind="world.observed", actor=self.name, payload={"text": text})
35
 
36
 
37
  class MischiefCritic(Agent):
38
+ """Judge: one verdict on whether the scene is genuinely strange."""
39
+
40
  name = "mischief-critic"
41
+ _persona = (
42
+ "You are the Mischief Critic — a tiny, sharp-eyed judge who decides if the wood "
43
+ "is being weird enough. You love specificity, playability, and AI-native strangeness. "
44
+ "Your job: give a one-sentence verdict. Name one thing that works and one thing that "
45
+ "would make it stranger. Be concise. Be demanding."
46
+ )
47
 
48
  def __init__(self, model: ModelProvider) -> None:
49
  self.model = model
50
 
51
  def act(self, run_id: str, turn: int, projection: StageProjection, recent_events: tuple[Event, ...]) -> Event:
52
+ prompt = _ctx.build(
53
+ agent_name=self.name,
54
+ persona=self._persona,
55
+ projection=projection,
56
+ all_events=recent_events,
57
  )
58
+ text = self.model.complete("mischief-critic", prompt)
59
  return Event(run_id=run_id, turn=turn, kind="judge.verdict", actor=self.name, payload={"text": text})
60
 
61
 
62
  class PocketActor(Agent):
63
+ """A tiny character living in the scene who wants something impossible."""
64
+
65
  name = "pocket-actor"
66
+ _persona = (
67
+ "You are a Pocket Actor — a tiny, specific being who lives inside this exact scene "
68
+ "and wants something that cannot exist. Speak in first person. One or two sentences. "
69
+ "Name what you want and why it's urgent. Be absurd but sincere."
70
+ )
71
 
72
  def __init__(self, model: ModelProvider) -> None:
73
  self.model = model
74
 
75
  def act(self, run_id: str, turn: int, projection: StageProjection, recent_events: tuple[Event, ...]) -> Event:
76
+ prompt = _ctx.build(
77
+ agent_name=self.name,
78
+ persona=self._persona,
79
+ projection=projection,
80
+ all_events=recent_events,
81
  )
82
+ text = self.model.complete("pocket-actor", prompt)
83
  return Event(run_id=run_id, turn=turn, kind="agent.spoke", actor=self.name, payload={"text": text})
84
 
85
+
86
+ class EchoAgent(Agent):
87
+ """Transforms visitor injections through the wood's logic."""
88
+
89
+ name = "echo"
90
+ _persona = (
91
+ "You are the Echo of Thousand Token Wood. When visitors drop something into the forest, "
92
+ "you return it changed — not opposite, but transformed by the wood's rules. "
93
+ "One sentence. Take the most recent visitor disturbance and make it stranger and more alive."
94
+ )
95
+
96
+ def __init__(self, model: ModelProvider) -> None:
97
+ self.model = model
98
+
99
+ def act(self, run_id: str, turn: int, projection: StageProjection, recent_events: tuple[Event, ...]) -> Event:
100
+ if not projection.user_artifacts:
101
+ text = "The wood holds its breath, waiting for a disturbance."
102
+ else:
103
+ prompt = _ctx.build(
104
+ agent_name=self.name,
105
+ persona=self._persona,
106
+ projection=projection,
107
+ all_events=recent_events,
108
+ )
109
+ text = self.model.complete("echo", prompt)
110
+ return Event(run_id=run_id, turn=turn, kind="agent.thought", actor=self.name, payload={"text": text})
src/core/conductor.py CHANGED
@@ -3,15 +3,17 @@ from __future__ import annotations
3
  from uuid import uuid4
4
 
5
  from src.core.events import Event
 
6
  from src.core.ledger import Ledger
7
  from src.core.projections import StageProjection, rebuild_stage
8
  from src.scenarios.base import Scenario
9
 
10
 
11
  class Conductor:
12
- def __init__(self, scenario: Scenario) -> None:
13
  self.scenario = scenario
14
  self.ledger = Ledger()
 
15
  self.run_id = str(uuid4())
16
  self.turn = 0
17
 
@@ -23,6 +25,11 @@ class Conductor:
23
  self.ledger.reset()
24
  self.run_id = str(uuid4())
25
  self.turn = 0
 
 
 
 
 
26
  self.ledger.append(
27
  Event(
28
  run_id=self.run_id,
@@ -40,14 +47,18 @@ class Conductor:
40
  self.reset(self.scenario.default_seed)
41
  return
42
  self.turn += 1
 
 
43
  projection = self.projection
44
  for agent in self.scenario.schedule(self.turn):
 
45
  event = agent.act(
46
  run_id=self.run_id,
47
  turn=self.turn,
48
  projection=projection,
49
- recent_events=self.ledger.events[-10:],
50
  )
 
51
  self.ledger.append(event)
52
  projection.apply(event)
53
 
@@ -62,4 +73,3 @@ class Conductor:
62
  payload={"text": text},
63
  )
64
  )
65
-
 
3
  from uuid import uuid4
4
 
5
  from src.core.events import Event
6
+ from src.core.governor import Governor
7
  from src.core.ledger import Ledger
8
  from src.core.projections import StageProjection, rebuild_stage
9
  from src.scenarios.base import Scenario
10
 
11
 
12
  class Conductor:
13
+ def __init__(self, scenario: Scenario, governor: Governor | None = None) -> None:
14
  self.scenario = scenario
15
  self.ledger = Ledger()
16
+ self.governor = governor or Governor()
17
  self.run_id = str(uuid4())
18
  self.turn = 0
19
 
 
25
  self.ledger.reset()
26
  self.run_id = str(uuid4())
27
  self.turn = 0
28
+ self.governor.__init__( # type: ignore[misc]
29
+ max_turns=self.governor.max_turns,
30
+ max_calls_per_turn=self.governor.max_calls_per_turn,
31
+ max_total_calls=self.governor.max_total_calls,
32
+ )
33
  self.ledger.append(
34
  Event(
35
  run_id=self.run_id,
 
47
  self.reset(self.scenario.default_seed)
48
  return
49
  self.turn += 1
50
+ self.governor.begin_turn(self.turn)
51
+ self.governor.check(self.turn)
52
  projection = self.projection
53
  for agent in self.scenario.schedule(self.turn):
54
+ self.governor.check(self.turn)
55
  event = agent.act(
56
  run_id=self.run_id,
57
  turn=self.turn,
58
  projection=projection,
59
+ recent_events=self.ledger.events,
60
  )
61
+ self.governor.record_call()
62
  self.ledger.append(event)
63
  projection.apply(event)
64
 
 
73
  payload={"text": text},
74
  )
75
  )
 
src/core/context.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from src.core.events import Event
4
+ from src.core.memory import EpisodicMemory
5
+ from src.core.projections import StageProjection
6
+
7
+
8
+ class ContextBuilder:
9
+ """Assembles a compact, role-scoped prompt for a single agent turn.
10
+
11
+ Layers, innermost first:
12
+ 1. Pinned persona — fixed identity and constraints
13
+ 2. Current scene — world state from the projection
14
+ 3. Memory — episodic recall from the ledger
15
+ 4. Visitor noise — recent user injections
16
+ """
17
+
18
+ def build(
19
+ self,
20
+ *,
21
+ agent_name: str,
22
+ persona: str,
23
+ projection: StageProjection,
24
+ all_events: tuple[Event, ...],
25
+ memory_window: int = 8,
26
+ ) -> str:
27
+ memory = EpisodicMemory(agent_name, max_recent=memory_window)
28
+ recall = memory.format_for_prompt(all_events)
29
+
30
+ visitor_lines = "\n".join(f"- {a}" for a in projection.user_artifacts[-3:]) or "(quiet)"
31
+
32
+ return (
33
+ f"IDENTITY\n{persona}\n\n"
34
+ f"CURRENT SCENE\n{projection.current_scene}\n\n"
35
+ f"YOUR MEMORY (recent events you witnessed)\n{recall}\n\n"
36
+ f"VISITOR DISTURBANCES\n{visitor_lines}"
37
+ )
src/core/governor.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+
6
+ @dataclass
7
+ class Governor:
8
+ """Rate and budget guard for the conductor loop.
9
+
10
+ Prevents runaway inference cascades by tracking calls and tokens
11
+ per run and per turn. Small models are cheap but many-agent
12
+ scenarios can still burn budget surprisingly fast.
13
+ """
14
+
15
+ max_turns: int = 100
16
+ max_calls_per_turn: int = 8
17
+ max_total_calls: int = 500
18
+
19
+ _total_calls: int = field(default=0, init=False, repr=False)
20
+ _calls_this_turn: int = field(default=0, init=False, repr=False)
21
+ _current_turn: int = field(default=-1, init=False, repr=False)
22
+
23
+ def begin_turn(self, turn: int) -> None:
24
+ if turn != self._current_turn:
25
+ self._calls_this_turn = 0
26
+ self._current_turn = turn
27
+
28
+ def check(self, turn: int) -> None:
29
+ if turn > self.max_turns:
30
+ raise BudgetExceeded(f"Turn cap {self.max_turns} reached")
31
+ if self._total_calls >= self.max_total_calls:
32
+ raise BudgetExceeded(f"Total call cap {self.max_total_calls} reached")
33
+ if self._calls_this_turn >= self.max_calls_per_turn:
34
+ raise BudgetExceeded(f"Per-turn call cap {self.max_calls_per_turn} reached on turn {turn}")
35
+
36
+ def record_call(self) -> None:
37
+ self._calls_this_turn += 1
38
+ self._total_calls += 1
39
+
40
+ @property
41
+ def stats(self) -> dict[str, int]:
42
+ return {
43
+ "total_calls": self._total_calls,
44
+ "calls_this_turn": self._calls_this_turn,
45
+ "current_turn": self._current_turn,
46
+ }
47
+
48
+
49
+ class BudgetExceeded(RuntimeError):
50
+ pass
src/core/memory.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ from src.core.events import Event
6
+
7
+
8
+ @dataclass
9
+ class EpisodicMemory:
10
+ """Per-agent view over the shared ledger.
11
+
12
+ Keeps the N most recent events that are visible to this agent:
13
+ its own actions, world observations, judge verdicts, and anything
14
+ a visitor injected. Everything else is noise for the prompt.
15
+ """
16
+
17
+ agent_name: str
18
+ max_recent: int = 8
19
+ _visible_kinds: frozenset[str] = field(
20
+ default_factory=lambda: frozenset(
21
+ {"world.observed", "judge.verdict", "user.injected", "run.started"}
22
+ ),
23
+ repr=False,
24
+ )
25
+
26
+ def visible(self, events: tuple[Event, ...]) -> list[Event]:
27
+ result = []
28
+ for e in events:
29
+ if e.actor == self.agent_name or e.kind in self._visible_kinds:
30
+ result.append(e)
31
+ return result[-self.max_recent :]
32
+
33
+ def format_for_prompt(self, events: tuple[Event, ...]) -> str:
34
+ recalled = self.visible(events)
35
+ if not recalled:
36
+ return "(no prior memory)"
37
+ lines = []
38
+ for e in recalled:
39
+ text = e.payload.get("text") or e.payload.get("summary") or str(e.payload)
40
+ lines.append(f"[turn {e.turn:03d}][{e.kind}] {text}")
41
+ return "\n".join(lines)
src/models/openai_compat.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import dataclass, field
6
+
7
+ from src.models.provider import ModelProvider
8
+
9
+
10
+ @dataclass
11
+ class OpenAICompatProvider(ModelProvider):
12
+ """Provider for any OpenAI-compatible chat completion API.
13
+
14
+ Works with: OpenAI, Together AI, Groq, Ollama (v0.1.14+), HuggingFace TGI,
15
+ NVIDIA NIM, and any endpoint that speaks the /v1/chat/completions protocol.
16
+
17
+ Model selection and endpoint are driven by env vars so the scenario config
18
+ never hard-codes a provider:
19
+ OPENAI_API_KEY — required for real calls
20
+ OPENAI_BASE_URL — optional, defaults to api.openai.com
21
+ MODEL_NAME — optional, defaults to gpt-4o-mini
22
+ TINY_TITAN_MODE — set to "1" to use a <=4B model profile
23
+ """
24
+
25
+ model: str = field(default_factory=lambda: os.getenv("MODEL_NAME", "gpt-4o-mini"))
26
+ base_url: str | None = field(default_factory=lambda: os.getenv("OPENAI_BASE_URL"))
27
+ max_tokens: int = 256
28
+ temperature: float = 0.9
29
+ _client: object = field(default=None, init=False, repr=False)
30
+
31
+ def _get_client(self):
32
+ if self._client is None:
33
+ try:
34
+ import openai
35
+ except ImportError as exc:
36
+ raise ImportError(
37
+ "openai package is required for OpenAICompatProvider. "
38
+ "Run: pip install openai"
39
+ ) from exc
40
+ kwargs: dict = {}
41
+ if self.base_url:
42
+ kwargs["base_url"] = self.base_url
43
+ self._client = openai.OpenAI(**kwargs)
44
+ return self._client
45
+
46
+ def complete(self, role: str, prompt: str) -> str:
47
+ client = self._get_client()
48
+ system = self._system_for_role(role)
49
+ try:
50
+ resp = client.chat.completions.create(
51
+ model=self.model,
52
+ messages=[
53
+ {"role": "system", "content": system},
54
+ {"role": "user", "content": prompt},
55
+ ],
56
+ max_tokens=self.max_tokens,
57
+ temperature=self.temperature,
58
+ )
59
+ return resp.choices[0].message.content.strip()
60
+ except Exception as exc:
61
+ return f"[model error: {exc}]"
62
+
63
+ @staticmethod
64
+ def _system_for_role(role: str) -> str:
65
+ personas = {
66
+ "seedkeeper": (
67
+ "You are the Seedkeeper of Thousand Token Wood — an ancient, gentle observer "
68
+ "who notices what grows, what fades, and what strange new thing just sprouted. "
69
+ "Describe the world in one vivid, specific sentence. Be concrete and surprising. "
70
+ "Do not explain. Do not moralize. Just observe."
71
+ ),
72
+ "mischief-critic": (
73
+ "You are the Mischief Critic — a sharp-eyed judge who decides whether a scene "
74
+ "is genuinely strange and playable or merely odd. "
75
+ "Give a one-sentence verdict that names what works and what would make it stranger. "
76
+ "Be encouraging but exacting."
77
+ ),
78
+ "pocket-actor": (
79
+ "You are a Pocket Actor — a tiny character living inside the scene who wants "
80
+ "something impossible and speaks with great urgency about it. "
81
+ "Speak in first person. One or two sentences. Be specific and a little absurd."
82
+ ),
83
+ "echo": (
84
+ "You are the Echo — you take whatever a visitor drops into the wood and return "
85
+ "it transformed by the forest's logic. One sentence. Make it weirder and more alive."
86
+ ),
87
+ "clue-gatherer": (
88
+ "You are a Clue Gatherer in a mystery scenario. "
89
+ "Extract one specific, concrete clue from the current scene. "
90
+ "State it plainly. Do not speculate."
91
+ ),
92
+ "hypothesis-former": (
93
+ "You are a Hypothesis Former. Based on the clues so far, propose one testable "
94
+ "explanation in a single sentence. Be specific. Start with 'Hypothesis:'."
95
+ ),
96
+ "devils-advocate": (
97
+ "You are the Devil's Advocate. Challenge the current hypothesis with one "
98
+ "specific counter-argument or overlooked fact. Be brief and sharp."
99
+ ),
100
+ "scene-whisperer": (
101
+ "You are a scene whisperer for a magical forest world. "
102
+ "Describe a new atmospheric detail in one vivid sentence. Be evocative."
103
+ ),
104
+ }
105
+ return personas.get(role, f"You are a {role}. Respond in one or two sentences.")
106
+
107
+
108
+ def build_from_env() -> ModelProvider:
109
+ """Return the best available provider based on environment configuration."""
110
+ from src.models.provider import DeterministicTinyModel
111
+
112
+ api_key = os.getenv("OPENAI_API_KEY", "")
113
+ if api_key and api_key not in ("", "sk-stub", "your-key-here"):
114
+ return OpenAICompatProvider()
115
+ return DeterministicTinyModel()
src/scenarios/__init__.py CHANGED
@@ -1,2 +1,5 @@
1
- """Scenario plugins."""
2
 
 
 
 
 
1
+ """Scenario plugins — each module exposes build_scenario() -> Scenario."""
2
 
3
+ from src.scenarios import mystery_roots, thousand_token_wood
4
+
5
+ __all__ = ["thousand_token_wood", "mystery_roots"]
src/scenarios/base.py CHANGED
@@ -1,7 +1,7 @@
1
  from __future__ import annotations
2
 
3
  from collections.abc import Iterable
4
- from dataclasses import dataclass
5
 
6
  from src.agents.base import Agent
7
  from src.core.events import Event
@@ -12,6 +12,7 @@ class Scenario:
12
  name: str
13
  default_seed: str
14
  agents: tuple[Agent, ...]
 
15
 
16
  def genesis(self, run_id: str, turn: int, seed: str) -> Iterable[Event]:
17
  yield Event(
@@ -23,9 +24,11 @@ class Scenario:
23
  )
24
 
25
  def schedule(self, turn: int) -> tuple[Agent, ...]:
 
 
 
26
  if turn % 3 == 0:
27
  return self.agents
28
  if turn % 2 == 0:
29
  return self.agents[:2]
30
- return self.agents[:1] + self.agents[2:]
31
-
 
1
  from __future__ import annotations
2
 
3
  from collections.abc import Iterable
4
+ from dataclasses import dataclass, field
5
 
6
  from src.agents.base import Agent
7
  from src.core.events import Event
 
12
  name: str
13
  default_seed: str
14
  agents: tuple[Agent, ...]
15
+ example_seeds: list[str] = field(default_factory=list)
16
 
17
  def genesis(self, run_id: str, turn: int, seed: str) -> Iterable[Event]:
18
  yield Event(
 
24
  )
25
 
26
  def schedule(self, turn: int) -> tuple[Agent, ...]:
27
+ n = len(self.agents)
28
+ if n == 0:
29
+ return ()
30
  if turn % 3 == 0:
31
  return self.agents
32
  if turn % 2 == 0:
33
  return self.agents[:2]
34
+ return self.agents[:1] + (self.agents[2:3] if n > 2 else ())
 
src/scenarios/mystery_roots.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Mystery Roots — a blackboard swarm scenario.
2
+
3
+ Three specialist agents post to a shared hypothesis board, and a judge
4
+ synthesises the best answer. This demonstrates the same engine running
5
+ a structurally different cognitive task: convergence rather than
6
+ divergent world-growth.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Iterable
12
+
13
+ from src.agents.base import Agent
14
+ from src.core.context import ContextBuilder
15
+ from src.core.events import Event
16
+ from src.core.projections import StageProjection
17
+ from src.models.openai_compat import build_from_env
18
+ from src.models.provider import ModelProvider
19
+ from src.scenarios.base import Scenario
20
+
21
+ _ctx = ContextBuilder()
22
+
23
+ _MYSTERIES = [
24
+ "All the clocks in the wood stopped at 3:07. No one wound them down.",
25
+ "The bridge appeared overnight. It leads somewhere the map insists does not exist.",
26
+ "Every morning the baker finds one extra loaf — baked perfectly but with ingredients she does not own.",
27
+ ]
28
+
29
+
30
+ class ClueGatherer(Agent):
31
+ name = "clue-gatherer"
32
+ _persona = (
33
+ "You are a careful Clue Gatherer. Extract exactly one new, concrete clue from the "
34
+ "current scene that has not yet been named. State it plainly in one sentence. "
35
+ "Start with 'Clue:'. Do not speculate."
36
+ )
37
+
38
+ def __init__(self, model: ModelProvider) -> None:
39
+ self.model = model
40
+
41
+ def act(self, run_id: str, turn: int, projection: StageProjection, recent_events: tuple[Event, ...]) -> Event:
42
+ prompt = _ctx.build(
43
+ agent_name=self.name,
44
+ persona=self._persona,
45
+ projection=projection,
46
+ all_events=recent_events,
47
+ )
48
+ text = self.model.complete("clue-gatherer", prompt)
49
+ return Event(run_id=run_id, turn=turn, kind="agent.thought", actor=self.name, payload={"text": text})
50
+
51
+
52
+ class HypothesisFormer(Agent):
53
+ name = "hypothesis-former"
54
+ _persona = (
55
+ "You are a Hypothesis Former. Based on the clues gathered so far, propose one "
56
+ "testable explanation in a single sentence. Start with 'Hypothesis:'. "
57
+ "Be specific. Name a cause, not just an effect."
58
+ )
59
+
60
+ def __init__(self, model: ModelProvider) -> None:
61
+ self.model = model
62
+
63
+ def act(self, run_id: str, turn: int, projection: StageProjection, recent_events: tuple[Event, ...]) -> Event:
64
+ prompt = _ctx.build(
65
+ agent_name=self.name,
66
+ persona=self._persona,
67
+ projection=projection,
68
+ all_events=recent_events,
69
+ )
70
+ text = self.model.complete("hypothesis-former", prompt)
71
+ return Event(run_id=run_id, turn=turn, kind="agent.spoke", actor=self.name, payload={"text": text})
72
+
73
+
74
+ class DevilsAdvocate(Agent):
75
+ name = "devils-advocate"
76
+ _persona = (
77
+ "You are the Devil's Advocate. Challenge the most recent hypothesis with one "
78
+ "sharp counter-argument or overlooked fact. Start with 'But:'. Be brief and specific."
79
+ )
80
+
81
+ def __init__(self, model: ModelProvider) -> None:
82
+ self.model = model
83
+
84
+ def act(self, run_id: str, turn: int, projection: StageProjection, recent_events: tuple[Event, ...]) -> Event:
85
+ prompt = _ctx.build(
86
+ agent_name=self.name,
87
+ persona=self._persona,
88
+ projection=projection,
89
+ all_events=recent_events,
90
+ )
91
+ text = self.model.complete("devils-advocate", prompt)
92
+ return Event(run_id=run_id, turn=turn, kind="agent.thought", actor=self.name, payload={"text": text})
93
+
94
+
95
+ class MysteryJudge(Agent):
96
+ name = "mystery-judge"
97
+ _persona = (
98
+ "You are the Mystery Judge. After reviewing the clues and debate, declare the "
99
+ "most likely explanation in one confident sentence. Start with 'Verdict:'. "
100
+ "Choose the most interesting, specific answer the evidence supports."
101
+ )
102
+
103
+ def __init__(self, model: ModelProvider) -> None:
104
+ self.model = model
105
+
106
+ def act(self, run_id: str, turn: int, projection: StageProjection, recent_events: tuple[Event, ...]) -> Event:
107
+ prompt = _ctx.build(
108
+ agent_name=self.name,
109
+ persona=self._persona,
110
+ projection=projection,
111
+ all_events=recent_events,
112
+ )
113
+ text = self.model.complete("mystery-judge", prompt)
114
+ return Event(run_id=run_id, turn=turn, kind="judge.verdict", actor=self.name, payload={"text": text})
115
+
116
+
117
+ class _MysteryScenario(Scenario):
118
+ def schedule(self, turn: int) -> tuple[Agent, ...]:
119
+ n = len(self.agents)
120
+ if turn % 4 == 0:
121
+ return self.agents # full sweep including judge
122
+ if turn % 4 == 1:
123
+ return (self.agents[0],) # gather clue
124
+ if turn % 4 == 2:
125
+ return (self.agents[1],) # form hypothesis
126
+ return (self.agents[2],) # challenge it
127
+
128
+
129
+ def build_scenario() -> Scenario:
130
+ model = build_from_env()
131
+ return _MysteryScenario(
132
+ name="mystery-roots",
133
+ default_seed=_MYSTERIES[0],
134
+ agents=(
135
+ ClueGatherer(model),
136
+ HypothesisFormer(model),
137
+ DevilsAdvocate(model),
138
+ MysteryJudge(model),
139
+ ),
140
+ example_seeds=_MYSTERIES,
141
+ )
src/scenarios/thousand_token_wood.py CHANGED
@@ -1,19 +1,28 @@
1
  from __future__ import annotations
2
 
3
- from src.agents.tiny_wood import MischiefCritic, PocketActor, SceneWhisperer
4
- from src.models.provider import DeterministicTinyModel
5
  from src.scenarios.base import Scenario
6
 
 
 
 
 
 
 
 
 
7
 
8
  def build_scenario() -> Scenario:
9
- model = DeterministicTinyModel()
10
  return Scenario(
11
  name="thousand-token-wood",
12
- default_seed="A village of stage props wakes up and argues about which fairy tale they belong to.",
13
  agents=(
14
  SceneWhisperer(model),
15
  MischiefCritic(model),
16
  PocketActor(model),
 
17
  ),
 
18
  )
19
-
 
1
  from __future__ import annotations
2
 
3
+ from src.agents.tiny_wood import EchoAgent, MischiefCritic, PocketActor, SceneWhisperer
4
+ from src.models.openai_compat import build_from_env
5
  from src.scenarios.base import Scenario
6
 
7
+ _SEEDS = [
8
+ "A village of stage props wakes up and argues about which fairy tale they belong to.",
9
+ "The last remaining compass has decided to point at feelings instead of north.",
10
+ "A library where every book is the memoir of a different moon.",
11
+ "The mushrooms have started charging admission to their bioluminescent shows.",
12
+ "Time in this clearing runs clockwise for small things and counterclockwise for large ones.",
13
+ ]
14
+
15
 
16
  def build_scenario() -> Scenario:
17
+ model = build_from_env()
18
  return Scenario(
19
  name="thousand-token-wood",
20
+ default_seed=_SEEDS[0],
21
  agents=(
22
  SceneWhisperer(model),
23
  MischiefCritic(model),
24
  PocketActor(model),
25
+ EchoAgent(model),
26
  ),
27
+ example_seeds=_SEEDS,
28
  )
 
src/ui/render.py CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations
3
  from collections import Counter
4
 
5
  from src.core.events import Event, event_summary
 
6
  from src.core.projections import StageProjection
7
 
8
 
@@ -10,8 +11,7 @@ def render_stage(projection: StageProjection) -> str:
10
  artifacts = "\n".join(f"- {item}" for item in projection.user_artifacts) or "- No visitor artifacts yet."
11
  notes = "\n".join(f"- {item}" for item in projection.agent_notes) or "- Agents are waiting."
12
  verdicts = "\n".join(f"- {item}" for item in projection.judge_notes) or "- No verdict yet."
13
- return f"""
14
- ## Current Clearing
15
 
16
  {projection.current_scene}
17
 
@@ -27,10 +27,12 @@ def render_stage(projection: StageProjection) -> str:
27
 
28
 
29
  def render_event_log(events: tuple[Event, ...]) -> str:
 
 
30
  return "\n".join(event_summary(event) for event in events)
31
 
32
 
33
- def render_stats(events: tuple[Event, ...]) -> str:
34
  by_kind = Counter(event.kind for event in events)
35
  by_actor = Counter(event.actor for event in events)
36
  lines = ["Events by kind:"]
@@ -43,5 +45,9 @@ def render_stats(events: tuple[Event, ...]) -> str:
43
  lines.append(" runtime model cap: <=32B")
44
  lines.append(" tiny mode target: <=4B")
45
  lines.append(" UI target: custom Gradio")
 
 
 
 
 
46
  return "\n".join(lines)
47
-
 
3
  from collections import Counter
4
 
5
  from src.core.events import Event, event_summary
6
+ from src.core.governor import Governor
7
  from src.core.projections import StageProjection
8
 
9
 
 
11
  artifacts = "\n".join(f"- {item}" for item in projection.user_artifacts) or "- No visitor artifacts yet."
12
  notes = "\n".join(f"- {item}" for item in projection.agent_notes) or "- Agents are waiting."
13
  verdicts = "\n".join(f"- {item}" for item in projection.judge_notes) or "- No verdict yet."
14
+ return f"""## Current Clearing
 
15
 
16
  {projection.current_scene}
17
 
 
27
 
28
 
29
  def render_event_log(events: tuple[Event, ...]) -> str:
30
+ if not events:
31
+ return "(ledger is empty)"
32
  return "\n".join(event_summary(event) for event in events)
33
 
34
 
35
+ def render_stats(events: tuple[Event, ...], governor: Governor | None = None) -> str:
36
  by_kind = Counter(event.kind for event in events)
37
  by_actor = Counter(event.actor for event in events)
38
  lines = ["Events by kind:"]
 
45
  lines.append(" runtime model cap: <=32B")
46
  lines.append(" tiny mode target: <=4B")
47
  lines.append(" UI target: custom Gradio")
48
+ if governor is not None:
49
+ lines.append("")
50
+ lines.append("Governor:")
51
+ for k, v in governor.stats.items():
52
+ lines.append(f" {k}: {v}")
53
  return "\n".join(lines)
 
tests/__init__.py ADDED
File without changes
tests/test_conductor.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ from src.core.conductor import Conductor
6
+ from src.core.events import Event
7
+ from src.models.provider import DeterministicTinyModel
8
+ from src.scenarios.thousand_token_wood import build_scenario
9
+
10
+
11
+ def _conductor() -> Conductor:
12
+ return Conductor(scenario=build_scenario())
13
+
14
+
15
+ class TestConductorReset:
16
+ def test_reset_clears_ledger(self):
17
+ c = _conductor()
18
+ c.reset("seed-a")
19
+ c.reset("seed-b")
20
+ kinds = {e.kind for e in c.ledger.events}
21
+ assert "run.started" in kinds
22
+ assert len(c.ledger.events) < 10 # not accumulating across resets
23
+
24
+ def test_reset_writes_genesis_events(self):
25
+ c = _conductor()
26
+ c.reset("forest awakens")
27
+ kinds = [e.kind for e in c.ledger.events]
28
+ assert "run.started" in kinds
29
+ assert "world.observed" in kinds
30
+
31
+ def test_reset_sets_turn_to_zero(self):
32
+ c = _conductor()
33
+ c.step()
34
+ c.step()
35
+ c.reset("fresh start")
36
+ assert c.turn == 0
37
+
38
+ def test_reset_uses_seed_in_event(self):
39
+ c = _conductor()
40
+ c.reset("unique-seed-xyz")
41
+ seed_events = [e for e in c.ledger.events if e.kind == "run.started"]
42
+ assert seed_events[0].payload["seed"] == "unique-seed-xyz"
43
+
44
+
45
+ class TestConductorStep:
46
+ def test_step_increments_turn(self):
47
+ c = _conductor()
48
+ c.reset("seed")
49
+ initial = c.turn
50
+ c.step()
51
+ assert c.turn == initial + 1
52
+
53
+ def test_step_appends_events(self):
54
+ c = _conductor()
55
+ c.reset("seed")
56
+ before = len(c.ledger.events)
57
+ c.step()
58
+ after = len(c.ledger.events)
59
+ assert after > before
60
+
61
+ def test_multiple_steps_accumulate(self):
62
+ c = _conductor()
63
+ c.reset("seed")
64
+ for _ in range(4):
65
+ c.step()
66
+ assert len(c.ledger.events) >= 5 # genesis + at least one per step
67
+
68
+ def test_step_without_reset_auto_resets(self):
69
+ c = _conductor()
70
+ c.step() # should not raise
71
+ assert len(c.ledger.events) > 0
72
+
73
+
74
+ class TestConductorInject:
75
+ def test_inject_appends_user_event(self):
76
+ c = _conductor()
77
+ c.reset("seed")
78
+ c.inject_user_event("a silver fish falls upward")
79
+ kinds = [e.kind for e in c.ledger.events]
80
+ assert "user.injected" in kinds
81
+
82
+ def test_inject_text_preserved(self):
83
+ c = _conductor()
84
+ c.reset("seed")
85
+ c.inject_user_event("strange message here")
86
+ injected = [e for e in c.ledger.events if e.kind == "user.injected"]
87
+ assert injected[-1].payload["text"] == "strange message here"
88
+
89
+
90
+ class TestConductorProjection:
91
+ def test_projection_reflects_latest_events(self):
92
+ c = _conductor()
93
+ c.reset("the wood wakes")
94
+ proj = c.projection
95
+ assert proj.seed == "the wood wakes" or "the wood wakes" in proj.current_scene
tests/test_events.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ from src.core.events import Event, event_summary
6
+
7
+
8
+ def _event(**kwargs) -> Event:
9
+ defaults = dict(run_id="r1", turn=1, kind="agent.spoke", actor="teller", payload={"text": "a line"})
10
+ defaults.update(kwargs)
11
+ return Event(**defaults) # type: ignore[arg-type]
12
+
13
+
14
+ class TestEventSchema:
15
+ def test_auto_id(self):
16
+ e1 = _event()
17
+ e2 = _event()
18
+ assert e1.id != e2.id
19
+
20
+ def test_explicit_id_preserved(self):
21
+ e = _event(id="fixed-id")
22
+ assert e.id == "fixed-id"
23
+
24
+ def test_schema_version_default(self):
25
+ assert _event().schema_version == 1
26
+
27
+ def test_extra_fields_rejected(self):
28
+ with pytest.raises(Exception):
29
+ Event(run_id="r", turn=0, kind="agent.spoke", actor="x", payload={}, unknown="bad") # type: ignore[call-arg]
30
+
31
+ def test_invalid_kind_rejected(self):
32
+ with pytest.raises(Exception):
33
+ _event(kind="not.a.real.kind")
34
+
35
+
36
+ class TestEventSummary:
37
+ def test_summary_includes_text(self):
38
+ e = _event(payload={"text": "the moss glows"})
39
+ summary = event_summary(e)
40
+ assert "the moss glows" in summary
41
+
42
+ def test_summary_includes_actor(self):
43
+ e = _event(actor="seedkeeper")
44
+ summary = event_summary(e)
45
+ assert "seedkeeper" in summary
46
+
47
+ def test_summary_falls_back_to_payload_when_no_text(self):
48
+ e = _event(payload={"summary": "a brief"})
49
+ summary = event_summary(e)
50
+ assert "a brief" in summary
tests/test_governor.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ from src.core.governor import BudgetExceeded, Governor
6
+
7
+
8
+ class TestGovernor:
9
+ def test_allows_within_budget(self):
10
+ g = Governor(max_turns=10, max_calls_per_turn=5, max_total_calls=100)
11
+ g.begin_turn(1)
12
+ g.check(1) # should not raise
13
+
14
+ def test_raises_on_turn_exceeded(self):
15
+ g = Governor(max_turns=5)
16
+ with pytest.raises(BudgetExceeded):
17
+ g.check(6)
18
+
19
+ def test_raises_on_total_calls_exceeded(self):
20
+ g = Governor(max_total_calls=3)
21
+ g.begin_turn(1)
22
+ g.record_call()
23
+ g.record_call()
24
+ g.record_call()
25
+ with pytest.raises(BudgetExceeded):
26
+ g.check(1)
27
+
28
+ def test_raises_on_per_turn_calls_exceeded(self):
29
+ g = Governor(max_calls_per_turn=2)
30
+ g.begin_turn(1)
31
+ g.record_call()
32
+ g.record_call()
33
+ with pytest.raises(BudgetExceeded):
34
+ g.check(1)
35
+
36
+ def test_per_turn_resets_on_new_turn(self):
37
+ g = Governor(max_calls_per_turn=2)
38
+ g.begin_turn(1)
39
+ g.record_call()
40
+ g.record_call()
41
+ g.begin_turn(2) # resets per-turn count
42
+ g.check(2) # should not raise
43
+
44
+ def test_stats_reflect_calls(self):
45
+ g = Governor()
46
+ g.begin_turn(3)
47
+ g.record_call()
48
+ g.record_call()
49
+ assert g.stats["total_calls"] == 2
50
+ assert g.stats["calls_this_turn"] == 2
51
+ assert g.stats["current_turn"] == 3
tests/test_ledger.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ from src.core.events import Event
6
+ from src.core.ledger import Ledger
7
+
8
+
9
+ def _make_event(turn: int = 0, kind: str = "agent.spoke", actor: str = "x") -> Event:
10
+ return Event(run_id="run-test", turn=turn, kind=kind, actor=actor, payload={"text": "hello"}) # type: ignore[arg-type]
11
+
12
+
13
+ class TestLedgerAppend:
14
+ def test_append_single(self):
15
+ ledger = Ledger()
16
+ e = _make_event()
17
+ ledger.append(e)
18
+ assert len(ledger.events) == 1
19
+
20
+ def test_append_returns_event(self):
21
+ ledger = Ledger()
22
+ e = _make_event()
23
+ returned = ledger.append(e)
24
+ assert returned is e
25
+
26
+ def test_idempotent_on_same_id(self):
27
+ ledger = Ledger()
28
+ e = _make_event()
29
+ ledger.append(e)
30
+ ledger.append(e)
31
+ assert len(ledger.events) == 1
32
+
33
+ def test_events_are_ordered(self):
34
+ ledger = Ledger()
35
+ e1 = _make_event(turn=1)
36
+ e2 = _make_event(turn=2)
37
+ ledger.append(e1)
38
+ ledger.append(e2)
39
+ assert ledger.events[0].turn == 1
40
+ assert ledger.events[1].turn == 2
41
+
42
+ def test_events_returns_immutable_tuple(self):
43
+ ledger = Ledger()
44
+ ledger.append(_make_event())
45
+ result = ledger.events
46
+ assert isinstance(result, tuple)
47
+
48
+ def test_extend(self):
49
+ ledger = Ledger()
50
+ events = [_make_event(turn=i) for i in range(3)]
51
+ ledger.extend(events)
52
+ assert len(ledger.events) == 3
53
+
54
+ def test_extend_deduplicates(self):
55
+ ledger = Ledger()
56
+ e = _make_event()
57
+ ledger.extend([e, e])
58
+ assert len(ledger.events) == 1
59
+
60
+ def test_reset_clears(self):
61
+ ledger = Ledger()
62
+ ledger.append(_make_event())
63
+ ledger.reset()
64
+ assert len(ledger.events) == 0
65
+
66
+ def test_reset_allows_same_id_again(self):
67
+ ledger = Ledger()
68
+ e = _make_event()
69
+ ledger.append(e)
70
+ ledger.reset()
71
+ ledger.append(e)
72
+ assert len(ledger.events) == 1
tests/test_memory.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from src.core.events import Event
4
+ from src.core.memory import EpisodicMemory
5
+
6
+
7
+ def _event(kind: str, actor: str = "x", turn: int = 1) -> Event:
8
+ return Event(run_id="r", turn=turn, kind=kind, actor=actor, payload={"text": f"{actor}:{kind}"}) # type: ignore[arg-type]
9
+
10
+
11
+ class TestEpisodicMemory:
12
+ def test_own_events_visible(self):
13
+ mem = EpisodicMemory("seedkeeper")
14
+ events = (_event("agent.spoke", actor="seedkeeper"),)
15
+ visible = mem.visible(events)
16
+ assert len(visible) == 1
17
+
18
+ def test_world_observed_visible_to_all(self):
19
+ mem = EpisodicMemory("pocket-actor")
20
+ events = (_event("world.observed", actor="scene-whisperer"),)
21
+ visible = mem.visible(events)
22
+ assert len(visible) == 1
23
+
24
+ def test_other_agent_spoke_not_visible(self):
25
+ mem = EpisodicMemory("pocket-actor")
26
+ events = (_event("agent.spoke", actor="scene-whisperer"),)
27
+ visible = mem.visible(events)
28
+ assert len(visible) == 0
29
+
30
+ def test_user_injected_visible_to_all(self):
31
+ mem = EpisodicMemory("echo")
32
+ events = (_event("user.injected", actor="visitor"),)
33
+ visible = mem.visible(events)
34
+ assert len(visible) == 1
35
+
36
+ def test_capped_at_max_recent(self):
37
+ mem = EpisodicMemory("x", max_recent=3)
38
+ events = tuple(_event("world.observed", turn=i) for i in range(10))
39
+ visible = mem.visible(events)
40
+ assert len(visible) == 3
41
+
42
+ def test_returns_most_recent(self):
43
+ mem = EpisodicMemory("x", max_recent=2)
44
+ events = tuple(_event("world.observed", turn=i) for i in range(5))
45
+ visible = mem.visible(events)
46
+ assert visible[0].turn == 3
47
+ assert visible[1].turn == 4
48
+
49
+ def test_format_for_prompt_returns_string(self):
50
+ mem = EpisodicMemory("x")
51
+ events = (_event("world.observed", actor="narrator"),)
52
+ result = mem.format_for_prompt(events)
53
+ assert isinstance(result, str)
54
+
55
+ def test_format_empty_returns_placeholder(self):
56
+ mem = EpisodicMemory("x")
57
+ result = mem.format_for_prompt(())
58
+ assert "no prior" in result.lower() or result
tests/test_mystery_roots.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from src.core.conductor import Conductor
4
+ from src.scenarios.mystery_roots import build_scenario
5
+
6
+
7
+ class TestMysteryRootsScenario:
8
+ def test_build_returns_scenario(self):
9
+ s = build_scenario()
10
+ assert s.name == "mystery-roots"
11
+
12
+ def test_has_four_agents(self):
13
+ s = build_scenario()
14
+ assert len(s.agents) == 4
15
+
16
+ def test_example_seeds_non_empty(self):
17
+ s = build_scenario()
18
+ assert len(s.example_seeds) > 0
19
+
20
+ def test_conductor_can_run_five_turns(self):
21
+ c = Conductor(scenario=build_scenario())
22
+ c.reset("All the clocks stopped.")
23
+ for _ in range(5):
24
+ c.step()
25
+ assert c.turn == 5
26
+ assert len(c.ledger.events) >= 6 # genesis + steps
27
+
28
+ def test_judge_verdict_appears(self):
29
+ c = Conductor(scenario=build_scenario())
30
+ c.reset("The bridge appeared overnight.")
31
+ for _ in range(8):
32
+ c.step()
33
+ kinds = {e.kind for e in c.ledger.events}
34
+ assert "judge.verdict" in kinds
35
+
36
+ def test_schedule_cycles_through_all_agents(self):
37
+ s = build_scenario()
38
+ seen = set()
39
+ for turn in range(1, 20):
40
+ for agent in s.schedule(turn):
41
+ seen.add(agent.name)
42
+ all_names = {a.name for a in s.agents}
43
+ assert seen == all_names
tests/test_projections.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from src.core.events import Event
4
+ from src.core.projections import StageProjection, rebuild_stage
5
+
6
+
7
+ def _event(kind: str, actor: str = "x", payload: dict | None = None) -> Event:
8
+ return Event(run_id="r", turn=1, kind=kind, actor=actor, payload=payload or {"text": "hi"}) # type: ignore[arg-type]
9
+
10
+
11
+ class TestStageProjectionApply:
12
+ def test_run_started_sets_seed(self):
13
+ proj = StageProjection()
14
+ proj.apply(_event("run.started", payload={"seed": "test-seed"}))
15
+ assert proj.seed == "test-seed"
16
+
17
+ def test_run_started_updates_scene(self):
18
+ proj = StageProjection()
19
+ proj.apply(_event("run.started", payload={"seed": "mossy"}))
20
+ assert "mossy" in proj.current_scene
21
+
22
+ def test_world_observed_updates_scene(self):
23
+ proj = StageProjection()
24
+ proj.apply(_event("world.observed", payload={"text": "the sky hums"}))
25
+ assert proj.current_scene == "the sky hums"
26
+
27
+ def test_agent_spoke_appends_note(self):
28
+ proj = StageProjection()
29
+ proj.apply(_event("agent.spoke", actor="teller", payload={"text": "I want the moon"}))
30
+ assert any("teller" in note for note in proj.agent_notes)
31
+
32
+ def test_agent_notes_capped_at_eight(self):
33
+ proj = StageProjection()
34
+ for i in range(12):
35
+ proj.apply(_event("agent.spoke", payload={"text": f"line {i}"}))
36
+ assert len(proj.agent_notes) <= 8
37
+
38
+ def test_judge_verdict_appends(self):
39
+ proj = StageProjection()
40
+ proj.apply(_event("judge.verdict", payload={"text": "keep it"}))
41
+ assert len(proj.judge_notes) == 1
42
+
43
+ def test_user_injected_appends(self):
44
+ proj = StageProjection()
45
+ proj.apply(_event("user.injected", payload={"text": "a lantern whispers"}))
46
+ assert "a lantern whispers" in proj.user_artifacts
47
+
48
+ def test_user_artifacts_capped_at_five(self):
49
+ proj = StageProjection()
50
+ for i in range(8):
51
+ proj.apply(_event("user.injected", payload={"text": f"artifact {i}"}))
52
+ assert len(proj.user_artifacts) <= 5
53
+
54
+
55
+ class TestRebuildStage:
56
+ def test_empty_events_returns_default(self):
57
+ proj = rebuild_stage(())
58
+ assert "curtain" in proj.current_scene.lower() or proj.current_scene
59
+
60
+ def test_rebuild_is_deterministic(self):
61
+ events = (
62
+ _event("run.started", payload={"seed": "repeat"}),
63
+ _event("world.observed", payload={"text": "stable scene"}),
64
+ )
65
+ p1 = rebuild_stage(events)
66
+ p2 = rebuild_stage(events)
67
+ assert p1.current_scene == p2.current_scene
68
+
69
+ def test_projection_is_pure_function_of_events(self):
70
+ events = (_event("world.observed", payload={"text": "golden spore drift"}),)
71
+ proj = rebuild_stage(events)
72
+ assert proj.current_scene == "golden spore drift"
tests/test_scenario.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from src.core.events import Event
4
+ from src.scenarios.base import Scenario
5
+ from src.scenarios.thousand_token_wood import build_scenario
6
+ from src.models.provider import DeterministicTinyModel
7
+
8
+
9
+ def _bare_scenario() -> Scenario:
10
+ from src.agents.tiny_wood import SceneWhisperer
11
+ return Scenario(
12
+ name="test-scenario",
13
+ default_seed="bare seed",
14
+ agents=(SceneWhisperer(DeterministicTinyModel()),),
15
+ )
16
+
17
+
18
+ class TestScenarioGenesis:
19
+ def test_genesis_yields_events(self):
20
+ s = build_scenario()
21
+ events = list(s.genesis("run-1", 0, "test seed"))
22
+ assert len(events) > 0
23
+
24
+ def test_genesis_events_have_correct_run_id(self):
25
+ s = build_scenario()
26
+ events = list(s.genesis("my-run", 0, "seed"))
27
+ assert all(e.run_id == "my-run" for e in events)
28
+
29
+ def test_genesis_includes_world_observed(self):
30
+ s = build_scenario()
31
+ events = list(s.genesis("r", 0, "mossy path"))
32
+ kinds = [e.kind for e in events]
33
+ assert "world.observed" in kinds
34
+
35
+ def test_genesis_seed_appears_in_payload(self):
36
+ s = build_scenario()
37
+ events = list(s.genesis("r", 0, "unique-seed"))
38
+ all_text = " ".join(str(e.payload) for e in events)
39
+ assert "unique-seed" in all_text
40
+
41
+
42
+ class TestScenarioSchedule:
43
+ def test_schedule_returns_agents(self):
44
+ s = build_scenario()
45
+ agents = s.schedule(1)
46
+ assert len(agents) > 0
47
+
48
+ def test_schedule_varies_by_turn(self):
49
+ s = build_scenario()
50
+ schedules = [tuple(a.name for a in s.schedule(t)) for t in range(1, 10)]
51
+ unique_schedules = set(schedules)
52
+ assert len(unique_schedules) > 1 # not all turns get the same set
53
+
54
+ def test_every_agent_gets_scheduled_eventually(self):
55
+ s = build_scenario()
56
+ seen = set()
57
+ for turn in range(1, 20):
58
+ for agent in s.schedule(turn):
59
+ seen.add(agent.name)
60
+ all_agents = {a.name for a in s.agents}
61
+ assert seen == all_agents