agharsallah
feat: update agent personas and scheduling in YAML configurations; enhance event handling and memory deduplication in core logic
27b304e | # Subscription-Based Event Routing | |
| ## Why subscriptions instead of schedules | |
| The Phase 0/1 conductor scheduled agents by turn parity (`turn % 3`, `turn % 2`). | |
| That's simple and fine for three agents. It breaks when: | |
| - You add more agents and the modulo arithmetic becomes arbitrary | |
| - Some agents need to react *immediately* to specific events, not on their next tick | |
| - You want to hibernated agents (not scheduled) to wake up when something relevant happens | |
| Subscription routing solves all three: agents declare what they care about, and the | |
| conductor routes accordingly. Coupling is to the **event schema**, not to each other. | |
| --- | |
| ## The two-track hybrid schedule | |
| The conductor runs **two tracks per step** in order: | |
| ```mermaid | |
| flowchart TD | |
| S(["step() β _tick(): turn += 1"]) --> Gov["governor.check()"] | |
| Gov --> P1["Track 1 β drain trigger queue FIFO<br/>(agents whose subscribes_to matched)"] | |
| P1 --> P2["Track 2 β run tick agents<br/>(schedule.tick_every fires)"] | |
| P2 --> N["appended events β notify_subscribers<br/>queue matching agents"] | |
| N -.->|feeds next step| P1 | |
| P2 --> Snap["maybe snapshot"] | |
| ``` | |
| ### Track 1: Event-triggered (subscriptions) | |
| When any event is appended to the ledger, the conductor checks which agents | |
| have that event kind in `manifest.subscribes_to`. Those agents are queued in | |
| a FIFO trigger queue and execute **before** the next tick batch. | |
| ```python | |
| def _notify_subscribers(self, event: Event) -> None: | |
| for agent in self.scenario.agents: | |
| if event.kind in agent.manifest.subscribes_to: | |
| self._trigger_queue.append((agent, event)) | |
| ``` | |
| **Important properties**: | |
| - A trigger fires once per triggering event (not per turn) | |
| - The governor caps how many triggers can fire per turn (no cascade explosions) | |
| - Triggers are consumed FIFO β the agent that subscribed first reacts first | |
| **Example**: the Echo agent subscribes to `user.injected`. When a visitor drops | |
| something into the world, Echo is immediately queued and reacts this turn β | |
| before the next scheduled tick. | |
| ### Track 2: Tick-based (manifest.schedule.tick_every) | |
| After the trigger queue is drained, scheduled agents fire: | |
| ```yaml | |
| schedule: | |
| tick_every: 3 # fire every 3rd turn regardless of subscriptions | |
| ``` | |
| `tick_every: None` = event-driven only (the agent never fires on a clock). | |
| `tick_every: 0` = every turn. | |
| Ticks and subscriptions are orthogonal. An agent can have both. | |
| ### Legacy fallback | |
| Agents without a manifest use the scenario's legacy `schedule(turn)` method. | |
| This preserves full backward compatibility β Phase 0/1 agents work unchanged. | |
| --- | |
| ## Preventing cascades | |
| Without limits, subscriptions create runaway chains: | |
| - Agent A emits `X` | |
| - Agent B subscribes to `X`, emits `Y` | |
| - Agent C subscribes to `Y`, emits `Z` | |
| - Agent A subscribes to `Z`... loop | |
| The governor prevents this: | |
| - `max_calls_per_turn`: hard cap on model calls per turn | |
| - `max_consecutive` (manifest): max turns an agent acts in a row | |
| - The trigger queue is drained at most once per step β agents added to the queue | |
| during a step's trigger processing fire on the **next** step, not immediately | |
| --- | |
| ## Subscription patterns by scenario type | |
| ### Divergent world-growth (Thousand Token Wood) | |
| ``` | |
| scene-whisperer β subscribes_to: [], tick: 1 | |
| pocket-actor β subscribes_to: [], tick: 2 | |
| echo β subscribes_to: [user.injected], tick: 3 | |
| mischief-critic β subscribes_to: [], tick: 16 | |
| ``` | |
| The seedkeeper narrates every turn; the pocket-actor wants something every other turn; | |
| the echo answers visitors instantly and otherwise transforms the table every third turn. | |
| The reckoning (`mischief-critic`) is *paced, not reactive*: it stays silent until turn 16, | |
| then records what became real β one closing `judge.verdict` that ends the show. A judge | |
| that instead `subscribes_to: [world.observed]` would fire on the genesis event and resolve | |
| the run on turn 1, before the cast ever interacts (see `scenario-authoring.md` pitfalls). | |
| ### Convergent mystery-solving (Mystery Roots) | |
| ``` | |
| clue-gatherer β subscribes_to: [run.started, world.observed], tick: None | |
| hypothesis-former β subscribes_to: [agent.thought], tick: None | |
| devils-advocate β subscribes_to: [agent.spoke], tick: None | |
| mystery-judge β subscribes_to: [], tick: 4 # periodic synthesis | |
| ``` | |
| The swarm is fully event-driven. The judge fires periodically to synthesise. | |
| Each clue triggers a hypothesis, each hypothesis triggers a challenge. | |
| The governor ensures this chain terminates each turn. | |
| --- | |
| ## Adding a new agent | |
| 1. Create `src/agents/my_agent.py` with a manifest that declares subscriptions. | |
| 2. Add the agent to the scenario's `agents` tuple. | |
| 3. Done β no conductor or scheduler edits. | |
| The routing is automatic: the conductor reads the manifest and wires up subscriptions | |
| the first time an event of the subscribed kind appears in the ledger. | |