Module 05

Agent-to-Agent Handover

⏱ ~4 hours ❓ 12-question quiz 🎯 Unlock Module 06

Handover Fundamentals

A handover is the moment one agent transfers responsibility — and context — to another. Getting it wrong is the most common source of silent failure in multi-agent systems. There are three things that must survive a handover:

🎯
Task Context
What the user originally wanted. The receiving agent must understand the original goal, not just the immediate sub-task it was handed.
📋
Work Done So Far
What previous agents found, decided, or produced. Without this, the receiving agent starts from scratch and may contradict previous work.
🔑
Constraints & Instructions
Any guardrails, format requirements, or user preferences established earlier. These must not be silently dropped at handover.
Failure ModeCauseFix
Context lossReceiving agent only gets the immediate sub-task, not the full conversationPass summarised history in handover payload
Infinite loopAgents hand off to each other indefinitelyTrack visited agents; set max hop count
Ambiguous ownershipTwo agents both think the other is responsibleExplicit routing with Command(goto=...)
Token overflowFull message history is too long for receiving agentSummarise history before handover

LangGraph Command & Handoff Tools

LangGraph provides two mechanisms for agent-to-agent handover: Command (for direct graph routing) and create_handoff_tool (for tool-based handover the LLM can invoke).

python Command-based direct routing
from langgraph.types import Command
from langchain_core.messages import HumanMessage, AIMessage
from typing import Literal

# Command allows a node to explicitly route to another node
# AND update state in one atomic operation
def billing_classifier(state: dict) -> Command[Literal["billing_agent", "support_agent"]]:
    """Classify intent and immediately route to the right agent."""
    last_msg = state["messages"][-1].content.lower()

    if any(word in last_msg for word in ["invoice", "payment", "charge", "refund"]):
        return Command(
            goto="billing_agent",
            update={
                "messages": [AIMessage(content="Routing to billing specialist...")],
                "routed_to": "billing",
                "reason": "billing keyword detected"
            }
        )
    else:
        return Command(
            goto="support_agent",
            update={
                "messages": [AIMessage(content="Routing to technical support...")],
                "routed_to": "support",
                "reason": "no billing keywords"
            }
        )
python create_handoff_tool — LLM-driven handover
from langgraph.prebuilt import create_react_agent
from langgraph.graph import StateGraph, START, END, MessagesState
from langgraph.prebuilt.chat_agent_executor import AgentState
from langchain_openai import ChatOpenAI

llm = ChatOpenAI(model="gpt-4o", temperature=0)

# create_handoff_tool generates a tool that routes to a named agent
# The LLM can call this tool when it decides to hand off
from langgraph.prebuilt import create_handoff_tool

transfer_to_billing  = create_handoff_tool(agent_name="billing_agent",
    description="Transfer to billing agent for invoice, payment, or refund queries.")
transfer_to_support  = create_handoff_tool(agent_name="support_agent",
    description="Transfer to technical support for product issues or bugs.")
transfer_to_triage   = create_handoff_tool(agent_name="triage_agent",
    description="Return to triage when unsure which specialist is needed.")

# Each agent has its own tools + handoff tools
triage_agent = create_react_agent(
    llm,
    tools=[transfer_to_billing, transfer_to_support],
    state_schema=AgentState,
    name="triage_agent",
)

billing_agent = create_react_agent(
    llm,
    tools=[transfer_to_triage],   # can hand back if wrong agent
    system_prompt="You are a billing specialist. Handle invoices, payments, and refunds.",
    state_schema=AgentState,
    name="billing_agent",
)

support_agent = create_react_agent(
    llm,
    tools=[transfer_to_triage],
    system_prompt="You are a technical support specialist. Debug product issues.",
    state_schema=AgentState,
    name="support_agent",
)

# Wire into a graph
from langgraph.graph import StateGraph
builder = StateGraph(AgentState)
builder.add_node("triage_agent",  triage_agent)
builder.add_node("billing_agent", billing_agent)
builder.add_node("support_agent", support_agent)
builder.add_edge(START, "triage_agent")

Swarm Pattern

In a swarm, agents hand off to each other dynamically — there is no central supervisor. Each agent decides which specialist should handle the next step. This is more flexible but requires loop prevention.

python Swarm with loop prevention via visited tracking
from typing import Annotated, Set
from typing_extensions import TypedDict
from langchain_core.messages import BaseMessage, AIMessage
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
import operator

llm = ChatOpenAI(model="gpt-4o", temperature=0)
MAX_HOPS = 5

class SwarmState(TypedDict):
    messages:      Annotated[list[BaseMessage], add_messages]
    current_agent: str
    visited:       Annotated[Set[str], operator.or_]  # union reducer
    hop_count:     int

