Orchestrator-Workers, Handoffs & What Crosses the Boundary
The two structural patterns behind almost every multi-agent system — a central planner delegating to specialists, versus peers transferring control — and the design decision that determines whether either works: what actually gets passed between agents.
Strip the vendor diagrams away and multi-agent systems reduce to a handful of shapes. Orchestrator-workers: one agent owns the task, decomposes it, delegates subtasks to specialist workers, and integrates their results — control always returns to the center. Handoffs: peers transfer ownership sideways — a triage agent realizes this is a billing question and hands the conversation to the billing agent, which now owns it; control does not return. Evaluator loops: a producer's output goes to a critic, which approves or sends it back with feedback — you built exactly this with the writer-critic cycle in Lesson 2.
| Pattern | Structure | Control flow | Canonical use case |
|---|---|---|---|
| Orchestrator-workers | Hub and spokes; planner + specialists | Always returns to the orchestrator, which integrates | Research: planner decomposes a question, parallel searchers gather, writer integrates (Lab 05) |
| Handoff | Peers; ownership transfers sideways | One-way transfer; the receiver owns the task from then on | Support routing: triage → billing specialist with its own tools and permissions |
| Evaluator loop | Producer + critic cycle | Bounded loop between two roles | Draft-review-revise; code-gen with a test-runner critic |
# Colab cell 1 — run once. No API key needed; the model calls are stubbed
# so the orchestrator-worker shape runs on LangGraph's machinery alone.
# planner -> N parallel searchers (fan-out) -> writer -> END
!pip install -q langgraph
import operator
from typing import Annotated, TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.types import Send
class ResearchState(TypedDict):
question: str
plan: list[str]
findings: Annotated[list[str], operator.add] # reducer merges parallel writes
draft: str
# --- stubbed model calls (Module 1 would make these real) ---
def plan_with_llm(question: str) -> list[str]:
return [f"{question} — angle {i}" for i in (1, 2, 3)]
def search_and_summarize(task: str) -> str:
return f"evidence for [{task}]"
def write_with_llm(question: str, findings: list[str]) -> str:
return f"Draft for {question!r} citing {len(findings)} findings."
def planner(state: ResearchState) -> dict:
# One model call: decompose the question into concrete,
# independently-searchable subtasks. Force structured output
# (Module 1 skills) so 'plan' is a clean list, not prose.
return {"plan": plan_with_llm(state["question"])}
def searcher(worker_input: dict) -> dict:
# receives ONE subtask (the Send payload), not the whole state
evidence = search_and_summarize(worker_input["task"])
return {"findings": [evidence]} # reducer appends parallel writes
def writer(state: ResearchState) -> dict:
return {"draft": write_with_llm(state["question"], state["findings"])}
# Fan-out: a conditional edge after 'planner' returns one Send per
# subtask, each carrying its own slice of state. (The Send API's exact
# signature varies by LangGraph version; the map/reduce concept is the
# stable part.)
def fan_out_to_searchers(state: ResearchState) -> list[Send]:
return [Send("searcher", {"task": task}) for task in state["plan"]]
builder = StateGraph(ResearchState)
builder.add_node("planner", planner)
builder.add_node("searcher", searcher)
builder.add_node("writer", writer)
builder.add_edge(START, "planner")
builder.add_conditional_edges("planner", fan_out_to_searchers, ["searcher"])
builder.add_edge("searcher", "writer")
builder.add_edge("writer", END)
graph = builder.compile()
result = graph.invoke({"question": "How do vector index types differ?",
"plan": [], "findings": [], "draft": ""})
print(result["findings"]) # one per subtask, merged by the reducer
print(result["draft"])Send payload) — not the whole conversation — and they run in parallel, their results merged by the operator.add reducer on findings. Those are two of the three legitimate reasons to go multi-agent (Lesson 5). The writer never sees raw search transcripts, only distilled findings. It all runs with no key because the three model calls are stubbed — swap them for real ones (Module 1) without touching the graph.The handoff payload decides everything
Whatever the pattern, quality is determined by what crosses the agent boundary. Passing the full conversation history feels safe but is usually wrong: it blows the receiver's context budget, buries the actual task in noise, and leaks irrelevant (sometimes sensitive) content across roles — and the receiver will latch onto distracting details exactly the way a human skimming a 40-page thread does. Passing a one-line summary is the opposite failure: the receiver lacks what it needs and hallucinates the gaps. The reliable middle ground is a structured brief: an explicit schema stating the task, the constraints, the relevant facts so far, and what the receiver must return.
# Colab cell 2 — run cell 1 first (it defines graph). Pure Python + pydantic;
# no API key needed.
!pip install -q pydantic
import json
import time
from pydantic import BaseModel
class HandoffBrief(BaseModel):
from_agent: str
to_agent: str
task: str # what the receiver must do
context: list[str] # ONLY the facts the receiver needs
constraints: list[str] # format, length, tone, citations
expected_output: str # shape of what comes back
def log_handoff(brief: HandoffBrief, path: str = "handoffs.jsonl") -> None:
record = {"ts": time.time(), **brief.model_dump()}
with open(path, "a") as f:
f.write(json.dumps(record) + "\n")
def planner_to_searcher_brief(subtask: str, question: str) -> HandoffBrief:
return HandoffBrief(
from_agent="planner",
to_agent="searcher",
task=f"Find evidence for: {subtask}",
context=[f"Overall research question: {question}"],
constraints=["cite the source of every claim",
"return at most 5 bullet findings"],
expected_output="bulleted findings, each with a source",
)
brief = planner_to_searcher_brief("compare HNSW vs IVF recall",
"How do vector index types differ?")
log_handoff(brief) # appends one line to handoffs.jsonl
print(brief.task, "->", brief.expected_output)
print(open("handoffs.jsonl").read().strip())HandoffBrief and logged before the receiver runs. The log serves two masters: debugging (when the writer produces garbage, read the brief it received — the bug is usually there, not in the writer) and the inter-agent trace your baseline comparison and README need. Pydantic gives you validation for free: a brief missing expected_output fails at construction, not three agents downstream. The demo builds one brief, logs it, and reads the JSONL line back — that file is the inter-agent trace your README and baseline comparison need.Shared state vs. message passing
There are two fundamentally different ways to let agents communicate, and LangGraph's reducer-merged state schema is a specific, opinionated answer — not the only one. Shared state (what you've been building all module): every node reads and writes one common, typed schema; the checkpointer snapshots the whole thing; any node can, in principle, see any field. Message passing: agents have no shared memory at all — each keeps its own private context, and the only thing that crosses a boundary is an explicit message, the same discipline as the HandoffBrief you just built, but as the only channel rather than a convention layered on top of a shared schema. Frameworks built around an actor or agent-to-agent messaging model (rather than a shared graph state) take this second approach natively.
| Shared state (LangGraph) | Message passing (actor-style) | |
|---|---|---|
| Debuggability | One snapshot to inspect; time-travel and checkpointing fall out for free | Have to reconstruct the global picture from many private logs — no single source of truth to get_state() |
| Coupling | Every node depends on the shape of one global schema — a field rename touches every reader | Agents only depend on the message contract they receive — internal state can change freely |
| Isolation | Nothing stops a careless node from reading a field it shouldn't need (the 'vague notes field' failure from Lesson 2) | Strong by construction — an agent literally cannot see what it wasn't sent |
| Best fit | One team, one deployable, tight iteration loop, need for audit/replay (compliance, this course's labs) | Independently owned/deployed agents, different release cadences, org boundaries between teams |
In practice the two aren't opposites so much as two layers of the same system. LangGraph gives you shared state as the infrastructure primitive — it's what the checkpointer persists and what makes resume and time-travel possible — but nothing stops you from disciplining how nodes actually use it to get message-passing's isolation benefits: the HandoffBrief pattern from this lesson is exactly that hybrid. Each node still technically shares one TypedDict, but by convention a searcher only ever reads the narrow brief fields meant for it, not the whole state. You get checkpointing's audit trail and message-passing's discipline at once — the discipline just isn't enforced by the type system, so code review is where it actually gets held.
The telephone game: why chains lose information
A single well-designed handoff brief solves the full-history-vs-one-liner problem for one hop. It does not automatically solve it for a chain of hops, and that's a distinct failure mode worth naming on its own: at each step, an agent summarizes what it received into a fresh brief for the next agent — and a summary of a summary loses whatever the first agent judged unimportant, even if it was a hard constraint. A user says "refund the coffee maker, but only if it's still sealed." Triage briefs the returns specialist with task: "process refund for coffee maker" — reasonable-looking, but the conditional silently dropped because it read like a detail rather than a constraint. The returns specialist briefs the payment agent with task: "issue refund, item: coffee maker" — by hop three, a real constraint from the original request no longer exists anywhere in the system, and every individual handoff still looks locally correct.
The fix isn't better summarization — it's recognizing that some fields shouldn't be summarized at all. Carry a small, explicit hard-constraints field (or a short list of verbatim quotes from the original request) through every hop of a chain unmodified, separate from the free-text task description each agent rewrites. Only the narrative gets compressed hop to hop; the constraints that must survive get passed as data, not prose, and nothing downstream is allowed to paraphrase them. This is the same principle as citations in Module 3's grounded generation: some information is too important to survive being retold.
HandoffBrief.task string summarizing the previous agent's brief (not the original ticket). The refund agent issues a full refund without checking API usage. Where's the bug, and what's the structural fix — not just "tell the refund agent to read more carefully"?Whiteboard drills
- ▸Orchestrator-workers: hub decomposes, delegates, integrates; control returns to center. Handoff: peer transfer of ownership; control doesn't return.
- ▸Evaluator loops are a two-role cycle — always bounded by a revision cap.
- ▸Full-history handoffs bloat context and bury the task; one-liners starve the receiver. Structured briefs (task, context, constraints, expected output) win.
- ▸Log every handoff payload — most multi-agent bugs are briefing bugs, visible in the log.
- ▸A rubber-stamp critic needs a rubric, the original requirements, and per-item grading — not a bigger model.