JJoeven

Curriculum/Multi-Agent Systems

Handoffs Are Typed Events

Planner → worker is {step_id, inputs, budget}. Critic → worker is {issues, attempt}. Paragraphs cannot be retried, hashed, or denied by destination.

intermediate20 min10 / 24

Planner → worker is not a paragraph. It is {step_id, inputs, budget, tools_hint} inside an envelope {to, payload}.

Worker → critic is {artifact_id, evidence_ids, claim}.

Critic → worker is {issues[], attempt}.

A handoff is an envelope
topayloadbudgethash

Wrong destination is an error. Extra keys drop. Poetry cannot be retried.

A handoff is an envelope

If those structs are not in the codebase, you do not have roles. You have a group chat that happens to mention “coder.” The interface-as-data lesson froze the researcher’s brief. This lesson freezes the event that carries any payload: who it is for, whether that who is legal, whether the payload is an object.

The Agents track froze a tool payload so a later thought could not change it. Here you freeze a handoff so a later thought cannot change destination, cannot smuggle extra keys, and cannot retry by rephrasing. Operations need ids and budgets. Poetry cannot be retried.

Envelope and payload

Envelope. to must be a role the graph allows. from is stamped by the runtime, not by the model. Optional: hop, parent_id, schema_id. Wrong to is an error even if the payload is pretty. A string instead of an envelope is an error.

Payload. Depends on the edge:

EdgePayload must includeIf missing
planner → workerstep_id, inputs, budgetworker must not start
worker → criticartifact_id, evidence ids, claimcritic fail closed
critic → workerissues, attemptnot a reassignment
researcher → coderbrief fields from parse_briefcoder must not start

Budget is part of the event so the worker cannot inherit the job’s remaining 1,000 hops. A step budget of 4 tool calls is a different product from “run until you feel done.” When the worker hits the budget, it returns to the supervisor or critic with cannot: step_budget, not with a silent extra search.

Budgets also stop a worker from becoming a nested swarm. If the coder’s step budget is 4, it cannot fan out 40 test-generation children unless the parent priced that swarm as its own job. Nested unbounded fan-out is how a “simple” sequential team becomes 50 launches you never approved. The failure-modes lesson will refuse unpriced 50. The envelope is where the 4 was supposed to live.

tools_hint is a hint the assembler may use to advertise tools. The dispatcher still uses ALLOW[role]. A hint that says refund does not grant refund. Hints are not allow-lists. Isolation already covered that; the event must not undo it.

Drop extra keys when you parse, same as briefs. A payload that includes also_tell_coder_to_skip_tests must not reach the worker. Build a new dict from known fields.

Walkthrough: three envelopes

Good: {"to": "coder", "payload": {"step_id": 1, "budget": 4, "brief": "bump timeout"}}. Parser expecting coder: ok.

String: "please fix". not_object.

Wrong destination: {"to": "refund", "payload": {}} when the next hop must be coder. wrong_to. Even an empty payload cannot sneak a destination change. The refund role is not a legal to on this edge. (A billing apply step later is a graph node, not a handoff the planner invents.)

A fourth case you should add in production: to is coder but payload is a list. need_payload as a dict. Types matter.

Live PythonOpen full playgroundpython
Output
Run to execute this in your browser. Nothing is sent to a server.

What printed: the good envelope is ok with destination coder and the inner payload. A string is not_object. The refund destination is wrong_to with got: refund. Only a dict with to plus a dict payload proceeds, and only if to matches what the orchestrator allowed for this hop.

This toy still passes the whole payload through. Production should parse inner fields (step_id int, budget int, brief already passed parse_brief) and drop the rest. Nest parsers. Do not one-shot eval or execute strings. There is no exec in these boxes on purpose.

Hash, log, do not let thoughts append

Log from, to, hash(payload). The ping-pong lesson will stop when the same hash repeats. You cannot hash a paragraph reliably across punctuation. You can hash a canonical dict (sorted keys, no extra fields).

The next hop must not pick up extra keys from a later thought. Stamp the event, persist it, then the worker assembler reads that row, not the live chat. If the planner keeps talking, those tokens are the planner’s trace, not a mutation of the already-sent event.

Illegal edges (B may not call A) belong to hop-limits. The parser here still helps: if B emits to: A, wrong_to or a later graph check drops it. Defense in depth: parser, graph, hop cap, duplicate hash.

Version the envelope. schema_id on the event lets you reject v1 handoffs when v2 requires attempt. Silent drift is how last month’s critic packet reaches this week’s worker missing issues and the worker treats empty as pass. Fail closed on unknown schema. Do not “do your best” with a partial dict.

How agents use this

This is the Agents freeze-payload idea between roles. Log from, to, hash(payload). The next hop must not pick up extra keys from a later thought.

Put parse_handoff in front of every role start. Unit-test wrong to, string body, missing payload, extra keys dropped. No tokens.

When you add supervisor mode, the only legal to from a worker is sup. Peer to: docs from the coder is wrong_to and peer_forbidden. When you add sequential mode, workers should not emit to at all: code picks the next node. If a worker still emits an envelope, ignore it or fail the eval — the graph is not a suggestion box.

Budgets on events are how you price a step before it runs. Swarms will price N children before launch. Same habit, smaller envelope.

Check your understanding

What is a legal planner-to-worker handoff?