← Notes

2026-07-18 · 9 min read

Multi-Agent Orchestration: Choreography, Circuit Breakers, and the Coordination Problem Nobody Designed For

Picture this. You wire up three agents to handle a research task: a Researcher agent pulls sources, a Writer agent drafts a summary from those sources, and a Fact-Checker agent verifies the draft against the original material before it ships. In your demo, it’s magic. Three agents, one clean pipeline, a polished report out the other end.

Then in production, the Researcher times out on a slow API call. It doesn’t crash, it just returns an empty result. The Writer, having no concept that “empty” means “something went wrong upstream,” cheerfully drafts a summary out of nothing but the task description and its own priors. The Fact-Checker checks the draft against an empty source set, finds no contradictions (there’s nothing to contradict), and approves it. You ship a confident, fluent, entirely fabricated report to a customer.

Nobody’s prompt was bad. Every agent did exactly what it was told. The system still failed, because nobody designed for what happens when one node in a pipeline degrades instead of dying cleanly.

This Is Not a New Problem

I said the same thing in the last post about a single agent losing its place mid-task: strip away the LLM and you’re looking at a distributed system. With multiple agents, the resemblance stops being a metaphor and becomes literal. You have independent processes, each with its own state and failure modes, communicating over unreliable channels, trying to make forward progress on a shared goal. That’s a distributed system by definition, whether or not any of the nodes happen to call themselves “agents.”

Which means the two decades of coordination patterns from microservices apply directly. The main one you have to pick first: orchestration vs. choreography.

flowchart TB
    subgraph Orchestration
        direction TB
        O[Orchestrator Agent] -->|1. dispatch| R1[Researcher]
        R1 -->|result| O
        O -->|2. dispatch| W1[Writer]
        W1 -->|result| O
        O -->|3. dispatch| F1[Fact-Checker]
        F1 -->|result| O
        O -->|final output| OUT1([Report])
    end

    subgraph Choreography
        direction TB
        R2[Researcher] -->|event: sources.ready| BUS[/Event Bus/]
        BUS -->|triggers| W2[Writer]
        W2 -->|event: draft.ready| BUS
        BUS -->|triggers| F2[Fact-Checker]
        F2 -->|event: report.verified| BUS
        BUS -->|triggers| OUT2([Report])
    end

    style O fill:#4f46e5,color:#fff
    style BUS fill:#f59e0b,color:#000
    style OUT1 fill:#10b981,color:#fff
    style OUT2 fill:#10b981,color:#fff

Orchestration puts one agent (or plain code, which is usually the better call) in charge. It calls the Researcher, waits, validates the result, calls the Writer, waits, validates, calls the Fact-Checker. Every decision about what happens next lives in one place. When the Researcher comes back empty, the orchestrator is the one piece of code positioned to notice and stop the pipeline right there.

Choreography has no central brain. Each agent listens for events and reacts. The Researcher finishes and emits sources.ready. The Writer is subscribed to that event and wakes up. Nobody is watching the whole pipeline, because there is no “whole pipeline” from any single agent’s point of view, just a series of local reactions to messages.

Both are legitimate. But for agent systems specifically, choreography has a sharp edge that it doesn’t have for microservices: microservices fail loudly (5xx, timeout, exception). Agents fail quietly (a plausible-sounding wrong answer). A choreographed system has no natural place to catch “the output looks fine but is nonsense,” because no single component ever sees the whole picture. That’s exactly what happened in the empty-Researcher scenario above. An orchestrator that explicitly validated “did the Researcher actually return sources?” before calling the Writer would have caught it in one line.

Default to orchestration until you have a specific reason not to. Choreography earns its keep when agents are genuinely independent and loosely coupled, think a swarm of monitoring agents each watching a different system and firing alerts. For a pipeline where each step depends on the last one’s output being correct, not just present, put a human-legible chokepoint in the middle.

Building the Orchestrator

