ProductHow it worksPricingBlogDocsLoginFind Your First Bug
A control console routing signals to three relay modules, one cable unplugged and silently dropped, with a routing card and a correlation-ID trace tape, representing multi-agent handoff and orchestration testing
TestingAIMulti-Agent Testing

How to Test Multi-Agent Systems (Handoffs and Orchestration)

Tom Piaggio
Tom PiaggioCo-Founder at Autonoma

Testing a multi-agent system means verifying three things a single agent's tests never check: that every handoff between agents carries the exact context the next agent needs, that the orchestrator delegates each task to the right agent, and that the orchestration layer executes every step in order without silently dropping one. A pipeline can pass every individual agent's test suite and still ship the wrong result, because the bug usually lives in the handoff, not in any one agent.

Every agent in the pipeline passed its own test suite. The planning agent produced a reasonable plan. The research agent returned real, correctly cited sources. The drafting agent wrote clean, readable copy. The final output still answered the wrong question, because the drafting agent never received the one constraint the planner had actually locked in three steps earlier.

That failure is structurally different from anything testing a single agent through simulation covers. A single agent's suite can be green top to bottom. The workflow can still be wrong, because correctness in a multi-agent system is a property of the handoffs between agents, not of any individual agent's output.

Testing a multi-agent system means testing three surfaces that a single agent's suite never touches. The handoff checks whether agent A passes agent B the exact fields B needs, asserted with a message schema at the boundary rather than on whatever B eventually produces. The delegation checks whether the orchestrator routed the task to an agent that can actually do it, asserted on the routing decision itself rather than on the final text. The orchestration checks whether every planned step actually executed, in order, asserted on the full executed step sequence rather than just the output. The sections below cover one surface each, in that order.

What Actually Breaks in a Multi-Agent System

Four failure classes account for almost everything that goes wrong once a pipeline composes more than one agent. None of them show up in a single-agent test, because none of them are about what one agent produced. They're about what happened between agents.

Handoff context loss is the most common one: agent A has the right information and simply doesn't pass all of it forward, so agent B works from an incomplete picture and produces a confident, wrong answer. A planner that decides on a $200 budget cap but only forwards "find a hotel" to the booking agent has lost context just as badly as if it had forwarded the wrong number.

Delegation to the wrong agent is a routing failure: the orchestrator sends a task to an agent that can't actually do it, and that agent attempts it anyway instead of failing loudly.

Orchestration-layer silent drops are the quietest failure of the four: a step in the plan never executes, and nothing downstream notices, because nothing was built to expect it. The silent failures that make agents unreliable in production are this same pattern one level up: nothing crashes, nothing errors, the run just quietly does less than it was supposed to. A four-step plan that runs three steps and returns a plausible-looking answer will pass a test that only checks the final output, every time.

Cascading error amplification is the compounding one: a small mistake early in the chain, a slightly wrong date, a misread field, gets treated as ground truth by every agent downstream and grows instead of getting caught, the same way a single transposed digit in step one becomes a completely wrong itinerary by step four.

None of these four are exotic: they're the default failure mode of composing agents, hand-rolled or framework-run.

Notice what they share. Not one of them is a language failure, and not one is detectable by reading the final output, which is the only artifact most AI evaluation tooling ever looks at. They're integration bugs between components that happen to be probabilistic, and integration bugs have always been caught by asserting on what the system did rather than on what it returned. That's the position Autonoma takes on AI features generally, and it's the throughline of the three surfaces below: every assertion targets an observable decision or effect, never a sentence.

Failure ClassObservable SymptomWhat to Assert
Handoff context lossDownstream agent asks again, or guessesFull payload schema at the boundary
Wrong delegationRight-sounding output, wrong agent usedRouting decision, not final text
Orchestration silent dropStep missing from execution logFull executed step sequence
Cascading amplificationSmall early error, large final errorIntermediate values, not just output

Testing Agent Handoffs

Testing agent handoffs and testing agent-to-agent communication are the same job, and they reduce to one rule: agent A has to pass agent B the fields B actually needs, not just the fields A felt like sending. That means the handoff has a schema, whether or not anyone's written it down yet, and the schema is the thing you test.

Handoff and delegation graph with assertion pointsOrchestrator Routing to Three Specialist AgentsOrchestratorASSERT: routing decisionASSERT: handoff payloadResearch AgentASSERT: handoff payloadDrafting AgentASSERT: handoff payloadFact-Check AgentTwo separate things can fail here, and they need two separate assertions.The orchestrator can route to the wrong agent, or the right agent can receive an incomplete payload.Testing only the final output catches neither until a user does.

Every edge in a multi-agent graph is a place a handoff can lose context. Every node the orchestrator picks is a place delegation can go to the wrong agent.

