Packages

Multi-agent orchestration strategies for GenAgent. One ensemble process owns N sub-agents under a strategy (Solo, Switchboard, Pool, Pipeline, Supervisor, Debate, Consensus).

Current section

Files

Jump to
gen_agent_ensemble guides workflows consensus.md
Raw

guides/workflows/consensus.md

# Consensus workflow

## Topology

N peer agents deliberate on a prompt in parallel, each returning
a **structured verdict** plus rationale. A convergence threshold
decides whether they agree; if not, the strategy composes a
re-prompt showing each agent the others' positions and runs
another round. After the round cap, it returns a divergence
report.

```
+-------+    +-----------+
|  E.*  | -> | Consensus | -> dispatch prompt to all N agents in parallel
+-------+    +-----------+
                   |
                   v
             all responded?
                   |
             +-----+-----+
             |           |
             v           v
         threshold    not converged,
          met?          round < cap?
             |           |
             v           v
        CONSENSUS:     re-prompt each agent with
        :verdict       the *others'* rationales
                       and go again
```

Unlike Debate (boolean convergence on free-form text), Consensus
produces a **programmable decision**: the caller branches on an
atom (`:approve | :revise | :reject` or whatever categorical
space the verdict parser uses) without re-parsing LLM prose.

## When to reach for it

- Architecture / design decisions where multi-model perspectives
  reduce the risk of a single model's blind spots.
- Security or safety reviews where you want multiple independent
  judgments before acting.
- Multi-reviewer code review loops (drop into a larger DevTeam
  flow as the "approve or revise?" gate).
- Any decision whose output is a categorical label with rationale.

Where it's wrong:

- You want a long free-form back-and-forth, not a vote. Use Debate.
- You want one coordinator to decompose and aggregate. Use
  Supervisor.
- You want one prompt through a linear chain. Use Pipeline.

## Config

```elixir
defmodule DecisionParser do
  def parse(text) do
    case Regex.run(~r/VERDICT:\s*(APPROVE|REVISE|REJECT)\b/i, text) do
      [_, verdict] ->
        atom = verdict |> String.downcase() |> String.to_atom()
        rationale = Regex.replace(~r/VERDICT:\s*\w+/i, text, "") |> String.trim()
        {:ok, atom, rationale}

      _ ->
        :error
    end
  end
end

config :gen_agent_ensemble,
  ensembles: [
    [
      name: "arch-review",
      strategy: GenAgentEnsemble.Strategies.Consensus,
      opts: [
        agents: [
          {"claude", GenAgentEnsemble.Agents.Simple,
            backend: GenAgent.Backends.Anthropic,
            model: "claude-sonnet-4-6",
            receive_timeout: 180_000,
            system: system_prompt()},
          {"haiku", GenAgentEnsemble.Agents.Simple,
            backend: GenAgent.Backends.Anthropic,
            model: "claude-haiku-4-5-20251001",
            system: system_prompt()},
          {"claude-cli", GenAgentEnsemble.Agents.Simple,
            backend: GenAgent.Backends.Claude,
            system_prompt: system_prompt()}
        ],
        verdict_parser: &DecisionParser.parse/1,
        threshold: :majority,
        rounds: 3,
        reply: :synthesis
      ]
    ]
  ]
```

Where `system_prompt/0` returns something like:

```elixir
"You review technical proposals. Be concrete about tradeoffs, " <>
"specific about risks. Keep to 3-5 tight sentences. " <>
"End every response with exactly one line: " <>
"VERDICT: APPROVE, VERDICT: REVISE, or VERDICT: REJECT."
```

## Options

- `:agents` (required) -- 2+ `{name, module, opts}` specs, distinct
  names.
- `:verdict_parser` (required) -- `(text -> {:ok, atom, rationale}
  | :error)`. The atom is the verdict category; the rationale is
  the response text with verdict markers stripped (the re-prompt
  shows this to the other agents, so strip the machine-readable
  part for cleaner context).
- `:threshold` (optional) -- convergence rule. Defaults to
  `:majority`.
    * `:unanimous` -- every agent must produce the same verdict
      and nobody abstains.
    * `:majority` -- more than N/2 agents agree on the same
      verdict.
    * `{:at_least, n}` -- at least `n` agents agree.