Here’s the orchestrator for the research pipeline, written as plain code, not another agent. The point of an orchestrator is to be the boring, deterministic, fully-testable part of the system.

# orchestrator/research_pipeline.py

from dataclasses import dataclass
from enum import Enum, auto


class StepStatus(Enum):
    OK = auto()
    EMPTY = auto()
    FAILED = auto()


@dataclass
class StepResult:
    status: StepStatus
    data: dict | None
    error: str | None = None


class PipelineAbortedError(Exception):
    """Raised when a step's output can't be trusted enough to continue."""


def run_research_pipeline(task: str) -> dict:
    sources = call_agent_with_guard(
        agent=researcher_agent,
        input={"task": task},
        validate=lambda r: bool(r.get("sources")),
        step_name="research",
    )

    draft = call_agent_with_guard(
        agent=writer_agent,
        input={"task": task, "sources": sources["sources"]},
        validate=lambda r: bool(r.get("draft")),
        step_name="draft",
    )

    verified = call_agent_with_guard(
        agent=fact_checker_agent,
        input={"draft": draft["draft"], "sources": sources["sources"]},
        validate=lambda r: "verdict" in r,
        step_name="verify",
    )

    if verified["verdict"] != "confirmed":
        raise PipelineAbortedError(
            f"Fact-checker rejected draft: {verified.get('reason')}"
        )

    return {"report": draft["draft"], "sources": sources["sources"]}


def call_agent_with_guard(agent, input: dict, validate, step_name: str) -> dict:
    result = agent.run(input)

    if not validate(result):
        # This is the line that would have caught the empty-Researcher bug.
        # The pipeline stops here instead of feeding garbage downstream.
        raise PipelineAbortedError(
            f"Step '{step_name}' returned an untrustworthy result: {result}"
        )

    return result

Notice what’s absent: no step calls the next step directly. The Writer agent has no idea the Fact-Checker exists. Every handoff passes through call_agent_with_guard, which is the one place in the system responsible for deciding whether a result is good enough to build on. That’s the whole value of orchestration in one function. It’s not about control, it’s about having a single seam where you can enforce “garbage in, no shipping out.”

Agent-Level Circuit Breakers

Validation catches bad results. It doesn’t help when an agent is bad on average right now, say, the model provider behind your Fact-Checker is degraded and returning malformed JSON on 80% of calls. Retrying each call individually just burns tokens and time while the underlying problem persists. This is precisely the failure mode circuit breakers were invented for in the Netflix Hystrix era, and it ports over unchanged.

stateDiagram-v2
    [*] --> Closed
    Closed --> Open: failure rate > threshold
    Open --> HalfOpen: cooldown elapsed
    HalfOpen --> Closed: trial call succeeds
    HalfOpen --> Open: trial call fails

    note right of Closed
        Calls pass through normally.
        Failures are counted.
    end note

    note right of Open
        Calls fail fast, no agent invocation.
        Fallback path used instead.
    end note

    note right of HalfOpen
        One trial call allowed through
        to test if the agent recovered.
    end note
# orchestrator/circuit_breaker.py

import time
from dataclasses import dataclass, field


@dataclass
class CircuitBreaker:
    failure_threshold: int = 5
    cooldown_seconds: int = 30

    _failures: int = field(default=0, init=False)
    _state: str = field(default="closed", init=False)
    _opened_at: float = field(default=0.0, init=False)

    def call(self, fn, *args, **kwargs):
        if self._state == "open":
            if time.monotonic() - self._opened_at < self.cooldown_seconds:
                raise AgentUnavailableError("Circuit open, refusing to call agent")
            self._state = "half_open"

        try:
            result = fn(*args, **kwargs)
        except AgentCallError:
            self._on_failure()
            raise
        else:
            self._on_success()
            return result

    def _on_failure(self):
        self._failures += 1
        if self._state == "half_open" or self._failures >= self.failure_threshold:
            self._state = "open"
            self._opened_at = time.monotonic()

    def _on_success(self):
        self._failures = 0
        self._state = "closed"


