Post

Resilience Patterns for Multi-Agent Systems — Handling Failure Gracefully

Multi-agent systems fail in more ways than single-agent systems — specialist timeouts, partial results, cascading failures, and context overflow each need different handling patterns.

Resilience Patterns for Multi-Agent Systems — Handling Failure Gracefully

A single-agent system has one failure mode that matters: the agent fails. A multi-agent system has at least six distinct failure modes, and they compose with each other. An agent timeout can cause a partial result, which can cause a downstream agent to hallucinate when asked to analyze incomplete data, which produces a wrong final answer that looks like a success. Understanding the failure taxonomy is the prerequisite for building resilience.

flowchart TD
    O([Orchestrator]) --> SA([Specialist A])
    O --> SB([Specialist B])
    O --> SC([Specialist C])

    SA -->|success| AGG([Aggregate])
    SB -->|timeout| CB{Circuit Breaker}
    CB -->|open: use fallback| FB([Fallback Agent])
    CB -->|closed: retry| SB
    SC -->|error| ERR{Error Type}
    ERR -->|retryable| RT[Retry w/ backoff]
    ERR -->|permanent| PR[Partial Result Strategy]

    FB --> AGG
    RT --> AGG
    PR --> AGG
    AGG -->|context overflow| SUM[Streaming Summarize]
    SUM --> RESP([Final Response])

The Failure Taxonomy

Agent timeout. The specialist doesn’t respond within its declared SLA. The orchestrator is now blocked on a call that may never return. Without an explicit timeout, the orchestrator hangs indefinitely and the user session times out.

Agent error. The specialist returns an error response. The critical question: is this transient (retry may succeed) or permanent (retry will not help)? UPSTREAM_TIMEOUT is transient. INSUFFICIENT_PERMISSIONS is permanent. Your error contract, covered in the contracts post, defines these categories. If your specialists return generic HTTP 500s instead of typed error codes, you cannot make this distinction.

Partial results. Four of five specialists succeeded. One timed out. The orchestrator must now decide: use the partial results and note the gap, retry the failed specialist asynchronously, abort and retry the whole workflow, or escalate to a human. The right choice depends on whether the missing specialist’s output is load-bearing for the final answer.

Cascading failure. Specialist A’s bad output becomes Specialist B’s bad input. B’s downstream errors look like B’s fault when the root cause is A. In pipeline topologies this propagation is especially dangerous because each stage amplifies the error rather than catching it.

Context overflow. The orchestrator accumulates results from specialists and its context grows too large. Quality degrades. In GPT-4-class models, the last few results in a very long context receive less attention than earlier results. The orchestrator may “forget” that Specialist A said X by the time it assembles the final response.

Model degradation. The specialist is running but producing lower-quality outputs — maybe because the model is under load, or because the input is subtly out-of-distribution. This is the hardest failure mode to detect because there is no error signal. The only way to catch it is LLM-as-judge evaluation on a sample of specialist outputs, or human review on a sample.

Circuit Breaker for Agent Calls

The circuit breaker pattern prevents your orchestrator from repeatedly calling a failing specialist, wasting tokens and time on calls that won’t succeed.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import time
from dataclasses import dataclass, field
from enum import Enum
from typing import Callable, Any


class CircuitState(Enum):
    CLOSED = "closed"      # Normal operation
    OPEN = "open"          # Failing — reject calls immediately
    HALF_OPEN = "half_open"  # Probing — allow one call through


@dataclass
class CircuitBreaker:
    agent_id: str
    failure_threshold: int = 5        # Open after N consecutive failures
    recovery_timeout: int = 60        # Seconds before trying again (half-open)
    state: CircuitState = CircuitState.CLOSED
    failure_count: int = 0
    last_failure_time: float = field(default_factory=lambda: 0.0)

    def call(self, fn: Callable, *args, **kwargs) -> Any:
        if self.state == CircuitState.OPEN:
            if time.time() - self.last_failure_time >= self.recovery_timeout:
                self.state = CircuitState.HALF_OPEN
            else:
                raise CircuitOpenError(
                    f"Circuit open for {self.agent_id}. "
                    f"Retry in {int(self.recovery_timeout - (time.time() - self.last_failure_time))}s"
                )

        try:
            result = fn(*args, **kwargs)
            self._on_success()
            return result
        except Exception as e:
            self._on_failure()
            raise

    def _on_success(self):
        self.failure_count = 0
        self.state = CircuitState.CLOSED

    def _on_failure(self):
        self.failure_count += 1
        self.last_failure_time = time.time()
        if self.failure_count >= self.failure_threshold or self.state == CircuitState.HALF_OPEN:
            self.state = CircuitState.OPEN


class CircuitOpenError(Exception):
    pass


# Usage in orchestrator
circuit_breakers: dict[str, CircuitBreaker] = {}


def get_circuit_breaker(agent_id: str) -> CircuitBreaker:
    if agent_id not in circuit_breakers:
        circuit_breakers[agent_id] = CircuitBreaker(agent_id=agent_id)
    return circuit_breakers[agent_id]


async def call_specialist_with_circuit_breaker(agent_id: str, payload: dict) -> dict:
    breaker = get_circuit_breaker(agent_id)
    try:
        return await breaker.call(call_specialist_api, agent_id, payload)
    except CircuitOpenError:
        return await call_fallback_agent(agent_id, payload)

