The Peer Handoff / Swarm Pattern, Implemented via the Reactive Reducer


Back to: Intro

A little background

The last three articles were about authority: the pipeline (no authority - just order), the supervisor (one agent holds it), the hierarchy (authority split across levels). This one is the opposite: no single agent holds authority at all. Control moves from peer to peer, and each agent decides - at the moment it’s acting - which peer is best suited to continue. That’s the peer handoff / swarm pattern, the one that sounds the most chaotic and the hardest to make debuggable.

In a framework built on tool-calling agents, a handoff is usually a special tool: transfer_to(agent, reason). The active agent calls it, and the framework re-points the conversation. The failure modes are the familiar ones: infinite handoff loops, unclear ownership, work bouncing around instead of getting done. The whole path is emergent - you reconstruct it from the trace after the run.

The reactive reducer has a different answer: a handoff is a context write to a typed field, and the swarm’s topology is the declared edges. The active agent still chooses the next peer - that’s the point of the pattern - but the choice is a value against an enum, and the set of legal handoffs is a graph you can read before the run. The chaos is real; the topology of the chaos is data.


The pattern

Peer Handoff / Swarm. Control moves dynamically between peer agents through explicit handoffs. The active agent determines which peer is best suited to continue based on the current conversation, state, or emerging requirements, then transfers responsibility directly. Unlike a supervisor model, no single agent necessarily sees or controls the entire execution path, so the workflow can evolve organically as the task unfolds.

Good for:

  • Dynamic workflows
  • Conversational systems
  • Loosely coupled specialists
  • Tasks where the correct path is not known ahead of time
  • Adaptive workflows that change as information emerges

Common failure points:

  • Infinite handoff loops
  • Unclear ownership
  • Agents bounce work around instead of completing it
  • Execution paths become hard to predict
  • Debugging and replay can be difficult

The machine: every agent is a peer, every handoff is a write

A conversational support swarm: triage, billing, technical, and security peers. Any peer can hand off to any other peer, or finish, or escalate. There’s no supervisor state - the machine is a mesh, and each state is one peer’s turn to act.