The pattern is a handoff contract: define the shape of every inter-agent message with something like pydantic, then assert on that shape at the boundary, not on whatever the receiving agent eventually does with the message. A payload missing a required field should fail the handoff test immediately, before it ever reaches the next agent and produces a plausible-looking wrong answer three steps later.

Here's the handoff-contract pattern: a typed message schema, and a test that asserts the orchestrator's actual output against it at the boundary, not downstream.

"""Surface 1: the handoff contract.

An orchestrator hands work to a specialist agent as a message. That message is
an API even though nobody wrote an OpenAPI spec for it. This test pins the
shape of that message so a prompt edit upstream cannot silently change what
downstream agents receive.

The orchestrator here is a deterministic fake. No LLM calls, no API keys.

Run with:
    pytest tests/test_handoff_contract.py
"""

from __future__ import annotations

import pytest
from pydantic import BaseModel, ConfigDict, Field, ValidationError


# ---------------------------------------------------------------------------
# The contract
# ---------------------------------------------------------------------------


class HandoffConstraints(BaseModel):
    """Limits the orchestrator imposes on the specialist agent.

    These are the fields that get quietly dropped when someone rewrites the
    orchestrator prompt. Making them required is the whole point.
    """

    model_config = ConfigDict(extra="forbid")

    max_tool_calls: int = Field(ge=1, le=100)
    timeout_seconds: float = Field(gt=0, le=600)
    allowed_tools: list[str] = Field(min_length=1)


class HandoffContext(BaseModel):
    """Everything the specialist needs that it cannot rediscover on its own."""

    model_config = ConfigDict(extra="forbid")

    correlation_id: str = Field(min_length=1)
    user_goal: str = Field(min_length=1)
    prior_findings: list[str] = Field(default_factory=list)


class HandoffMessage(BaseModel):
    """The message an orchestrator sends a specialist agent.

    `extra="forbid"` is deliberate: an agent that invents a new top-level field
    is a contract break, not a harmless addition. You want that to fail loudly
    in CI rather than get ignored by the recipient at runtime.
    """

    model_config = ConfigDict(extra="forbid")

    task_id: str = Field(min_length=1)
    recipient: str = Field(min_length=1)
    constraints: HandoffConstraints
    context: HandoffContext


# ---------------------------------------------------------------------------
# A deterministic fake orchestrator
# ---------------------------------------------------------------------------

ALLOWED_TOOLS_BY_AGENT: dict[str, list[str]] = {
    "researcher": ["web_search", "read_url"],
    "coder": ["read_file", "write_file", "run_tests"],
    "reviewer": ["read_file", "run_tests"],
}


def build_handoff(task_id: str, recipient: str, user_goal: str) -> dict:
    """Stand-in for the payload a real orchestrator would emit.

    In your codebase this is the function that serializes an LLM's delegation
    decision into a message. Swap the body, keep the test.
    """
    return {
        "task_id": task_id,
        "recipient": recipient,
        "constraints": {
            "max_tool_calls": 8,
            "timeout_seconds": 45.0,
            "allowed_tools": ALLOWED_TOOLS_BY_AGENT[recipient],
        },
        "context": {
            "correlation_id": f"corr-{task_id}",
            "user_goal": user_goal,
            "prior_findings": [],
        },
    }


# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------


def test_orchestrator_payload_validates_against_the_contract():
    payload = build_handoff("t-001", "coder", "Add a retry to the upload path")

    message = HandoffMessage.model_validate(payload)

    assert message.task_id == "t-001"
    assert message.recipient == "coder"
    assert message.constraints.max_tool_calls == 8
    assert message.context.correlation_id == "corr-t-001"
    assert "run_tests" in message.constraints.allowed_tools


@pytest.mark.parametrize("recipient", sorted(ALLOWED_TOOLS_BY_AGENT))
def test_every_recipient_gets_a_valid_handoff(recipient: str):
    """One broken recipient branch should not hide behind a passing default."""
    payload = build_handoff("t-002", recipient, "Investigate the failing job")

    message = HandoffMessage.model_validate(payload)

    assert message.recipient == recipient
    assert message.constraints.allowed_tools == ALLOWED_TOOLS_BY_AGENT[recipient]


def test_missing_required_constraint_is_rejected():
    """The regression this test exists for: a dropped timeout.

    Without the contract, the specialist agent inherits some default timeout
    and the bug shows up three weeks later as an unexplained cost spike.
    """
    payload = build_handoff("t-003", "researcher", "Summarize the incident")
    del payload["constraints"]["timeout_seconds"]

    with pytest.raises(ValidationError) as excinfo:
        HandoffMessage.model_validate(payload)

    errors = excinfo.value.errors()
    assert [e["loc"] for e in errors] == [("constraints", "timeout_seconds")]
    assert errors[0]["type"] == "missing"