Keep circuit breakers in shared storage (Redis) if you run multiple orchestrator instances. A circuit that’s open on one instance should be open on all of them.

Retry with Backoff — And Its Limits

Retry is safe only for idempotent operations. Read-only tool calls are idempotent. Analysis tasks are idempotent. Sending an email is not. Creating a record is not. Charging a payment card is not.

Before adding retry to any specialist call, check the idempotency field in that agent’s contract. If idempotent: false, do not retry. Duplicate actions at the specialist level are worse than a visible failure at the orchestrator level.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import asyncio
import random


async def retry_with_backoff(
    fn: Callable,
    args: tuple = (),
    kwargs: dict = None,
    max_retries: int = 3,
    base_delay: float = 1.0,
    max_delay: float = 30.0,
    retryable_errors: tuple = (TimeoutError, TransientAgentError),
) -> Any:
    kwargs = kwargs or {}
    last_exception = None

    for attempt in range(max_retries + 1):
        try:
            return await fn(*args, **kwargs)
        except retryable_errors as e:
            last_exception = e
            if attempt == max_retries:
                break
            # Exponential backoff with jitter to prevent thundering herd
            delay = min(base_delay * (2 ** attempt) + random.uniform(0, 1), max_delay)
            await asyncio.sleep(delay)
        except Exception:
            raise  # Non-retryable errors propagate immediately

    raise last_exception

Cap retries at 3 for agent calls. More than 3 retries on a specialist call means the orchestrator is waiting 30+ seconds while burning tokens on failed requests. It’s better to surface the failure cleanly and let the orchestrator use its fallback or partial-result strategy.

Fallback Agents

When the primary specialist fails, route to a fallback. The fallback is typically simpler — fewer tools, lower capability, but higher reliability.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
SPECIALIST_FALLBACKS = {
    "com.yourco.finance.invoice-processor-v2": "com.yourco.finance.invoice-processor-basic",
    "com.yourco.research.deep-analyst": "com.yourco.research.surface-analyst",
}


async def call_with_fallback(agent_id: str, payload: dict) -> dict:
    try:
        return await call_specialist_with_circuit_breaker(agent_id, payload)
    except (CircuitOpenError, MaxRetriesExceeded) as e:
        fallback_id = SPECIALIST_FALLBACKS.get(agent_id)
        if fallback_id is None:
            raise

        result = await call_specialist_api(fallback_id, payload)
        result["_fallback_used"] = True
        result["_fallback_reason"] = str(e)
        return result

Always tag fallback results so the orchestrator knows it’s assembling a response with degraded specialist coverage. The final response to the user should acknowledge the limitation explicitly — “I was unable to access the full invoice analysis; the summary below is based on available information.” Hiding fallback usage from the user is a transparency problem.

Partial Result Strategies

When some specialists succeed and others don’t, you have four options. Which one is correct depends on whether the missing result is essential or supplementary.

Missing specialist roleRecommended strategy
Essential (output gates the final answer)Abort, return error, don’t partially assemble
Supplementary (output enriches but isn’t required)Use partial results, note the gap in the response
Can be retried asynchronouslyUse partial results now, enqueue retry, update response later
Human judgment requiredEscalate to human review queue

Document which specialists are essential versus supplementary in your orchestrator’s routing configuration, not in ad-hoc code. This makes the dependency visible.

Context Overflow Mitigation

As an orchestrator accumulates specialist results, it should compress older results rather than appending indefinitely.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
async def assemble_with_compression(specialist_results: list[dict], max_tokens: int = 4000) -> str:
    assembled = []
    total_tokens = 0

    for result in specialist_results:
        result_text = format_specialist_result(result)
        result_tokens = count_tokens(result_text)

        if total_tokens + result_tokens > max_tokens:
            # Summarize what we have so far before adding more
            summary = await summarize_context(assembled)
            assembled = [summary]
            total_tokens = count_tokens(summary)

        assembled.append(result_text)
        total_tokens += result_tokens

    return "\n\n".join(assembled)

The simpler alternative: extract only the fields you need from each specialist result. If the invoice processor returns 40 fields but the orchestrator only needs status, total, and approval_queue_id, extract those three and discard the rest before adding to context.

Chaos Engineering for Agent Systems

Resilience patterns that haven’t been tested under real failure conditions are hypotheses, not guarantees. In staging, deliberately inject failures.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import random


class ChaosSpecialistProxy:
    """Wraps a specialist call with configurable failure injection."""

    def __init__(self, specialist_fn, failure_rate: float = 0.2, chaos_enabled: bool = False):
        self.specialist_fn = specialist_fn
        self.failure_rate = failure_rate
        self.chaos_enabled = chaos_enabled

    async def call(self, payload: dict) -> dict:
        if self.chaos_enabled and random.random() < self.failure_rate:
            failure_type = random.choice(["timeout", "error", "malformed_response"])
            if failure_type == "timeout":
                raise TimeoutError("Chaos: injected timeout")
            elif failure_type == "error":
                raise TransientAgentError("Chaos: injected transient error")
            else:
                return {"_chaos": "malformed", "unexpected_field": True}  # Contract violation

        return await self.specialist_fn(payload)

Run chaos injection on every deploy to staging, not just when you make changes to the orchestrator. A specialist team’s deployment that changes latency characteristics can silently break the orchestrator’s timeout assumptions — you want to catch that before it reaches production.

This post is licensed under CC BY 4.0 by the author.