class AgentUnavailableError(Exception):
    pass


class AgentCallError(Exception):
    pass

Wrap each agent’s call site in one of these, tracked separately per agent. When the Fact-Checker’s breaker trips, the orchestrator has an explicit decision to make: fall back to a cheaper deterministic check, queue the report for human review, or fail the whole pipeline outright. All three are better than hammering a degraded model 400 times an hour and hoping.

The Shared State Trap

The other classic distributed-systems failure that shows up almost immediately in multi-agent systems: agents stepping on each other’s state. It’s tempting to give every agent read/write access to one shared context object, “the task state,” so nobody has to explicitly pass data around. This is the agent-system equivalent of every microservice writing directly to the same database table, and it goes wrong the same way.

sequenceDiagram
    participant W as Writer Agent
    participant S as Shared Task State
    participant F as Fact-Checker Agent

    W->>S: read draft_v1
    F->>S: read draft_v1
    Note over W,F: Both agents working from the same version
    W->>S: write draft_v2 (revised wording)
    F->>S: write verified=true (against draft_v1!)
    Note over S: State now says "verified" for a<br/>draft that no longer exists

The Fact-Checker verified draft_v1, but by the time it wrote its verdict, the Writer had already overwritten the state with draft_v2. The system now confidently reports a verified draft that was never actually checked. Nobody’s code was buggy in isolation, this is a plain lost-update race condition, the same one that motivated optimistic locking and version columns in every database that’s ever shipped.

The fix is the same fix: agents don’t share mutable state, they exchange immutable messages.

# Each agent receives an immutable snapshot and returns a new one.
# Nobody mutates shared state; the orchestrator owns the sequence.

@dataclass(frozen=True)
class TaskState:
    version: int
    sources: tuple[str, ...]
    draft: str | None = None
    verdict: str | None = None
    verified_draft_version: int | None = None


def writer_step(state: TaskState) -> TaskState:
    new_draft = writer_agent.run(sources=state.sources)
    return dataclasses.replace(state, version=state.version + 1, draft=new_draft)


def fact_checker_step(state: TaskState) -> TaskState:
    verdict = fact_checker_agent.run(draft=state.draft, sources=state.sources)
    return dataclasses.replace(
        state,
        verdict=verdict,
        verified_draft_version=state.version,  # pin the exact version checked
    )

verified_draft_version is the whole fix. If any later step tries to ship a draft whose version doesn’t match what was actually verified, that’s a one-line check, not a debugging session six weeks from now trying to figure out why a “verified” report contained an unverified claim.

Tradeoffs

ConsiderationOrchestrationChoreography
Failure visibilityCentralized, easy to log and alert onDiffuse, needs distributed tracing to reconstruct
CouplingOrchestrator knows about every agentAgents only know event schemas
Adding a new stepEdit the orchestratorPublish a new event, subscribe to it
Catching “quietly wrong” outputNatural, one chokepoint per handoffHard, no component sees the full picture
Scales to how many agentsComfortable up to a dozen or soBetter for large, loosely related agent fleets
Operational modelOne thing to reason aboutDistributed system, full stop

Start with orchestration. It’s more code up front, but it’s the code that lets you sleep at night, because the one place a bad handoff can hide is the one place you’re already forcing every result to pass through a validator. Reach for choreography only once you have enough independent agents that a central orchestrator would itself become the bottleneck, and by then you’ll also need the observability tooling to make choreography debuggable in the first place.

What Comes Next

This covers getting a fixed pipeline of agents to hand off work correctly. It doesn’t cover how you’d know if the Fact-Checker started silently getting worse at its job, that’s an evaluation and observability problem, and it’s a different post. Nor does it cover what happens when a human needs to step into the loop mid-pipeline to approve or correct a decision an agent isn’t confident about. Both build directly on the chokepoints this orchestrator already gives you, which is exactly why it was worth building the chokepoints first.