def test_out_of_range_constraint_is_rejected():
    """A budget of zero tool calls is a bug, not a strict budget."""
    payload = build_handoff("t-004", "coder", "Fix the flaky test")
    payload["constraints"]["max_tool_calls"] = 0

    with pytest.raises(ValidationError) as excinfo:
        HandoffMessage.model_validate(payload)

    assert excinfo.value.errors()[0]["loc"] == ("constraints", "max_tool_calls")


def test_unexpected_field_is_rejected():
    """An agent inventing `priority` is a contract break, not a feature."""
    payload = build_handoff("t-005", "reviewer", "Check the migration")
    payload["priority"] = "urgent"

    with pytest.raises(ValidationError) as excinfo:
        HandoffMessage.model_validate(payload)

    assert excinfo.value.errors()[0]["type"] == "extra_forbidden"

Testing Delegation Correctness

Multi-agent system QA usually comes down to one question in practice: did the orchestrator send the task to the right agent? That's a routing decision, worth testing separately from whether the chosen agent then did a good job. A router that occasionally sends a billing question to the scheduling agent is broken even on the runs where the scheduling agent happens to bounce it back correctly.

This is also where non-determinism has to be handled honestly, not waved away. An LLM-driven orchestrator won't route identically every time, and it doesn't need to. Assert on the routing decision's structure and constraints, did it pick an agent that's actually capable of this task, did it stay inside the allowed set, rather than on a single expected literal path. For genuinely stochastic routing, run the same input N times and assert on the majority outcome instead of a single pass or fail, the same approach that works for testing non-deterministic AI outputs generally. If you're building this specifically on CrewAI, framework-native evaluation patterns cover the orchestrator's built-in hooks in more depth; the assertions here apply whether you're on CrewAI, AutoGen, LangGraph, or something hand-rolled.

The failure mode worth watching for is silent misroute: the wrong agent gets picked, but it's competent enough to produce something plausible, so the output doesn't look broken. A refund request routed to general support instead of billing might still get a polite, well-written reply; it just won't process the refund. Only a routing-level assertion catches that, a text-quality check on the reply never will.

There's a second assertion worth pairing with it, and it's the one that survives a refactor. A routing test proves the orchestrator picked the billing agent. It doesn't prove a refund was issued. Those come apart the moment the billing agent's own tool changes, and when they do the routing test stays green while the customer stays un-refunded. Asserting on the outcome inside the product, the refund record, the ledger entry, the email that went out, is what Autonoma covers: behavioral end-to-end tests against the running application, checking the effect the whole pipeline was supposed to produce rather than any single hop within it.

If a delegation test flakes, treat it as a signal, not noise. Either the assertion is too strict for what's actually invariant about the routing, or the handoff contract feeding the router is underspecified and the router is making a reasonable decision on bad input.
"""Surface 2: the delegation decision.

The orchestrator picks which specialist handles a task. That decision is the
single highest-leverage thing to test, because a wrong route means every
downstream assertion is measuring the wrong agent.

Two kinds of assertion live here:

1. Deterministic routes. Same input, same agent, every time. Assert exactly.
2. Stochastic routes. Ambiguous input where a real model would waver. Assert on
   the majority outcome over N runs, not on a single pass/fail, otherwise the
   test is a coin flip you have wired into CI.

Run with:
    pytest tests/test_delegation_routing.py
"""

from __future__ import annotations

import random
from collections import Counter

import pytest

# The allowed set. A route outside this set is a hallucinated agent name, which
# is a different and worse bug than a merely suboptimal route.
CANDIDATE_AGENTS = frozenset({"researcher", "coder", "reviewer", "summarizer"})

# Weighted tie-break for genuinely ambiguous work. A real orchestrator gets this
# spread from model sampling; here it is an explicit distribution so the test
# runs offline and the intent is visible.
AMBIGUOUS_SPLIT: dict[str, float] = {"coder": 0.8, "reviewer": 0.2}


def route(task: str, rng: random.Random) -> str:
    """Deterministic fake of an orchestrator's routing step.

    Replace the body with a call to your real router. The tests below do not
    care how the decision is made, only that it lands on a known agent.
    """
    text = task.lower()

    if any(word in text for word in ("implement", "refactor", "fix the bug")):
        return "coder"
    if any(word in text for word in ("research", "look up", "find out")):
        return "researcher"
    if any(word in text for word in ("summarize", "tl;dr", "recap")):
        return "summarizer"
    if any(word in text for word in ("review", "audit", "critique")):
        return "reviewer"

    population = list(AMBIGUOUS_SPLIT)
    weights = [AMBIGUOUS_SPLIT[name] for name in population]
    return rng.choices(population, weights=weights, k=1)[0]


