By Sagar Shankaran, Founder of CallSphere
Learn how to test multi-agent handoff logic, verify conversation routing, validate context transfer between agents, and test boundary conditions in agent orchestration systems.
Key takeaways
In multi-agent systems, a triage agent routes conversations to specialized agents — billing, technical support, sales. Handoff failures are some of the most damaging bugs: a customer asking about a refund gets routed to tech support, or context is lost during transfer and the next agent asks the customer to repeat everything.
Testing handoffs requires verifying three things: the router selects the right destination agent, the full conversation context transfers correctly, and edge cases like ambiguous requests or mid-conversation re-routing work properly.
Define handoffs as explicit, inspectable objects rather than implicit side effects.
Hear it before you finish reading
Talk to a live CallSphere AI voice agent in your browser — 60 seconds, no signup.
flowchart LR
PR(["PR opened"])
UNIT["Unit tests"]
EVAL["Eval harness<br/>PromptFoo or Braintrust"]
GOLD[("Golden set<br/>200 tagged cases")]
JUDGE["LLM as judge<br/>plus regex graders"]
SCORE["Aggregate score<br/>and per slice"]
GATE{"Score regress<br/>more than 2 percent?"}
BLOCK(["Block merge"])
MERGE(["Merge to main"])
PR --> UNIT --> EVAL --> GOLD --> JUDGE --> SCORE --> GATE
GATE -->|Yes| BLOCK
GATE -->|No| MERGE
style EVAL fill:#4f46e5,stroke:#4338ca,color:#fff
style GATE fill:#f59e0b,stroke:#d97706,color:#1f2937
style BLOCK fill:#dc2626,stroke:#b91c1c,color:#fff
style MERGE fill:#059669,stroke:#047857,color:#fff
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class Handoff:
source_agent: str
target_agent: str
reason: str
context: dict = field(default_factory=dict)
conversation_history: list[dict] = field(default_factory=list)
@dataclass
class HandoffResult:
should_handoff: bool
handoff: Optional[Handoff] = None
response: Optional[str] = None
class TriageAgent:
def __init__(self, llm, available_agents: list[str]):
self.llm = llm
self.available_agents = available_agents
def process(self, message: str, history: list[dict]) -> HandoffResult:
# LLM determines routing
decision = self.llm.chat([
{"role": "system", "content": self._build_routing_prompt()},
*history,
{"role": "user", "content": message},
])
parsed = self._parse_decision(decision["content"])
if parsed["action"] == "handoff":
return HandoffResult(
should_handoff=True,
handoff=Handoff(
source_agent="triage",
target_agent=parsed["target"],
reason=parsed["reason"],
context=parsed.get("extracted_context", {}),
conversation_history=history + [
{"role": "user", "content": message}
],
),
)
return HandoffResult(should_handoff=False, response=parsed["response"])
Use a FakeLLM to control routing decisions and verify the triage agent routes correctly.
import pytest
@pytest.fixture
def fake_llm():
return FakeLLM(responses=[])
def make_routing_response(target: str, reason: str) -> str:
return f'{{"action": "handoff", "target": "{target}", "reason": "{reason}"}}'
def test_billing_question_routes_to_billing(fake_llm):
fake_llm.responses = [make_routing_response("billing", "refund request")]
triage = TriageAgent(llm=fake_llm, available_agents=["billing", "tech", "sales"])
result = triage.process("I want a refund for my last charge", history=[])
assert result.should_handoff is True
assert result.handoff.target_agent == "billing"
def test_technical_issue_routes_to_tech(fake_llm):
fake_llm.responses = [make_routing_response("tech", "login error")]
triage = TriageAgent(llm=fake_llm, available_agents=["billing", "tech", "sales"])
result = triage.process("I cannot log in to my account", history=[])
assert result.should_handoff is True
assert result.handoff.target_agent == "tech"
def test_general_question_stays_in_triage(fake_llm):
fake_llm.responses = ['{"action": "respond", "response": "How can I help?"}']
triage = TriageAgent(llm=fake_llm, available_agents=["billing", "tech", "sales"])
result = triage.process("Hello", history=[])
assert result.should_handoff is False
assert result.response is not None
Verify that all relevant context passes from the source agent to the destination agent.
def test_context_includes_conversation_history(fake_llm):
fake_llm.responses = [make_routing_response("billing", "payment issue")]
triage = TriageAgent(llm=fake_llm, available_agents=["billing", "tech"])
history = [
{"role": "user", "content": "Hi, I have a problem"},
{"role": "assistant", "content": "Sure, what is the issue?"},
]
result = triage.process("I was double charged $49.99", history=history)
# Full history must transfer — no lost context
assert len(result.handoff.conversation_history) == 3
assert result.handoff.conversation_history[-1]["content"] == "I was double charged $49.99"
def test_extracted_context_contains_key_info(fake_llm):
fake_llm.responses = [
'{"action": "handoff", "target": "billing", "reason": "refund",'
' "extracted_context": {"amount": "$49.99", "issue": "double charge"}}'
]
triage = TriageAgent(llm=fake_llm, available_agents=["billing"])
result = triage.process("I was double charged $49.99", history=[])
assert result.handoff.context["amount"] == "$49.99"
assert result.handoff.context["issue"] == "double charge"
Edge cases where routing logic is most likely to fail.
def test_invalid_target_agent_raises_error(fake_llm):
fake_llm.responses = [make_routing_response("nonexistent_agent", "test")]
triage = TriageAgent(llm=fake_llm, available_agents=["billing", "tech"])
with pytest.raises(ValueError, match="Unknown agent"):
triage.process("Route me somewhere", history=[])
def test_ambiguous_request_asks_clarification(fake_llm):
"""When the intent is unclear, triage should ask rather than guess."""
fake_llm.responses = ['{"action": "respond", "response": "Could you clarify?"}']
triage = TriageAgent(llm=fake_llm, available_agents=["billing", "tech"])
result = triage.process("I have a problem", history=[])
assert result.should_handoff is False
assert "clarif" in result.response.lower()
def test_mid_conversation_rerouting(fake_llm):
"""Agent should re-route if the topic changes mid-conversation."""
fake_llm.responses = [make_routing_response("tech", "now a tech issue")]
triage = TriageAgent(llm=fake_llm, available_agents=["billing", "tech"])
history = [
{"role": "user", "content": "I need a refund"},
{"role": "assistant", "content": "Let me connect you to billing."},
{"role": "user", "content": "Actually, my app keeps crashing"},
]
result = triage.process("The crash happens on every login", history=history)
assert result.handoff.target_agent == "tech"
The Agents SDK models handoffs as special tool calls. Mock the LLM to return a handoff tool call, then verify the runner transfers control to the correct agent and carries the conversation state.
Still reading? Stop comparing — try CallSphere live.
CallSphere ships complete AI voice agents per industry — 14 tools for healthcare, 10 agents for real estate, 4 specialists for salons. See how it actually handles a call before you book a demo.
Use mocked LLMs for unit tests of routing logic. Use real LLMs in a small set of integration tests that verify end-to-end handoff flows, especially for ambiguous cases where routing quality depends on prompt wording.
Lost context. The destination agent does not receive the conversation history or extracted entities, so it asks the user to repeat information. Always assert that the handoff object contains the complete conversation history.
#MultiAgent #Handoffs #Routing #Testing #Python #Orchestration #AgenticAI #LearnAI #AIEngineering

