The Debate / Consensus Pattern, Implemented via the Reactive Reducer


Back to: Intro

A little background

Five patterns in, and we’ve covered flow, authority, and delegation. Now we get to reasoning - patterns where the agents don’t divide the work, they stress-test it. First up: debate / consensus. Several agents examine the same problem from independent perspectives, produce competing answers, critique one another, revise over rounds, and a judge or voting rule picks the outcome.

In most frameworks, a debate is a group chat: throw the agents in a room, let them talk, and hope it converges. “Debates can run indefinitely” is a real failure point - in a group chat, convergence is a vibe. The reactive reducer treats the debate as what it actually is: a bounded loop with a join and a judge, and every one of those is a declared machine construct. The debate can’t run indefinitely, because “indefinitely” isn’t a legal run.


The pattern

Debate / Consensus. Several agents examine the same problem from independent perspectives rather than dividing it into separate tasks. They may produce competing answers, critique one another’s reasoning, revise their positions over several rounds, and expose assumptions or weaknesses. A judge, voting rule, scoring mechanism, or synthesis agent then determines the final outcome.

Good for:

  • Ambiguous reasoning
  • Architecture decisions
  • Hypothesis evaluation
  • Adversarial review
  • Reducing individual-agent blind spots

Common failure points:

  • Agents may share the same blind spot
  • Apparent consensus can reinforce a common error
  • Debates can run indefinitely
  • Judge quality becomes critical
  • Token cost grows rapidly

The machine: a debate is a loop with a join and a judge

The question: should we move the billing system to a microservices architecture? Three debaters - a proponent, an opponent, and a pragmatist - each argue from their perspective. A judge decides whether the debate has converged, and if not, sends them back with a note about what’s still contested.

fsm:
  name: arch-debate
  version: 1
  initialState: question

  context:
    question:   { type: string }
    positions:  { type: map, of: string }   # opening positions, keyed by debater - kept
    critiques:  { type: map, of: string }   # this round's critiques - cleared each round
    verdict:    { type: enum, values: [consensus, continue] }
    judge_note: { type: string }
    answer:     { type: string }

  agents:
    moderator: { canTransition: [question] }
    pro:       { canTransition: [positions, critique] }
    con:       { canTransition: [positions, critique] }
    prag:      { canTransition: [positions, critique] }
    judge:     { canTransition: [judge, finalize] }

  states:
    question:
      description: "Stage the debate question: updateContext(field=question, value=<the question>)."
      requires:
        - { name: question, from: agent }
      transitions:
        - { toState: positions }

    positions:
      description: >-
        Round 1 - state your position on "{{ context.question }}" from your assigned perspective:
        updateContext(field=positions, value=<your position>).
      requires:
        # One field, three named debaters. Entries are keyed by their actual author, so the
        # three requirements ARE the join - no counting guard, and no way to argue for someone else.
        - { name: positions, from: { agent: pro },  prompt: "State your position." }
        - { name: positions, from: { agent: con },  prompt: "State your position." }
        - { name: positions, from: { agent: prag }, prompt: "State your position." }
      transitions:
        - { toState: critique }

    critique:
      description: >-
        The positions on the table: {{ context.positions }}. Critique them -
        updateContext(field=critiques, value=<your critique>). If the judge sent you back,
        address its note: "{{ context.judge_note }}".
      requires:
        - { name: critiques, from: { agent: pro },  prompt: "Critique the other positions." }
        - { name: critiques, from: { agent: con },  prompt: "Critique the other positions." }
        - { name: critiques, from: { agent: prag }, prompt: "Critique the other positions." }
      transitions:
        - { toState: judge }

    judge:
      description: >-
        Positions: {{ context.positions }}. This round's critiques: {{ context.critiques }}.
        Has the debate converged? If yes, stage updateContext(field=verdict, value=consensus).
        If not, stage verdict=continue and judge_note=<what is still contested, specifically>.
      requires:
        - { name: verdict, from: { agent: judge } }
        - { name: judge_note, from: { agent: judge }, required: false, label: "What's still contested (if continue)" }
      transitions:
        - { toState: finalize, guards: [ { backend: cel, expression: "context.verdict == 'consensus'" } ] }
        - toState: critique
          guards: [ { backend: cel, expression: "context.verdict == 'continue'" } ]
          onTransition:
            # Clear the round's critiques and the routing trigger; KEEP the positions and the
            # judge's note. Emptying the map is what re-arms the three requirements - an entry
            # that survived would satisfy its debater's requirement without asking them anything.
            - { remove: { critiques: "*" } }
            - { assign: { verdict: "" } }

    finalize:
      description: >-
        The debate has converged. Compose the final decision from the positions and critiques,
        noting where the debaters landed. Stage: updateContext(field=answer, value=<decision>).
      requires:
        - { name: answer, from: { agent: judge } }
      transitions:
        - { toState: done }

    done:
      description: "Decision reached."
      terminal: true

  policy:
    maxIterations: 8             # hard cap on the debate loop (headless)
    maxExchangesPerStage: 30
    maxExchangesPerAgentPerStage: 15
    checkpointEvery: 5