def majority_route(task: str, trials: int = 101, seed: int = 1337) -> tuple[str, float]:
    """Run the same input N times and return (winning agent, its share).

    This is the assertion shape you want for any non-deterministic step. A
    single call tells you nothing; the distribution tells you whether the
    router actually has an opinion.
    """
    if trials < 1:
        raise ValueError("trials must be >= 1")

    rng = random.Random(seed)
    counts = Counter(route(task, rng) for _ in range(trials))
    winner, hits = counts.most_common(1)[0]

    unknown = set(counts) - CANDIDATE_AGENTS
    if unknown:
        raise AssertionError(f"router produced unknown agents: {sorted(unknown)}")

    return winner, hits / trials


# ---------------------------------------------------------------------------
# Deterministic routes
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    ("task", "expected_agent"),
    [
        ("Implement pagination on the orders endpoint", "coder"),
        ("Refactor the retry helper", "coder"),
        ("Fix the bug in the CSV importer", "coder"),
        ("Research how competitors price seats", "researcher"),
        ("Look up the Postgres lock semantics", "researcher"),
        ("Summarize yesterday's incident thread", "summarizer"),
        ("Review the auth middleware diff", "reviewer"),
        ("Audit the new IAM policy", "reviewer"),
    ],
)
def test_deterministic_routes_pick_the_expected_agent(task: str, expected_agent: str):
    selected = route(task, random.Random(0))

    assert selected == expected_agent
    assert selected in CANDIDATE_AGENTS


@pytest.mark.parametrize("seed", [0, 1, 2, 7, 99])
def test_deterministic_routes_ignore_the_rng(seed: int):
    """A route that is supposed to be deterministic must not drift with sampling."""
    assert route("Implement pagination", random.Random(seed)) == "coder"


# ---------------------------------------------------------------------------
# Stochastic route
# ---------------------------------------------------------------------------


def test_ambiguous_task_has_a_stable_majority():
    """"Make this better" is ambiguous. The router should still lean one way."""
    winner, share = majority_route("Make this better")

    assert winner == "coder"
    assert share >= 0.6, f"router has no clear preference (winner share {share:.2f})"


def test_ambiguous_task_never_leaves_the_candidate_set():
    """Even when the router wavers, it may only waver between real agents."""
    rng = random.Random(4242)
    seen = {route("Make this better", rng) for _ in range(500)}

    assert seen <= CANDIDATE_AGENTS
    assert len(seen) > 1, "expected this input to be genuinely ambiguous"


def test_majority_route_rejects_a_nonsense_trial_count():
    with pytest.raises(ValueError):
        majority_route("Make this better", trials=0)

Testing the Orchestration Layer

Multi-agent orchestration testing sits one layer above individual handoffs: did the plan actually execute the way it was supposed to? Four specific things go wrong here. A step gets silently dropped: the plan called for four steps and only three ran, and nothing raised an error because nothing was watching for the missing one. Steps execute out of order, which matters the moment a later step depends on an earlier one's output.

Two agents can also hit a deadlock, each waiting on the other to move first, which looks like a hang rather than a bug. And a retry storm sets in when a flaky step gets retried without a backoff or a cap, so one slow agent quietly multiplies into dozens of duplicate calls before anyone notices the run never actually finished.

The assertion that catches all four is the same one: capture the actual sequence of steps that executed and assert it against the sequence the plan specified, not just the final output. A workflow that produces the right answer by accident, with a step out of order or a step silently skipped, is still a broken workflow. It just hasn't been unlucky yet. Log the step sequence as structured data, not free-text logs meant for a human to read, and the assertion becomes a list comparison instead of a regex against a log file.

"""Surface 3: the executed step sequence.

A plan is what the orchestrator said it would do. The step log is what it
actually did. Multi-agent bugs live in the gap between the two: a step silently
dropped, two steps executed out of order, one step run four times because a
retry had no ceiling.

Every step is logged as structured data (name plus timestamp), never as a free
text line, because you cannot assert on a log line without regexes and you can
assert on a tuple of names in one comparison.

The clock is fake and monotonic, so the log is byte-identical on every run and
in CI. Wall clock timestamps would make these assertions untestable.

Run with:
    pytest tests/test_orchestration_sequence.py
"""

from __future__ import annotations

from collections import Counter
from dataclasses import dataclass, field

import pytest

PLAN: tuple[str, ...] = (
    "parse_request",
    "retrieve_context",
    "draft_answer",
    "review_answer",
    "emit_result",
)


