« Phase 07 README · Warmup · Hitchhiker's · Principal Deep Dive · Core Contributor · Staff Notes
Phase 07 — Deep Dive: Multi-Agent Orchestration
The load-bearing mechanism in this phase is not "several LLMs." It is a shared, totally-ordered
message log plus a mutable blackboard dict, driven by an orchestrator loop that reads state,
routes by name, and terminates on a quiescence condition. Strip the word "agent" and what remains is
a tiny deterministic message-passing runtime. This doc traces that runtime at the level a person
implementing it has to reason about — the envelope schema, the routing loop, the ordering and
consistency invariants, and where they break — because that is exactly what solution.py makes you
build.
The message envelope and the two data structures
Every interaction is one Message: a five-field record (sender, recipient, kind, content, seq).
Four fields are semantic; seq is infrastructure. sender and recipient are agent names (strings,
resolved through a registry, never object references — this is what lets a handoff cross a process
boundary in the real thing). kind is a small closed vocabulary — control, handoff, result,
draft, critique — that tags the protocol slot a message occupies, which is what makes the
transcript self-describing: history(kind="handoff") reconstructs the routing graph without parsing
content. content is the opaque payload (a string, or a typed Handoff).
Behind the messages sit two structures with different jobs:
- The message log — an append-only
list[Message].post(msg)stampsmsg.seqfrom a monotonic counter, increments the counter, appends, and returns. That counter is the entire ordering guarantee: because it is an integer bumped on a single code path,history()is a total order, and two identical runs produce byte-identical transcripts. Order by a wall clock instead and you have lost determinism the instant two events land in the same millisecond — untestable, unreplayable. - The blackboard — a shared
statedict:{task, completed: [...], results: {...}}. This is the 1970s blackboard architecture: agents coordinate by reading and writing shared state, not by calling each other. The supervisor routes offcompleted; workers append their output toresults. The distinction from the log matters — the log is the history (immutable, ordered, an audit tape); the blackboard is the current world (mutable, overwritten). Confusing the two is how people build systems they cannot replay.
The orchestrator loop, field by field
Supervisor.run(task, workers, max_turns, bus) is Phase 01's agent loop lifted one level: the
"tools" are whole agents. Setup builds a registry — {w.name: w for w in workers} plus the
supervisor itself — the name-to-agent map that makes routing a dictionary lookup and makes an unknown
target a detectable error rather than a crash. Then a bounded for _turn in range(max_turns):
- Decide.
decision = self.act(render_state(state)).strip().render_stateprojects the blackboard to a small deterministic string —TASK: ...andCOMPLETED: researcher writer. The supervisor routes on progress, not on the content of results, which keeps its context tiny and its policy a pure function of a stable rendering. - Record the decision.
post(Message(self.name, self.name, "control", decision)). The routing decision is itself a first-class message — a self-addressedcontrolmessage — so the why of the next step is in the tape, not hidden in a variable. - Terminate or route. If
decision == "done", setreason = "done"and break — the quiescence condition. Otherwise build a least-context handoff:prior = list(results.values())[-1](the most recent result, not the whole history), thenmake_handoff(self.name, decision, task, {task, prior}, registry), which validatesdecisionis a known agent and raisesValueErrorif not. - Execute.
worker = registry[decision]; result = worker.act(render_handoff(handoff)). The worker sees onlyrender_handoffoutput —TASK:and optionallyPRIOR:— never the bus, neverCOMPLETED:, never another worker's chatter. - Commit. Post the
resultmessage, then mutate the blackboard:results[name] = result; completed.append(name). State advances; the loop re-reads it next turn.
If the loop exits by exhausting range(max_turns) without a done, reason stays "max_turns" and
the Transcript returns a partial result with stopped_reason == "max_turns" — a bounded failure,
never a hang. The termination condition is therefore two-pronged: success is the policy emitting
done (progress-driven quiescence); safety is the budget firing (a hard ceiling). Both are
explicit; neither depends on a model choosing to stop.
A worked trace
Task: "Summarize recent work on X with citations", workers researcher and writer, a supervisor
that routes the first unfinished worker then done. Watch the seq counter and the blackboard:
state = {task, completed: [], results: {}}
turn 0: render_state → "COMPLETED: " → policy → "researcher"
#0 supervisor→supervisor [control] "researcher"
#1 supervisor→researcher [handoff] ctx={task, prior:""}
researcher.act("TASK: ...\n") → "sources: Smith 2021, Lee 2022, Ada 2023"
#2 researcher→supervisor [result] commit: completed=[researcher]
turn 1: render_state → "COMPLETED: researcher" → policy → "writer"
#3 supervisor→supervisor [control] "writer"
#4 supervisor→writer [handoff] ctx={task, prior:"sources: ..."}
writer.act("TASK: ...\nPRIOR: sources: ...\n") → "Report drafted from [sources: ...]"
#5 writer→supervisor [result] commit: completed=[researcher, writer]
turn 2: render_state → "COMPLETED: researcher writer" → policy → "done"
#6 supervisor→supervisor [control] "done" → stop (reason="done")
Seven messages, two routed workers, turns=2, stopped_reason="done". Note the mechanism that makes
this auditable: reading #0..#6 in order tells you exactly who decided what, who was handed what,
and what came back — no call stack to reconstruct. And note the least-context property is visible in
#4: the writer's handoff carries prior (the researcher's findings) and the task, and nothing from
#0..#3. The critique-revise loop is the same shape with kind toggling draft/critique and the
budget being max_rounds: a good draft is one round (draft, approve); revise-then-approve is two; a
never-approved draft stops at max_rounds with approved=False.
Ordering, consistency, and the races the miniature hides
The miniature is single-threaded, so several distributed-systems hazards are latent — invisible here, lethal the moment you add real concurrency. Name them, because they are the whole difficulty of the production version:
- Message ordering. A single monotonic counter gives a total order for free only because there is one writer. Fan out to parallel workers on separate threads or processes and "the next seq" is a contended resource; you need a single-writer bus, a per-topic sequence, or logical (Lamport) clocks to recover a deterministic order. Order is not a given — it is a design.
- Livelock (ping-pong). No deadlock is possible in the miniature — nobody blocks — but livelock
is trivial: a supervisor whose policy oscillates
researcher → writer → researchermakes progress in the log while making no progress towarddone. Themax_turnsbudget is precisely the livelock breaker; without it the loop burns tokens forever. In a real parallel topology you also get deadlock: a join step that waits for N worker results blocks forever if one worker never posts — which is why real fan-outs need per-branch timeouts, not just a global budget. - Duplicate delivery. A real network bus is at-least-once; handlers must be idempotent. Here it
bites at commit:
results[name] = resultis idempotent by key (a redelivery overwrites with the same value), butcompleted.append(name)is not — a redelivered result double-appends and the supervisor's progress view corrupts. Idempotency is a per-write property you have to design, not a bus feature you can assume. - Shared-state races. The blackboard is a shared mutable dict. Single-threaded, reads and writes are trivially serialized. Under parallel workers writing the same key, last-writer-wins is a race, and you need either per-worker keys or a reducer — a merge function that combines concurrent writes deterministically (exactly LangGraph's channel model, Phase 07's Core Contributor doc).
Why the naive alternatives fail at the mechanism level
Two shortcuts tempt everyone; both fail structurally, not stylistically.
"One big agent." Fold research, writing, and checking into a single prompt-and-toolset and the failure is context blowup: every turn re-feeds the entire growing transcript, so cost and latency scale with turns squared, and past a point retrieval degrades (lost-in-the-middle) so the model gets worse as it accumulates its own history. There is also no verification seam — the same policy that wrote the draft judges it, sharing its blind spots. Roles exist to keep each policy's context small, single-purpose, and independently checkable.
"Unstructured chatter." Let agents call each other directly, passing whatever they like, and you
lose the total order (no seq, no replay), the audit tape (hidden side channels), and — worst —
convergence: with no blackboard to route off and no budget to bound the exchange, there is no
defined quiescence condition, so "when does it stop?" has no answer. The bus-plus-blackboard-plus-
budget is not ceremony; it is the minimum machinery that makes a multi-agent run ordered,
replayable, and guaranteed to terminate.