- `:rounds` (optional) -- hard cap on rounds. Defaults to 3.
- `:reply` (optional) -- response shape:
    * `:synthesis` (default) -- converged: verdict header +
      per-agent rationale. Diverged: divergence header + each
      agent's final position.
    * `{:synthesize, fun}` -- call `fun.(summary)` where summary
      is `%{status: :converged | :diverged, verdict: atom | nil,
      rounds: integer, threshold: threshold_spec, responses:
      [{agent, verdict_or_nil, rationale, raw_text}]}`. Useful for
      piping the decision out as structured data.

## Canonical workflow

### Sync run

```elixir
iex> E.ask!("arch-review",
...>   "Proposal: replace ETS session store with Redis for a 500 RPS service. " <>
...>   "30min TTL, 2KB sessions, team has no Redis experience. Should this go forward?"
...> ) |> E.puts()
CONSENSUS: :revise (3 of 3 agreed via majority, round 1)

claude [REVISE]:
At 500 RPS with 2KB payloads and 30min TTL...
...
```

### Extracting the decision as a pipeable atom

```elixir
iex> summary =
...>   E.start_link(
...>     name: "scratch",
...>     strategy: GenAgentEnsemble.Strategies.Consensus,
...>     opts: [
...>       agents: [...],
...>       verdict_parser: &DecisionParser.parse/1,
...>       reply: {:synthesize, &Function.identity/1}
...>     ]
...>   )
iex> {:ok, %{text: summary}} = E.ask("scratch", "...")
iex> summary.verdict
:approve
iex> summary.status
:converged
```

Note: `{:synthesize, fn}` returns whatever the fn returns from
the ensemble as `response.text`. When the fn returns a map (as
with `&Function.identity/1`), the "text" field holds that map,
which is a minor abuse of the `%Response{}` shape but handy for
programmatic use.

### Async

```elixir
iex> {:ok, tok} = E.tell("arch-review", proposal)
iex> E.status("arch-review")
{:ok, %{phase: %{round: 1, responded: 2, expected: 3}, ...}}
iex> E.await("arch-review", tok, 600_000) |> E.puts()
```

## Variations

- **Heterogeneous backends.** The value proposition: different
  models bring different biases. Run Claude Sonnet + GPT-5-mini +
  Haiku on the same proposal and the disagreements surface risks
  a single model would have hidden.
- **Asymmetric roles.** Give each agent a different system prompt
  -- "you review for correctness", "you review for performance",
  "you review for operational risk" -- and Consensus turns into
  a multi-perspective review panel.
- **Custom verdict space.** The parser owns the atom space. You
  can use `:yes | :no`, `:ship | :hold | :kill`,
  `:red | :yellow | :green`, etc. Anything the models can be
  taught to emit.
- **Strict vs loose convergence.** `:unanimous` is strict (useful
  for high-stakes gates); `{:at_least, 2}` with N=4 is loose
  (useful for "two seconds" style reviews).

## Gotchas

- **The system prompt does the teaching.** The verdict parser
  only detects the format; the agents have to *produce* it. Be
  explicit in the system prompt about the exact format ("End with
  VERDICT: X") or unparseable responses proliferate and you burn
  rounds for no reason.
- **Abstains don't block convergence.** If one agent's response
  is unparseable, the remaining agents can still hit the threshold.
  That's by design -- one malformed response shouldn't tank the
  panel. But watch for a systemic parse-rate problem via `status`
  or logs.
- **Re-prompts are pure text.** The strategy inserts the others'
  rationales into each agent's next prompt. The agent's own
  previous response stays in its backend's conversation memory.
  This means each round's re-prompt is heavy -- expect larger
  token counts on round 2+ than round 1.
- **One consensus at a time.** Concurrent `tell`/`ask` calls
  queue. For parallel review panels, start multiple Consensus
  ensembles.
- **Agent death halts the session.** The panel size is baked in
  at init; a dead agent invalidates the threshold arithmetic.
  If an agent's backend (e.g. CLI subprocess) dies, restart the
  whole ensemble rather than expecting a partial panel.
- **Heterogeneous backends == heterogeneous timeouts.** Sonnet
  responses through `gen_agent_anthropic` need `:receive_timeout:
  180_000` to reliably complete long-context rounds. OpenAI's
  gpt-5-mini is a reasoning model and can burn output tokens on
  hidden reasoning -- set `:max_output_tokens` high enough to
  leave room for the actual message.