fsm:
  name: support-swarm
  version: 1
  initialState: triage

  context:
    request:      { type: string }
    handoff:      { type: enum, values: [triage, billing, technical, security, finish, stuck] }
    handoff_note: { type: string }             # THIS hop's note - consumed on every edge
    trail:        { type: list, of: string }   # every hop so far - never cleared
    answer:       { type: string }

  agents:
    triage:    { canTransition: [triage] }
    billing:   { canTransition: [billing] }
    technical: { canTransition: [technical] }
    security:  { canTransition: [security] }
    finisher:  { canTransition: [finish] }
    moderator: { canTransition: [stuck] }

  states:
    triage:
      description: >-
        Read the request and either work it, or hand off to the peer best suited to continue.
        Stage: updateContext(field=request, value=<gist>),
        updateContext(field=handoff, value=<peer|finish|stuck>),
        and updateContext(field=handoff_note, value=<what you did, what you're passing, and why>).
      requires:
        - { name: request, from: agent }
        - { name: handoff, from: agent }
        - { name: handoff_note, from: agent }
      # One exit hook per state, rather than the same two-line consume on all five edges.
      # It runs BEFORE the edge fires, so both fields are still set here.
      exit:
        - { append: { trail: "${ 'triage → ' + context.handoff + ': ' + context.handoff_note }" } }
        - { assign: { handoff: "", handoff_note: "" } }
      transitions:
        - { toState: billing,   guards: [ { backend: cel, expression: "context.handoff == 'billing'" } ] }
        - { toState: technical, guards: [ { backend: cel, expression: "context.handoff == 'technical'" } ] }
        - { toState: security,  guards: [ { backend: cel, expression: "context.handoff == 'security'" } ] }
        - { toState: finish,    guards: [ { backend: cel, expression: "context.handoff == 'finish'" } ] }
        - { toState: stuck,     guards: [ { backend: cel, expression: "context.handoff == 'stuck'" } ] }

    billing:
      description: >-
        The request: "{{ context.request }}". How it got to you: {{ context.trail }}.
        Work it, then hand off to another peer, finish, or escalate. Stage:
        updateContext(field=handoff, value=<peer|finish|stuck>) and
        updateContext(field=handoff_note, value=<what you did and why you're passing it on>).
      requires:
        - { name: handoff, from: agent }
        - { name: handoff_note, from: agent }
      exit:
        - { append: { trail: "${ 'billing → ' + context.handoff + ': ' + context.handoff_note }" } }
        - { assign: { handoff: "", handoff_note: "" } }
      transitions:
        - { toState: triage,    guards: [ { backend: cel, expression: "context.handoff == 'triage'" } ] }
        - { toState: technical, guards: [ { backend: cel, expression: "context.handoff == 'technical'" } ] }
        - { toState: security,  guards: [ { backend: cel, expression: "context.handoff == 'security'" } ] }
        - { toState: finish,    guards: [ { backend: cel, expression: "context.handoff == 'finish'" } ] }
        - { toState: stuck,     guards: [ { backend: cel, expression: "context.handoff == 'stuck'" } ] }

    technical:
      description: >-
        The request: "{{ context.request }}". How it got to you: {{ context.trail }}.
        Work it, then hand off, finish, or escalate - stage `handoff` and `handoff_note`.
      requires:
        - { name: handoff, from: agent }
        - { name: handoff_note, from: agent }
      exit:
        - { append: { trail: "${ 'technical → ' + context.handoff + ': ' + context.handoff_note }" } }
        - { assign: { handoff: "", handoff_note: "" } }
      transitions:
        - { toState: triage,    guards: [ { backend: cel, expression: "context.handoff == 'triage'" } ] }
        - { toState: billing,   guards: [ { backend: cel, expression: "context.handoff == 'billing'" } ] }
        - { toState: security,  guards: [ { backend: cel, expression: "context.handoff == 'security'" } ] }
        - { toState: finish,    guards: [ { backend: cel, expression: "context.handoff == 'finish'" } ] }
        - { toState: stuck,     guards: [ { backend: cel, expression: "context.handoff == 'stuck'" } ] }

    security:
      description: >-
        The request: "{{ context.request }}". How it got to you: {{ context.trail }}.
        Work it, then hand off, finish, or escalate - stage `handoff` and `handoff_note`.
      requires:
        - { name: handoff, from: agent }
        - { name: handoff_note, from: agent }
      exit:
        - { append: { trail: "${ 'security → ' + context.handoff + ': ' + context.handoff_note }" } }
        - { assign: { handoff: "", handoff_note: "" } }
      transitions:
        - { toState: triage,    guards: [ { backend: cel, expression: "context.handoff == 'triage'" } ] }
        - { toState: billing,   guards: [ { backend: cel, expression: "context.handoff == 'billing'" } ] }
        - { toState: technical, guards: [ { backend: cel, expression: "context.handoff == 'technical'" } ] }
        - { toState: finish,    guards: [ { backend: cel, expression: "context.handoff == 'finish'" } ] }
        - { toState: stuck,     guards: [ { backend: cel, expression: "context.handoff == 'stuck'" } ] }

    finish:
      description: >-
        Compose the final answer from the full handoff record: {{ context.trail }}.
        Stage: updateContext(field=answer, value=<answer>).
      requires:
        - { name: answer, from: { agent: finisher } }
      transitions:
        - { toState: done }

    stuck:
      description: >-
        The swarm bounced the request without converging. The trail is {{ context.trail }} -
        every hop, who took it, and why. The moderator escalates with that record, then ends.
      requires:
        - { name: answer, from: { agent: moderator } }
      transitions:
        - { toState: done }

    done:
      description: "Resolved (or escalated)."
      terminal: true

  policy:
    maxIterations: 20            # hard backstop on the whole mesh if running headless
    maxExchangesPerStage: 30
    maxExchangesPerAgentPerStage: 15
    checkpointEvery: 5

Peer Handoff / Swarm work flow:

handoff == billing

handoff == technical

handoff == security

handoff == finish

handoff == triage

handoff == technical

handoff == security

handoff == finish

handoff == triage

handoff == billing

handoff == security

handoff == finish

handoff == triage

handoff == billing

handoff == technical

handoff == finish

handoff == stuck

handoff == stuck

handoff == stuck

handoff == stuck

answer staged

escalated

triage

billing

technical

security

finish

stuck

done

Four things to hold onto:

  • The active agent’s choice is the handoff write. That’s the pattern’s core requirement - the next peer is decided by the agent acting, based on what it just learned - and the reducer preserves it exactly. But the choice is a value against a typed enum, and the legal set of choices is the declared edges. An agent can hand off to triage, billing, technical, security, finish, or stuck - and to nothing else. A hallucinated peer is a coercion failure, not a routing event.
  • Every handoff carries a note, and the note survives the handoff. Each state’s exit folds this hop into trail - "billing → security: dispute is valid, but the account shows a takeover pattern" - and then blanks handoff and handoff_note. The receiving peer reads {{ context.trail }}, so it sees not just why it was called but the whole path the request took to reach it, which is what “no single agent sees the entire execution path” costs you if you don’t keep it somewhere.
  • Consuming the trigger is what makes the mesh re-armable. Both fields are blanked on the way out for the same reason a review loop blanks its verdict: a requirement is satisfied by presence, so a handoff_note that survived the hop would satisfy the next peer’s requirement before it was asked anything, and the machine would fall through the state without a turn. Doing it in exit rather than per-edge is worth noticing on its own - five edges per state, one hook. exit runs after the edge is selected and before it fires, so blanking the field the guard just read is safe.
  • The swarm is bounded structurally. Any peer can route to stuck - a declared escalation state - the moment it senses the work isn’t converging, and policy.maxIterations caps the total exchange count. Notice what’s not there: no hop counter in context. Convergence is a property of the declared graph plus the policy, not a field an agent has to keep honest.

Walking a three-peer handoff

A customer asks a billing question that turns out to be a security issue. triage reads the request, stages handoff = billing with a note (“charge dispute, check for fraud signals”). The edge is selected; exit appends "triage → billing: charge dispute, check for fraud signals" to trail and blanks both fields; the machine lands in billing with nothing set and parks, summoning the billing peer.

The billing peer reads the request and the trail, works the charge, finds the fraud angle, and stages handoff = security with its own note. Its hop is appended, the fields are blanked again, and security parks. The security peer now sees two hops of history - the original triage reasoning and the billing peer’s finding - confirms the takeover, and writes handoff = finish. The finisher composes the answer from a trail that reads as the case history. done.

finishersecuritybillingtriageMachine (reducer)finishersecuritybillingtriageMachine (reducer)exit appends hop totrail, blanks bothfieldspark at triagerequest + handoff=billing + noteA2A summon (objective templated on note)handoff=security + note (fraud angle)A2A summon (sees request + the whole trail)handoff=finish + noteA2A summon (finish state)answerdone (terminal)

Notice what each peer sees. Not the previous peer’s transcript - the store: the request, the accumulated notes, the last handoff note. Each handoff is a data value, and the receiving peer’s objective interpolates the relevant ones in full. Ownership is unambiguous at every moment: the machine is at a state, and the state’s canTransition agent is the one acting. “Unclear ownership” - the pattern’s second failure point - has no purchase here, because ownership is the machine’s current state.


What the reducer does to the failure points

Infinite handoff loops. This is the pattern’s headline failure, and the reducer converts it from a trace-forensics problem into a declared one - but it’s worth being precise about which backstop does what, because an earlier version of this article credited one of them with more than it does.

The stuck state is a declared escape valve: any peer can route into it the moment it senses the work isn’t converging, and it has its own escalation objective. policy.maxIterations caps the total exchange count for headless runs, so a swarm that never routes to stuck is still bounded. Those two are the real cross-turn protection. The reducer’s no-progress guard is not a third one: it stops a single advance() call from oscillating between states within one microstep run - a genuinely useful hazard-free property, and the reason a guard cycle can’t spin the CPU - but it does not span turns, so it will not notice a swarm that bounces triage → billing → triage over three separate agent turns with the context changing each time. Cross-turn convergence is the graph and the policy, full stop.

What the reducer does give you here is legibility. If the swarm is looping, trail is a list of every hop with the reasoning attached, so “why did this bounce four times” is a field you read rather than a trace you reconstruct. And notice what’s still not there: no hop counter an agent has to keep honest. size(context.trail) is the hop count, derived from the record itself - if you wanted a hard cap of six hops, that’s a guard, not a new field.

Unclear ownership. At any instant, the machine is in exactly one state, and that state’s canTransition names the acting agent. There’s no “who’s driving the conversation right now” question - the parked record answers it. And because a handoff is a context write that fires a guard, the moment of ownership transfer is a journaled event: the edge that fired, the context that triggered it, the state that received it.

Agents bounce work around instead of completing it. The bounce is still possible - the pattern wants dynamic routing - but it’s now bounded and legible. The stuck edge is the escape valve you drew, not a framework timeout, and maxIterations is the hard ceiling. The swarm either converges to finish, escalates to stuck, or trips the policy cap. There’s no third option: “bounce forever” isn’t a legal run.

Execution paths become hard to predict. They’re still not predictable - that’s the point of the pattern; the path depends on what each peer learns. But they’re enumerable and replayable in a way the tool-calling design isn’t. The legal topology is the declared edge set: you can draw it, count it, and reason about it before the run. And a finished run is a sequence of (state, context-write, edge-fired) records - a complete replay of the path, without reconstructing it from model outputs.

Debugging and replay can be difficult. This is the one the design turns around entirely, and trail is why. The run is the debug artifact: every handoff is a context write, every route is a fired edge, and the store at each step is journaled - but more to the point, the answer to “why did the swarm go triage → billing → technical → stuck?” is literally one field. trail holds each hop and the reasoning the peer gave for it, in order, in the same store the peers worked from. No trace scraping, no prompt reconstruction, and - because the notes accumulate rather than being overwritten by the next hop - no reliance on whichever peer happened to write last.

One trade-off worth naming honestly: the mesh is verbose. Every peer state declares the same set of handoff edges, and the stuck escape repeats on each. That’s the price of making the topology explicit - the supervisor pattern’s single routing table becomes N routing tables, one per peer. If you find yourself copying a state five times, that’s a signal the pattern might be a supervisor in disguise, and the last article’s hierarchy is the cheaper shape.


The whole thing, one idea

MomentWhoWhat happened in context
triagetriage peerrequest + handoff=billing + note
billingbilling peerhandoff=security + note → appended to trail, both consumed
securitysecurity peerhandoff=finish + note → appended to trail, both consumed
finishfinisheranswer staged from accumulated notes
done-terminal

No supervisor. No manager. Each peer chose its successor by writing a typed handoff value with a note, the guards routed it, the store carried the accumulated context, and the loop backstop was a declared stuck edge plus maxIterations - not a timeout you’d have to discover. The swarm’s path is emergent; its topology is data. The machine is still the only thing that moves; the peers just decide where it goes next.

Up next: 6. Debate / Consensus