def make_swarm_agent(name: str, specialty: str, can_handoff_to: list[str]):
    def agent_node(state: SwarmState) -> dict:
        # Prevent revisiting the same agent (loop detection)
        if name in state["visited"] and state["hop_count"] > 1:
            return {
                "messages": [AIMessage(content=f"[{name}] Already visited; ending here.")],
                "current_agent": "END"
            }

        # Prevent runaway chains
        if state["hop_count"] >= MAX_HOPS:
            return {
                "messages": [AIMessage(content="[Swarm] Max hops reached. Terminating.")],
                "current_agent": "END"
            }

        # Agent decides whether to answer or hand off
        decision_prompt = (
            f"You are {name}, specialised in {specialty}.\n"
            f"Can you answer the user's request? If not, hand off to one of: {can_handoff_to}.\n"
            f"Respond with either your answer OR 'HANDOFF: '"
        )
        response = llm.invoke(state["messages"] + [
            AIMessage(content=decision_prompt)
        ])

        if response.content.startswith("HANDOFF:"):
            next_agent = response.content.split(":")[1].strip()
            return {
                "messages": [AIMessage(content=f"[{name}] Handing off to {next_agent}")],
                "current_agent": next_agent,
                "visited": {name},
                "hop_count": state["hop_count"] + 1,
            }
        else:
            return {
                "messages": [response],
                "current_agent": "END",
                "visited": {name},
            }
    return agent_node

def swarm_router(state: SwarmState) -> str:
    next = state.get("current_agent", "END")
    return "end" if next == "END" else next

builder = StateGraph(SwarmState)
builder.add_node("billing",  make_swarm_agent("billing",  "invoices & payments", ["support", "compliance"]))
builder.add_node("support",  make_swarm_agent("support",  "technical issues",    ["billing", "compliance"]))
builder.add_node("compliance", make_swarm_agent("compliance", "regulations & policy", ["billing", "support"]))
builder.add_edge(START, "billing")  # entry point
builder.add_conditional_edges("billing",    swarm_router, {"billing": "billing", "support": "support", "compliance": "compliance", "end": END})
builder.add_conditional_edges("support",    swarm_router, {"billing": "billing", "support": "support", "compliance": "compliance", "end": END})
builder.add_conditional_edges("compliance", swarm_router, {"billing": "billing", "support": "support", "compliance": "compliance", "end": END})
swarm_graph = builder.compile()

Context Preservation on Handover

The biggest silent failure in multi-agent systems: the receiving agent starts answering without context, contradicting or repeating work already done. The fix is to summarise the relevant history before handing off.

python Structured handover payload with context summary
from pydantic import BaseModel, Field
from typing import List, Optional
from langchain_openai import ChatOpenAI

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

class HandoverPayload(BaseModel):
    """Structured context passed between agents."""
    original_task:    str = Field(description="The user's original goal verbatim")
    summary_so_far:   str = Field(description="2-3 sentence summary of work done")
    key_findings:     List[str] = Field(description="Bullet points of important discoveries")
    remaining_task:   str = Field(description="What the receiving agent needs to do")
    constraints:      List[str] = Field(description="Constraints and requirements to maintain")
    attempted_approaches: List[str] = Field(description="Approaches already tried (to avoid repetition)")

def build_handover_payload(
    original_task: str,
    conversation_history: list,
    remaining_task: str,
) -> HandoverPayload:
    """Use an LLM to extract and structure the handover context."""
    history_text = "\n".join([
        f"{m.type.upper()}: {m.content[:200]}"
        for m in conversation_history[-10:]  # last 10 messages to keep concise
    ])

    structured_llm = llm.with_structured_output(HandoverPayload)
    payload = structured_llm.invoke(
        f"Original task: {original_task}\n\n"
        f"Conversation so far:\n{history_text}\n\n"
        f"Remaining task for next agent: {remaining_task}\n\n"
        "Extract and structure the handover context."
    )
    return payload

# Usage in a handover node
def research_to_writer_handover(state: dict) -> dict:
    payload = build_handover_payload(
        original_task=state["original_task"],
        conversation_history=state["messages"],
        remaining_task="Write a 500-word article based on the research findings",
    )
    # Inject the structured payload as the first message to the writing agent
    handover_message = (
        f"HANDOVER CONTEXT:\n"
        f"Original task: {payload.original_task}\n"
        f"Research summary: {payload.summary_so_far}\n"
        f"Key findings: {chr(10).join(f'- {f}' for f in payload.key_findings)}\n"
        f"Your task: {payload.remaining_task}\n"
        f"Constraints: {', '.join(payload.constraints)}\n"
        f"Do NOT repeat: {', '.join(payload.attempted_approaches)}"
    )
    return {"handover_context": handover_message}

Stateful Handover with Checkpointing

When handing off to an agent in a separate subgraph, you need to decide whether to share the same state or isolate it. Use the same thread_id for continuity; use a new thread for isolation.

python Merging subgraph results back into parent state
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import MemorySaver

# Subgraph — isolated worker
class SubState(TypedDict):
    task:   str
    result: str