@dataclass(frozen=True)
class StepEvent:
    """One executed step. Structured, so tests compare values not substrings."""

    name: str
    ts: float
    attempt: int = 1


@dataclass
class FakeClock:
    """Monotonic, deterministic, no wall clock anywhere in the suite."""

    now: float = 0.0
    tick: float = 0.5

    def advance(self) -> float:
        self.now = round(self.now + self.tick, 6)
        return self.now


@dataclass
class FakeOrchestrator:
    """Runs a plan and records what it actually executed.

    The `drop`, `swap` and `retries` knobs are how we inject the three failure
    modes on purpose. Your real orchestrator does not need them; it produces
    the same failure modes for free.
    """

    plan: tuple[str, ...] = PLAN
    drop: frozenset[str] = frozenset()
    swap: tuple[str, str] | None = None
    retries: dict[str, int] = field(default_factory=dict)
    clock: FakeClock = field(default_factory=FakeClock)
    log: list[StepEvent] = field(default_factory=list)

    def _ordered_plan(self) -> list[str]:
        steps = list(self.plan)
        if self.swap is not None:
            first, second = self.swap
            i, j = steps.index(first), steps.index(second)
            steps[i], steps[j] = steps[j], steps[i]
        return steps

    def run(self) -> list[StepEvent]:
        self.log.clear()
        for step in self._ordered_plan():
            if step in self.drop:
                continue
            for attempt in range(1, self.retries.get(step, 1) + 1):
                self.log.append(
                    StepEvent(name=step, ts=self.clock.advance(), attempt=attempt)
                )
        return list(self.log)


def step_names(log: list[StepEvent]) -> tuple[str, ...]:
    return tuple(event.name for event in log)


def first_divergence(actual: tuple[str, ...], expected: tuple[str, ...]) -> int | None:
    """Index of the first position where the run left the plan, or None.

    Reporting the index beats reporting "sequences differ" when the plan has
    twenty steps and you are reading a CI log at 2am.
    """
    for index in range(max(len(actual), len(expected))):
        actual_step = actual[index] if index < len(actual) else None
        expected_step = expected[index] if index < len(expected) else None
        if actual_step != expected_step:
            return index
    return None


# ---------------------------------------------------------------------------
# Happy path
# ---------------------------------------------------------------------------


def test_workflow_executes_the_planned_sequence():
    log = FakeOrchestrator().run()

    assert step_names(log) == PLAN
    assert first_divergence(step_names(log), PLAN) is None


def test_step_timestamps_are_monotonic():
    """Out-of-order timestamps mean your trace is lying about causality."""
    log = FakeOrchestrator().run()

    timestamps = [event.ts for event in log]
    assert timestamps == sorted(timestamps)
    assert len(set(timestamps)) == len(timestamps)


# ---------------------------------------------------------------------------
# Failure mode 1: a silently dropped step
# ---------------------------------------------------------------------------


def test_dropped_step_is_caught():
    """The orchestrator skips review and the answer still looks plausible."""
    log = FakeOrchestrator(drop=frozenset({"review_answer"})).run()
    actual = step_names(log)

    assert actual != PLAN
    assert "review_answer" not in actual
    assert first_divergence(actual, PLAN) == PLAN.index("review_answer")


@pytest.mark.parametrize("dropped", PLAN)
def test_dropping_any_single_step_breaks_the_sequence(dropped: str):
    """No step in the plan is quietly optional."""
    actual = step_names(FakeOrchestrator(drop=frozenset({dropped})).run())

    assert actual != PLAN
    assert len(actual) == len(PLAN) - 1


# ---------------------------------------------------------------------------
# Failure mode 2: out-of-order execution
# ---------------------------------------------------------------------------


def test_out_of_order_step_is_caught():
    """Reviewing before drafting: every step ran, the order is nonsense."""
    log = FakeOrchestrator(swap=("draft_answer", "review_answer")).run()
    actual = step_names(log)

    assert sorted(actual) == sorted(PLAN), "same steps, so a set check would pass"
    assert actual != PLAN, "but the order is wrong"
    assert first_divergence(actual, PLAN) == PLAN.index("draft_answer")


# ---------------------------------------------------------------------------
# Failure mode 3: duplication from an unbounded retry
# ---------------------------------------------------------------------------


def test_duplicated_step_from_unbounded_retry_is_caught():
    """retrieve_context runs four times. Nothing errors. The bill triples."""
    log = FakeOrchestrator(retries={"retrieve_context": 4}).run()
    actual = step_names(log)

    counts = Counter(actual)
    assert counts["retrieve_context"] == 4
    assert actual != PLAN

    repeats = {name: n for name, n in counts.items() if n > 1}
    assert repeats == {"retrieve_context": 4}

    attempts = [e.attempt for e in log if e.name == "retrieve_context"]
    assert attempts == [1, 2, 3, 4]