Written by
Sagar Shankaran· Founder, CallSphere
LinkedInSagar Shankaran is the founder of CallSphere, where he builds production AI voice and chat agents deployed across healthcare, hospitality, real estate, and home services. He writes about agentic AI, LLM engineering, and shipping voice agents that handle real calls in production.
See how AI voice agents work for your industry. Live demo available -- no signup required.
How to design a multi-agent system using MCP for tools and A2A for cross-vendor coordination, with a CallSphere voice agent as a participating node.
A2A is the open standard for agent-to-agent coordination. Here is how the Agent Card JSON works, how discovery happens, and what to publish.
A2A unlocks cross-vendor agent coordination, but most enterprise voice/chat workloads still ship faster on a single-vendor stack. Here is how to choose.
BrowserStack offers 30,000+ real devices; Sauce Labs ships deep Appium automation. Here is how AI voice agent teams use both for WebRTC mobile QA in 2026.
Step-by-step build of a working agent with the OpenAI Agents SDK — Agent class, tools, handoffs, tracing — plus an eval pipeline that catches regressions before merge.
AWS Multi-Agent Orchestrator ships supervisor routing, classifier, and shared memory. How to compose a customer-support agent team on Bedrock that scales cleanly.
© 2026 CallSphere Inc. All rights reserved.
Made within San Francisco