def sub_worker(state: SubState) -> dict:
    # Perform isolated work
    result = llm.invoke(f"Complete this task: {state['task']}")
    return {"result": result.content}

sub_builder = StateGraph(SubState)
sub_builder.add_node("worker", sub_worker)
sub_builder.add_edge(START, "worker")
sub_builder.add_edge("worker", END)
subgraph = sub_builder.compile()

# Parent graph that calls subgraph and merges results
class ParentState(TypedDict):
    messages: Annotated[list[BaseMessage], add_messages]
    task:     str
    result:   str

def call_subgraph(state: ParentState) -> dict:
    sub_result = subgraph.invoke({"task": state["task"], "result": ""})
    # Merge the subgraph's result back into parent state
    return {
        "result": sub_result["result"],
        "messages": [AIMessage(content=f"Subgraph completed: {sub_result['result'][:100]}")]
    }

parent_builder = StateGraph(ParentState)
parent_builder.add_node("call_subgraph", call_subgraph)
parent_builder.add_edge(START, "call_subgraph")
parent_builder.add_edge("call_subgraph", END)
parent_graph = parent_builder.compile(checkpointer=MemorySaver())

Cross-Service Handover

When the receiving agent runs in a different service (different Python process, microservice, or cloud function), handover requires an async communication channel.

python Async handover via REST with correlation ID
import httpx, uuid
from pydantic import BaseModel
from typing import Optional

class CrossServiceHandover(BaseModel):
    correlation_id:  str     # links related calls across services
    source_agent:    str
    target_agent:    str
    original_task:   str
    context_summary: str
    payload:         dict
    callback_url:    Optional[str] = None  # where to POST the result

async def handoff_to_remote_agent(
    target_service_url: str,
    handover: CrossServiceHandover,
) -> dict:
    """Fire a handover to another service and await the response."""
    async with httpx.AsyncClient(timeout=120.0) as client:
        response = await client.post(
            f"{target_service_url}/handover",
            json=handover.model_dump(),
            headers={
                "X-Correlation-ID": handover.correlation_id,
                "X-Source-Agent":   handover.source_agent,
            }
        )
        response.raise_for_status()
        return response.json()

# Usage inside a LangGraph node
async def billing_to_fulfillment_handover(state: dict) -> dict:
    handover = CrossServiceHandover(
        correlation_id  = state.get("correlation_id") or str(uuid.uuid4()),
        source_agent    = "billing_service",
        target_agent    = "fulfillment_service",
        original_task   = state["original_task"],
        context_summary = state["billing_summary"],
        payload         = {"order_id": state["order_id"], "amount": state["amount"]},
    )
    result = await handoff_to_remote_agent(
        "https://fulfillment.internal/api",
        handover
    )
    return {"fulfillment_result": result, "correlation_id": handover.correlation_id}
💡
Always propagate the correlation ID

A correlation_id is a UUID created at the start of the workflow and passed through every handover. It allows you to trace a single user request across all agents and services in LangSmith/Langfuse, even when they run in separate processes. Without it, debugging cross-service workflows is nearly impossible.

Testing Handover Logic

python Integration test for a 3-hop handover chain
import pytest
from langchain_core.messages import HumanMessage

def test_triage_routes_to_billing():
    """Billing keywords must reach the billing agent."""
    result = swarm_graph.invoke({
        "messages": [HumanMessage("I need to dispute a charge on my invoice")],
        "current_agent": "billing",
        "visited": set(),
        "hop_count": 0,
    })
    # Last message should come from billing agent
    last_msg = result["messages"][-1]
    assert "billing" in last_msg.content.lower() or "invoice" in last_msg.content.lower()
    assert result["hop_count"] <= MAX_HOPS

def test_handover_preserves_context():
    """Context summary must be non-empty after handover."""
    from langchain_core.messages import AIMessage
    mock_history = [
        HumanMessage("I want to build a RAG pipeline"),
        AIMessage("I found three relevant papers on RAG architectures."),
    ]
    payload = build_handover_payload(
        original_task="Build a RAG pipeline",
        conversation_history=mock_history,
        remaining_task="Write the implementation code",
    )
    assert payload.original_task != ""
    assert len(payload.key_findings) > 0
    assert payload.summary_so_far != ""

def test_swarm_respects_max_hops():
    """Swarm must terminate when MAX_HOPS is reached."""
    result = swarm_graph.invoke({
        "messages": [HumanMessage("Bouncing question that keeps getting handed off")],
        "current_agent": "billing",
        "visited": set(),
        "hop_count": MAX_HOPS - 1,  # start near the limit
    })
    assert result["hop_count"] <= MAX_HOPS
    assert result["current_agent"] in ("END", "billing", "support", "compliance")

📝 Knowledge Check

Module 05 — Quiz

Score 80% or higher (10 out of 12) to unlock Module 06.

0 of 12 answered