Debate / Consensus work flow:

question staged

every named debater's position keyed in

every named debater's critique keyed in

verdict == consensus

verdict == continue (critiques emptied, positions kept)

answer staged

question

positions

critique

judge

finalize

done

Three things to hold onto:

  • The debate rounds are fan-out states, and the join is the requirement set. positions and critique each park and elicit all three debaters concurrently. Both write into a single map field, and a map entry is keyed by whoever actually wrote it - so a requirement naming from: { agent: con } is satisfied only by the opponent’s entry. Three requirements on one field are therefore an exact all-of join with no guard at all, and nobody can file a position under a colleague’s name. The debate’s rounds are structural: positions → critique → judge, repeated.
  • Positions accumulate; critiques churn. The split is the design. positions is written once and never cleared, so round 3’s critics are still arguing about the positions the debate opened with rather than a summary of them. critiques is emptied on every loop-back. That’s the same accumulate-vs-consume split the blackboard pattern makes between facts and hypotheses, and it shows up any time a loop has both a durable artifact and a per-round working position.
  • The judge’s continue edge consumes the critiques but keeps the note. When the judge writes verdict = continue, the edge fires back to critique, remove: { critiques: "*" } empties the map, and verdict is blanked. Emptying the map is not cosmetic: an entry that survived the round would satisfy its debater’s requirement immediately, and round 2 would run without anyone being asked - a round in the trace with no argument in it. judge_note is deliberately kept, because the critique state’s objective is templated on it, so the debaters come back and address the specific contested point instead of re-litigating from scratch. Same consume-the-trigger / keep-the-feedback discipline as the pipeline’s review back-edge.

Walking one round of the loop

The moderator stages the question. positions parks and summons all three debaters at once; each writes its position from its assigned perspective, landing under its own key. When all three keys exist the state’s requirements are satisfied and the machine moves to critique, parks again, and summons all three - each now reading the full positions map. Critiques land the same way; the machine moves to judge.

The judge reads both maps and decides. Say the proponent and pragmatist have converged but the opponent is still on a stale cost argument. The judge writes verdict = continue and judge_note = "con's cost objection relies on pre-negotiated pricing; address the updated vendor quote". The continue edge fires: critiques is emptied, verdict is blanked, positions and the note are untouched, and the machine re-parks at critique. Because the map is empty, all three requirements are unsatisfied again and all three debaters are genuinely re-summoned - with the judge’s note in their objective. They re-critique, now targeted, against the original positions rather than a paraphrase of them. The judge reads the fresh round and writes verdict = consensus. finalize composes the decision. done.