def test_retry_ceiling_of_one_is_the_normal_case():
    log = FakeOrchestrator(retries={"retrieve_context": 1}).run()

    assert step_names(log) == PLAN
    assert all(event.attempt == 1 for event in log)

How to Debug a Multi-Agent Workflow

Debugging a multi-agent workflow starts with one habit worth adopting before you need it: put a correlation ID on every inter-agent message. If you already run distributed tracing, the W3C Trace Context spec is the standard worth reusing here rather than inventing your own ID format. Once a workflow produces a wrong result, the debugging question is never "which agent is broken." It's "where did the context first diverge from what it should have been."

Traced multi-agent message flow, first divergence versus visible failureTrace ID abc-123: Message Log, In OrderMessage 1: Orchestrator to Research Agent, task briefMessage 2: Research to Drafting AgentFIRST DIVERGENT MESSAGE, constraint field droppedMessage 3: Drafting to Fact-Check AgentFact-Check Agent output: flags wrong claimVISIBLE FAILURE, but not the sourcebug traced hereThe agent that visibly fails is usually the first one to notice, not the one that caused it.Bisect the log for the first message where content stops matching expectation.

Fact-Check is where the failure surfaces. Message 2 is where the failure started. A trace ID is what lets you tell the difference.

Capture the full message log for the run, then bisect it: walk the log in order and find the first message where the content no longer matches what the previous step should have produced. That message points at the actual bug. The agent that visibly fails downstream is usually just the first one to notice something was already wrong, not the one that caused it.

A handoff-contract assertion or a routing assertion, on its own, only checks what the agents said to each other. It can't confirm that the workflow's actual effect inside your running product, the record it updated, the email it sent, the state it changed, was the correct one. That in-app behavioral layer is what we built Autonoma for: end-to-end tests that drive the real deployed application in a preview environment on the pull request, so the pipeline's effect gets checked against the product's actual state instead of against the log it wrote about itself.

The two kinds of coverage answer different questions and you want both. Contract and routing tests localize a failure to a specific hop, which is what makes a broken pipeline debuggable in minutes. A behavioral test tells you the pipeline is broken at all, including in the cases where every hop looks individually correct, which is the cascading-amplification failure from earlier in this piece. Skip the first and every debugging session starts from zero. Skip the second and you'll ship a run where all four assertions pass and the customer's refund never happened.

#!/usr/bin/env python3
"""Find the first divergent handoff in a multi-agent message log.

When a five-agent run produces a wrong answer, the useful question is not "was
the output wrong" but "which handoff was the first one to be wrong". Everything
after that point is a downstream symptom and reading it is wasted time.

This walks a message log in order and stops at the first message whose actual
payload no longer matches what the sender was supposed to emit.

Standard library only. No third-party dependencies.

Usage:
    python tools/trace_bisect.py sample_trace.json
    python tools/trace_bisect.py sample_trace.json --json
    python tools/trace_bisect.py sample_trace.json --correlation-id corr-9f2

Exit codes:
    0  no divergence found
    1  divergence found (the interesting case, so CI can gate on it)
    2  bad input (missing file, malformed JSON, malformed message)
"""

from __future__ import annotations

import argparse
import json
import sys
from typing import Any

REQUIRED_FIELDS = ("correlation_id", "sender", "recipient", "payload", "expected_payload")

EXIT_CLEAN = 0
EXIT_DIVERGED = 1
EXIT_BAD_INPUT = 2


class TraceError(Exception):
    """Raised when the trace file cannot be interpreted."""


def load_trace(path: str) -> list[dict[str, Any]]:
    """Read and structurally validate a message log."""
    try:
        with open(path, encoding="utf-8") as handle:
            data = json.load(handle)
    except FileNotFoundError as exc:
        raise TraceError(f"trace file not found: {path}") from exc
    except json.JSONDecodeError as exc:
        raise TraceError(f"{path} is not valid JSON: {exc}") from exc

    if not isinstance(data, list):
        raise TraceError(f"{path} must contain a JSON array of messages")
    if not data:
        raise TraceError(f"{path} contains no messages")

    for index, message in enumerate(data):
        if not isinstance(message, dict):
            raise TraceError(f"message {index} is not an object")
        missing = [name for name in REQUIRED_FIELDS if name not in message]
        if missing:
            raise TraceError(f"message {index} is missing fields: {', '.join(missing)}")

    return data


