feat: phase 1 — memory layer, governor, second scenario, 70 passing tests
Browse filesEngine 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 +24 -0
- .gitignore +2 -1
- README.md +136 -34
- app.py +161 -64
- docs/adr/0005-episodic-memory-as-ledger-view.md +28 -0
- docs/adr/0006-context-builder-separates-prompt-assembly.md +31 -0
- docs/adr/0007-governor-as-runtime-safety-valve.md +30 -0
- docs/adr/0008-second-scenario-proves-modularity.md +33 -0
- docs/blog/building-in-public.md +11 -1
- docs/blog/engine-architecture.md +154 -0
- docs/journal/2026-06-07-phase-1-memory-second-scenario.md +63 -0
- pyproject.toml +1 -0
- requirements.txt +1 -1
- src/agents/tiny_wood.py +70 -9
- src/core/conductor.py +13 -3
- src/core/context.py +37 -0
- src/core/governor.py +50 -0
- src/core/memory.py +41 -0
- src/models/openai_compat.py +115 -0
- src/scenarios/__init__.py +4 -1
- src/scenarios/base.py +6 -3
- src/scenarios/mystery_roots.py +141 -0
- src/scenarios/thousand_token_wood.py +14 -5
- src/ui/render.py +10 -4
- tests/__init__.py +0 -0
- tests/test_conductor.py +95 -0
- tests/test_events.py +50 -0
- tests/test_governor.py +51 -0
- tests/test_ledger.py +72 -0
- tests/test_memory.py +58 -0
- tests/test_mystery_roots.py +43 -0
- tests/test_projections.py +72 -0
- tests/test_scenario.py +61 -0
|
@@ -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=
|
|
@@ -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
|
|
@@ -1,63 +1,165 @@
|
|
| 1 |
# Multi-Agent Land
|
| 2 |
|
| 3 |
-
Hackathon project for the **Thousand Token Wood** trail: a small-model, multi-agent
|
|
|
|
| 4 |
|
| 5 |
-
The
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
-
|
| 8 |
|
| 9 |
-
##
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
-
|
|
|
|
| 29 |
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
app.py Gradio composition root
|
| 32 |
src/
|
| 33 |
-
core/
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
docs/
|
| 39 |
vision.md One-page product and technical vision
|
| 40 |
-
architecture/ System design
|
| 41 |
-
adr/ Append-only Architecture Decision Records
|
| 42 |
schema/ Event and manifest contracts
|
| 43 |
-
runbooks/ Local dev, demo, recovery
|
| 44 |
-
strategy/ Hackathon prize strategy
|
| 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
|
| 50 |
```
|
| 51 |
|
| 52 |
-
|
| 53 |
|
| 54 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
-
|
| 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 |
+
```
|
|
@@ -6,112 +6,209 @@ import socket
|
|
| 6 |
import gradio as gr
|
| 7 |
|
| 8 |
from src.core.conductor import Conductor
|
| 9 |
-
from src.scenarios
|
| 10 |
from src.ui.render import render_event_log, render_stage, render_stats
|
| 11 |
|
|
|
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
APP_CSS = """
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
#stage {
|
| 20 |
-
border: 1px solid
|
| 21 |
-
background: linear-gradient(180deg, #172017 0%,
|
| 22 |
-
color:
|
| 23 |
-
padding:
|
| 24 |
-
border-radius:
|
| 25 |
-
min-height:
|
|
|
|
|
|
|
| 26 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
#events textarea, #stats textarea {
|
| 28 |
-
font-family: ui-monospace,
|
| 29 |
-
font-size:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
}
|
| 31 |
-
.wood-title h1 { margin-bottom: 0; }
|
| 32 |
-
.wood-title p { margin-top: 6px; color: #b9c3a7; }
|
| 33 |
"""
|
| 34 |
|
|
|
|
| 35 |
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 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
|
| 51 |
-
conductor.reset(seed.strip() or scenario.default_seed)
|
| 52 |
return (
|
| 53 |
-
render_stage(
|
| 54 |
-
render_event_log(
|
| 55 |
-
render_stats(
|
| 56 |
)
|
| 57 |
|
| 58 |
|
| 59 |
-
def
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
render_stats(conductor.ledger.events),
|
| 65 |
-
)
|
| 66 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
-
|
|
|
|
|
|
|
| 69 |
if user_event.strip():
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
return (
|
| 73 |
-
|
| 74 |
-
render_event_log(conductor.ledger.events),
|
| 75 |
-
render_stats(conductor.ledger.events),
|
| 76 |
-
)
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
| 80 |
gr.Markdown(
|
| 81 |
"""
|
| 82 |
# Multi-Agent Land
|
| 83 |
-
Tiny agents
|
| 84 |
""",
|
| 85 |
-
elem_classes=["wood-
|
| 86 |
)
|
| 87 |
|
| 88 |
with gr.Row():
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
label="World seed",
|
| 91 |
-
|
| 92 |
-
|
|
|
|
| 93 |
)
|
| 94 |
-
with gr.Column(scale=
|
| 95 |
-
|
| 96 |
-
|
| 97 |
|
| 98 |
-
stage = gr.Markdown(
|
|
|
|
|
|
|
|
|
|
| 99 |
|
| 100 |
with gr.Row():
|
| 101 |
user_event = gr.Textbox(
|
| 102 |
-
label="Drop something into the
|
| 103 |
placeholder="Example: A lantern starts whispering recipes.",
|
| 104 |
lines=2,
|
|
|
|
|
|
|
| 105 |
)
|
| 106 |
-
|
| 107 |
|
| 108 |
with gr.Row():
|
| 109 |
-
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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__":
|
|
@@ -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.
|
|
@@ -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.
|
|
@@ -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.
|
|
@@ -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.
|
|
@@ -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:
|
|
|
|
|
|
|
|
|
|
| 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)
|
|
@@ -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.*
|
|
@@ -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
|
|
@@ -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]
|
|
@@ -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
|
|
@@ -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 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 45 |
-
|
| 46 |
-
|
|
|
|
|
|
|
| 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})
|
|
@@ -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
|
| 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 |
)
|
|
|
|
@@ -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 |
+
)
|
|
@@ -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
|
|
@@ -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)
|
|
@@ -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()
|
|
@@ -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"]
|
|
@@ -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 ())
|
|
|
|
@@ -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 |
+
)
|
|
@@ -1,19 +1,28 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
-
from src.agents.tiny_wood import MischiefCritic, PocketActor, SceneWhisperer
|
| 4 |
-
from src.models.
|
| 5 |
from src.scenarios.base import Scenario
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
def build_scenario() -> Scenario:
|
| 9 |
-
model =
|
| 10 |
return Scenario(
|
| 11 |
name="thousand-token-wood",
|
| 12 |
-
default_seed=
|
| 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 |
)
|
|
|
|
@@ -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)
|
|
|
|
File without changes
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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"
|
|
@@ -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
|