judgepragconproMachine (reducer)judgepragconproMachine (reducer)join guard passes→ critiquejoin guard passes→ judgecritiques consumed,note kept → critique(round 2)join passes →judgesummon (positions)summon (positions)summon (positions)positions['agent:pro']positions['agent:con']positions['agent:prag']summon (critique)summon (critique)summon (critique)critiques['agent:pro']critiques['agent:con']critiques['agent:prag']verdict=continue + judge_notesummon (objective templated onjudge_note)summonsummoncritiques['agent:pro'] (fresh round)critiques['agent:con'] (fresh round)critiques['agent:prag'] (fresh round)verdict=consensusanswer (finalize)done (terminal)

What the reducer does to the failure points

Agents may share the same blind spot. The reducer doesn’t fix this - no orchestration pattern does. Three models from the same family share the same blind spots, and a debate between them is just three voices of the same voice. What the design does is make the blind spot visible and attributable: positions and critiques sit in maps keyed by their author, so a post-hoc audit reads “all three missed X” as a comparison across entries rather than a hunt through a transcript, and it can tell you which debater has been agreeing with everyone for four rounds. The fix is model diversity in the agent roster (different families, different temperatures, different evidence), not a different loop shape.

Apparent consensus can reinforce a common error. Same story: the machine can’t tell you the consensus is right, but it can tell you how it formed. The full position/critique/judge-note trail is in the store, so a consensus that formed without any critique actually landing (everyone just agreed) looks structurally different from one where the judge had to run two rounds to resolve a real objection. The judge_note field is the tell: an empty note at consensus means the judge saw no live contention; a long note means the debate did work.

Debates can run indefinitely. This is the headline failure, and it’s bounded here - though by two things rather than the three an earlier version of this article claimed. The judge’s verdict is an enum with exactly two values (consensus ends the debate, continue costs a round), and policy.maxIterations caps the total exchange count, so even a judge that keeps saying continue hits the cap. The reducer’s no-progress guard is not a third: it stops oscillation within a single advance() call, which keeps the microstep loop hazard-free, but it doesn’t span turns, so it won’t notice a debate going four rounds with slightly different words each time. Cross-round convergence is the enum and the policy.

The inverse failure is the sneakier one, and it’s worth naming because it looks like success: a loop that runs and doesn’t debate. If critiques weren’t emptied on the loop-back, round 2’s requirements would be satisfied by round 1’s entries, the state would fall straight through, and the judge would grade the same critiques again. Rounds in the trace, no argument in them.

Judge quality becomes critical. True - and now the judge is a named, scoped role with a typed output. The judge can only write verdict (an enum) and judge_note (a string). It can’t declare victory by calling a tool, can’t skip the finalize step, and can’t route the debate anywhere other than finalize or critique. A weak judge produces a weak judge_note, which produces weak re-critiques - but that failure is legible in the store, and you can swap the judge agent (a different model, a different prompt) without touching the machine.

Token cost grows rapidly. It does, and the design gives you the knobs. The debate’s cost is exactly: (debaters × rounds × positions+critiques) + judge calls. Rounds are bounded by the judge’s enum and maxIterations. And because each debater’s objective is templated from staged fields (positions, the judge’s note) rather than a growing chat transcript, each round’s context cost is roughly constant - the debaters read the store, not the conversation history. A group-chat debate’s prompt grows with every message; this one grows with the number of fields, which is bounded by the design.


The whole thing, one idea

MomentWhoWhat happened in context
questionmoderatorquestion set
positions3 debaters (concurrent)positions keyed by all three authors - kept for the whole run
critique3 debaters (concurrent)critiques keyed by all three authors
judgejudgeverdict (+ judge_note if continue) set
judge → critique-critiques emptied, verdict blanked; positions + note kept
critique (round 2)3 debatersfresh critiques, against the original positions
judgejudgeverdict = consensus
finalizejudgeanswer set
done-terminal

No group chat. No “let’s talk it out.” The debate was a declared loop: fan-out states for the rounds, a writer-keyed map that made the round’s completion and its attribution the same fact, a judge with a two-value enum, a continue edge that empties the critiques and keeps the positions, and maxIterations as the gavel. The agents argued; the machine kept score - and kept the record of who said what.

Up next: 7. Blackboard / Shared-State