def diff_payload(actual: Any, expected: Any, prefix: str = "") -> list[str]:
    """Human-readable list of the specific fields that diverged."""
    if isinstance(actual, dict) and isinstance(expected, dict):
        findings: list[str] = []
        for key in sorted(set(actual) | set(expected)):
            path = f"{prefix}.{key}" if prefix else str(key)
            if key not in actual:
                findings.append(f"{path}: missing (expected {expected[key]!r})")
            elif key not in expected:
                findings.append(f"{path}: unexpected field ({actual[key]!r})")
            else:
                findings.extend(diff_payload(actual[key], expected[key], path))
        return findings

    if actual != expected:
        label = prefix or "payload"
        return [f"{label}: got {actual!r}, expected {expected!r}"]
    return []


def find_first_divergence(
    messages: list[dict[str, Any]],
    correlation_id: str | None = None,
) -> dict[str, Any] | None:
    """Walk the log in order; return the first message that diverged."""
    for index, message in enumerate(messages):
        if correlation_id is not None and message["correlation_id"] != correlation_id:
            continue
        if message["payload"] != message["expected_payload"]:
            return {
                "index": index,
                "correlation_id": message["correlation_id"],
                "sender": message["sender"],
                "recipient": message["recipient"],
                "differences": diff_payload(
                    message["payload"], message["expected_payload"]
                ),
                "payload": message["payload"],
                "expected_payload": message["expected_payload"],
            }
    return None


def format_report(result: dict[str, Any] | None, inspected: int) -> str:
    if result is None:
        return f"No divergence found across {inspected} message(s)."

    lines = [
        f"First divergent handoff at message index {result['index']}.",
        f"  correlation_id : {result['correlation_id']}",
        f"  {result['sender']} -> {result['recipient']}",
        "  differences:",
    ]
    lines.extend(f"    - {item}" for item in result["differences"])
    lines.append("")
    lines.append(
        f"Everything after index {result['index']} is downstream of this. "
        "Fix this handoff first."
    )
    return "\n".join(lines)


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="trace_bisect.py",
        description="Find the first divergent handoff in a multi-agent message log.",
    )
    parser.add_argument("trace", help="path to a JSON message log")
    parser.add_argument(
        "--correlation-id",
        default=None,
        help="only inspect messages carrying this correlation id",
    )
    parser.add_argument(
        "--json",
        action="store_true",
        dest="as_json",
        help="emit machine-readable JSON instead of a text report",
    )
    return parser


def main(argv: list[str] | None = None) -> int:
    args = build_parser().parse_args(argv)

    try:
        messages = load_trace(args.trace)
    except TraceError as exc:
        print(f"error: {exc}", file=sys.stderr)
        return EXIT_BAD_INPUT

    if args.correlation_id is not None:
        inspected = sum(
            1 for m in messages if m["correlation_id"] == args.correlation_id
        )
        if inspected == 0:
            print(
                f"error: no messages with correlation_id {args.correlation_id!r}",
                file=sys.stderr,
            )
            return EXIT_BAD_INPUT
    else:
        inspected = len(messages)

    result = find_first_divergence(messages, args.correlation_id)

    if args.as_json:
        print(json.dumps({"inspected": inspected, "divergence": result}, indent=2))
    else:
        print(format_report(result, inspected))

    return EXIT_CLEAN if result is None else EXIT_DIVERGED


if __name__ == "__main__":
    raise SystemExit(main())

Wire these checks into CI so a broken handoff or dropped step gets caught on the pull request that introduced it, not in production:

name: multi-agent-tests

# The suite is deterministic and offline: no API keys, no live LLM calls, no
# network. That is what makes it safe to run on every pull request.

on:
  pull_request:
  push:
    branches: [main]
  workflow_dispatch:

jobs:
  pytest:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        python-version: ["3.10", "3.11", "3.12"]

    steps:
      - name: Check out the repository
        uses: actions/checkout@v4

      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
          cache: pip

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt

      - name: Run the multi-agent test suite
        run: pytest -v

      - name: Bisect the sample trace
        # Exit code 1 is the expected result here: sample_trace.json ships with
        # a deliberate divergence so the helper has something to find.
        run: |
          python tools/trace_bisect.py sample_trace.json && exit 1 || true
          python tools/trace_bisect.py examples/clean_trace.json

Agent-to-Agent (A2A) Testing Is the Same Testing

None of the above changes if you call it agent-to-agent testing, A2A communication testing, or multi-agent handoff validation instead of what's written above. Vendors and protocol specs use different words for the same three surfaces: a message contract at the boundary, a routing decision at the orchestrator, and an executed step sequence across the whole run. Whether the agents talk over a formal agent-to-agent protocol, an internal message bus, or a plain function call passing a dict, the three surfaces don't change, and neither do the assertions.

The framework underneath changes the implementation of these tests, not the shape of them. A CrewAI crew, an AutoGen group chat, and a hand-rolled orchestration loop all have a handoff, a routing decision, and a step sequence somewhere in them, even when the framework's own docs don't name them that. Whatever protocol or framework sits underneath, test those three surfaces, in that order, and the a2a vocabulary stops mattering.

One surface sits outside all three, and it's the one your users actually experience. A message contract, a routing assertion, and a step sequence together prove the pipeline behaved the way it was designed to. None of them prove the design produced the right result inside your product. That last check is what Autonoma runs: behavioral end-to-end tests against the real deployed application on every pull request, asserting on the record that changed and the email that went out rather than on the log the pipeline wrote about itself, with the Diffs Agent updating the suite as the pipeline's shape changes. Test the three surfaces so a broken run takes minutes to localize instead of an afternoon. Put Autonoma underneath them so you find out the run was broken before the customer does.

Frequently Asked Questions

Test three surfaces separately: the handoff (does agent A pass agent B the exact fields B needs, checked with a schema at the boundary), the delegation (did the orchestrator route the task to an agent capable of handling it, asserted on the routing decision rather than the final text), and the orchestration layer (did every planned step actually execute, in order, checked against the full executed step sequence rather than just the final output). A workflow can pass every individual agent's own test suite and still fail on any of these three.

Agent-to-agent (a2a) testing is another name for testing the handoffs between agents in a multi-agent system: verifying that the message one agent sends another carries the context, fields, and constraints the receiving agent actually needs, usually enforced with a schema or contract checked at the message boundary rather than by inspecting the final output.

Put a correlation ID on every inter-agent message so you can pull the full message log for a single run, then bisect that log in order to find the first message where the content no longer matches what the previous step should have produced. That first divergent message is almost always the actual bug. The agent that visibly fails downstream is typically just the first one to notice the problem, not the one that caused it.

Single-agent testing checks whether one agent, given an input, produces a correct output, which is what simulation-driven agent testing covers. Multi-agent testing adds a layer on top: it checks what happens between agents, whether a handoff carries the right context, whether the orchestrator delegates to the right agent, and whether the orchestration layer executes every step in the correct order. A pipeline can have every individual agent pass its own tests and still produce the wrong final result, because the defect lives in the handoff or the orchestration, not in any single agent's logic.

Assert on the routing decision's structure and constraints rather than a single expected literal path: did the orchestrator pick an agent that's actually capable of the task, did it stay inside the allowed set. For genuinely stochastic routing, run the same input N times and assert on the majority outcome instead of a single pass or fail.

A handoff contract is a typed schema defining the shape of every inter-agent message, asserted at the boundary rather than downstream, so a payload missing a required field fails immediately instead of producing a plausible wrong answer three steps later.

A behavioral assertion on the outcome. Handoff contracts, routing checks, and step-sequence assertions all verify that the pipeline behaved the way it was designed to, which is what makes a broken run debuggable in minutes instead of an afternoon. None of them verify that the design produced the right result. A refund request can clear all three, correct payload, correct agent, every step executed in order, and still leave the customer un-refunded, because the effect inside the product was never checked. Asserting on that effect, the record that changed and the email that went out, is what Autonoma runs, as behavioral end-to-end tests against the real deployed application on each pull request. The two kinds of coverage answer different questions and you want both.

Related articles

A horizontal agent trajectory diagram showing a tool call passing a right-tool checkpoint but failing an argument-accuracy checkpoint

How to Test AI Agents That Take Actions (Tool Calls)

A runnable guide to testing tool-calling agents: right tool, right order, right arguments, mocked vs live calls, failure handling, and non-determinism.

A chatbot test pipeline moving from manual QA through scripted and semantic assertions into an automated CI gate that samples the model N times before allowing a merge

Chatbot Automation Testing: Why Assertions Fail

Chatbot automation testing that survives non-deterministic replies: the migration to a CI gate, n-run sampling, threshold gating, and real GitHub Actions YAML.

Ghost Inspector alternative concept: Quara the frog beside a cracked recorded-test snapshot next to a regenerating test path

Ghost Inspector Alternative: Recorder, Framework, or AI?

Looking for a Ghost Inspector alternative? Compare record-and-playback SaaS, code frameworks, and AI-agent-generated testing by approach, not just by tool.

Diagram showing AI-generated auth code without a baseline: an agent writes login code on one side, while expected auth behavior (valid login, rejected password, protected route redirect) must be defined explicitly on the other

How to Test the Auth Code an AI Agent Wrote

When an AI agent writes your authentication, there is no baseline for correct behavior. Here is how to test AI-generated code for the auth bugs that compile, pass review, and lock users out.