Skip to main content

88 - Handoffs: Delegating Control to a Specialist Agent

A handoff is a tool that, once called, replaces the active agent. What the receiver sees, how to measure routing and how to break an infinite bounce.

What do you think about this article?

88 - Handoffs: Delegating Control to a Specialist Agent
Machine Learning Engineerfedericocalo.dev

At a Glance

A handoff is a tool that, once called, replaces the active agent. What the receiver sees, how to measure routing and how to break an infinite bounce.

A handoff is a special kind of tool: when the model calls it, it does not get a result back, it gives up its seat. From the next turn on, a different agent, with different instructions and different tools, does the answering, and its reply goes straight to the user. The first agent never sees it. It is the simplest way to have specialists instead of one agent with forty tools, and the simplest way to build, without noticing, two agents that bounce control back and forth forever.

Version tested. openai-agents 0.24.0 on Python 3.14.4, on 2026-11-12, with a fake model in place of a real one (more on that below). Handoffs are one of the most distinctive abstractions of this SDK, and parameter names may change between versions: pin the version in your requirements.txt and reread the documentation page on handoffs when you upgrade.

The analogy: the switchboard and the desk

You call a company. The switchboard answers: it knows nothing about invoices or faults, it only knows how to recognise what you are talking about and put the right person on the line. When it transfers you to accounting, two things happen at once: accounting speaks to you directly (the operator does not repeat its sentences) and the operator leaves the conversation. If accounting then decides yours is a technical problem, it transfers you again, and if the technician sends you back to accounting you are in the circle of hell we all know.

This analogy already contains the three themes of the article: who decides on the transfer, how much of the previous conversation is passed along, and how to avoid the infinite bounce.

Why a handoff instead of a single agent with more tools

This is the question to ask before any code, because the obvious answer ("it is tidier") is the wrong one: tidiness alone does not justify a new class of failures. There are four reasons why a handoff does things that a single agent with a bigger catalogue cannot do, and two of them are structural.

Reason 1: incompatible instructions. This is the strongest reason and the least cited. The refunds desk has a precise mandate: verify the order, apply the 50 euro policy, close. Technical support has another: ask for the error details, proceed by elimination, open a ticket if you cannot fix it. These two sets of instructions do not add up, they dilute each other. A single system prompt that contains both forces the model to keep two policies in mind, and the concrete risk is that it applies one of them halfway: it asks for error details before a refund, or it refunds without checking because "it was a technical problem". With two agents, each sees only its own mandate.

Reason 2: different permissions. The issue_refund tool moves money. In a single agent that tool is in the catalogue always: during a conversation about a device's firmware the model still has the technical ability to call it. You can write in the instructions that it must not, and that will be true almost always, but "almost always" on an operation that moves money is not a security posture. By separating the agents, the refund tool does not exist for the technical agent. It is the difference between a prohibition and an absence, and this reason alone is enough to justify the separation.

Reason 3: the crowded catalogue. Every tool description takes up context on every single turn: the more tools there are, the more characters you pay for on each call (I count them further down). There is also an effect on selection, because tools with close meanings ("look up order", "look up return", "look up shipment") are easy to confuse. How much worse the choice gets as the catalogue grows I cannot say in general: it depends on the model and on the descriptions, and you measure it on your own catalogue with the confusion matrix we build in the section on routing.

Reason 4: different models for different jobs. A triage agent does one thing, classify and route, and runs on every request; it is a job you can give to a smaller, faster model. An agent that has to reason about a contractual contradiction deserves a more capable model and runs on a fraction of the requests. With a single agent you pay the most expensive model for everything; by separating, you pay it where it is needed.

And now the honest part, because these four reasons do not always apply. When a single agent is the better choice:

  • The tasks share the mandate. If "look up order", "look up return" and "look up shipment" all answer to the same policy and the same permissions, splitting them into three agents adds turns and graph without adding anything.
  • The catalogue is small and its boundaries are sharp. With few tools the crowding problem does not exist, and a handoff costs one extra model call to solve a problem you do not have.
  • The user keeps changing topic. With a star graph every change of topic goes through the router: a user who alternates between refunds and technical problems pays a routing turn at every switch. A single agent with both catalogues is smoother, if reasons 1 and 2 do not apply.

The three items share a root: a handoff is paid for with an extra call and a graph to maintain, and the payment is justified only if you obtain separate mandates or permissions. If all you obtain is tidiness, you have spent latency on aesthetics. Hence the criterion I recommend as a first filter: split when the mandate or the permissions change, not when the topic changes. Different topics with the same mandate are tools of the same agent; the same topic with incompatible mandates is two agents.

The mechanism, in a diagram

The flow of a handoff between a triage agent and a specialised agent user "I want a refund" TRIAGE agent no domain tools REFUNDS agent tools: orders, refund policy: max 50 EUR TECHNICAL agent tools: diagnostics, ticket transfer_to_refunds() transfer_to_technical() the reply goes DIRECTLY to the user the TRIAGE does not re-enter If REFUNDS can hand off to TECHNICAL and TECHNICAL can hand off to REFUNDS, the graph has a cycle: you need a limit.
In a handoff, control moves and does not come back. It is the main difference from the agent-as-tool of the next lesson.

There is nothing magical under the hood. For every agent in the handoffs list the SDK generates a tool with a conventional name, transfer_to_ followed by the agent's name in lowercase with spaces turned into underscores (the documentation uses "Refund Agent" as its example, which becomes transfer_to_refund_agent), and adds it to the model's catalogue. When the model calls that tool, the runner does not execute a function: it changes the active agent and runs the loop again. The rest (descriptions, schema, choice) is the ordinary tool-calling mechanism seen in Tools: Function Calling, JSON Schemas and Errors the Agent Can Read.

The minimal code, tested without a real model

To really run the listings in this article without calling any model, I wrote a fake model: a class that the SDK accepts in place of a model (it extends agents.Model), replies with a fixed script and records what it receives on every call, that is instructions, history, available tools and handoffs. This has a limit you must keep in mind: what I verify is the mechanics of the SDK (which tool is generated, who answers, what the agent receiving control sees), not the quality of a real model's decisions. Where a decision is needed, the script makes it, and I say so each time. The file is called fake.py and every following block imports it.

"""A fake, deterministic model: it replies from a script and records what it receives."""
import itertools
import json

from agents import Model, ModelResponse, Usage
from openai.types.responses import (ResponseFunctionToolCall, ResponseOutputMessage,
                                    ResponseOutputText)

_counter = itertools.count()


def tool_call(name, **arguments):
    """The model decides to call a tool (or a handoff, which for it is just a tool)."""
    n = next(_counter)
    return ResponseFunctionToolCall(type="function_call", name=name, id=f"fc_{n}",
                                    call_id=f"call_{n}", arguments=json.dumps(arguments))


def say(sentence):
    """The model replies with a text message."""
    return ResponseOutputMessage(id="m", type="message", role="assistant", status="completed",
                                 content=[ResponseOutputText(type="output_text", text=sentence,
                                                             annotations=[])])


class FakeModel(Model):
    """`script`: list of steps, one per call. A step is an output or a function
    input -> output. If `script` is a function, it answers every call."""

    def __init__(self, script, log=None):
        self.script = script
        self.log = log if log is not None else []

    async def get_response(self, system_instructions, input, model_settings, tools, output_schema,
                           handoffs, tracing, *, previous_response_id=None, conversation_id=None,
                           prompt=None):
        schemas = [(t.name, t.description, t.params_json_schema) for t in tools] + \
                  [(h.tool_name, h.tool_description, h.input_json_schema) for h in handoffs]
        self.log.append({
            "instructions": system_instructions, "input": input,
            "tools": [t.name for t in tools], "handoffs": [h.tool_name for h in handoffs],
            "handoff_descriptions": {h.tool_name: h.tool_description for h in handoffs},
            "handoff_schemas": {h.tool_name: h.input_json_schema for h in handoffs},
            "chars": len(system_instructions or "") + len(json.dumps(schemas)) + len(json.dumps(input)),
        })
        step = self.script(input) if callable(self.script) else self.script.pop(0)
        step = step(input) if callable(step) else step
        return ModelResponse(output=step if isinstance(step, list) else [step],
                             usage=Usage(), response_id=None)

    def stream_response(self, *args, **kwargs):
        raise NotImplementedError("the fake model does not stream")

The two functions tool_call and say build the two possible outputs of a model: a tool call (a handoff is, for the model, the call of an ordinary tool) or a final message. The FakeModel class takes a script, that is a list of steps consumed one per call, or a function that answers every call, and saves in the log the rows we need: tool names, handoff names and descriptions, the history received and a count of the characters sent. Call identifiers come from a counter, because in my trials a repeated identifier made the loop start over without performing the transfer. With this in hand, here is the minimal declaration.

import asyncio
from agents import Agent, Runner, function_tool
from fake import FakeModel, tool_call, say

@function_tool
def look_up_order(number: str) -> dict:
    """Details of an order from its number."""
    return {"number": number, "amount": 42.0, "status": "delivered"}

@function_tool
def issue_refund(number: str, amount: float) -> dict:
    """Issues a refund on an order. Maximum 50 EUR without approval."""
    if amount > 50:
        return {"ok": False, "error": "over_the_limit",
                "how_to_fix": "Explain to the user that an approval is needed."}
    return {"ok": True, "refunded": amount}

seen = []          # here the fake model records what it receives at every turn

refunds_agent = Agent(
    name="Refunds",
    handoff_description="Handles refund and return requests on existing orders.",
    instructions=("You are the refunds desk. Always verify the order before refunding. "
                  "Above 50 EUR you cannot proceed: explain it and close."),
    tools=[look_up_order, issue_refund],
    model=FakeModel([tool_call("look_up_order", number="A-7781"),
                     say("I refunded 42 EUR on order A-7781.")], seen),
)

technical_agent = Agent(
    name="Technical",
    handoff_description="Fixes malfunctions, errors and configuration problems.",
    instructions="You are technical support. Ask for the error details, then guide the user.",
    model=FakeModel([], seen),
)

triage = Agent(
    name="Triage",
    instructions=(
        "Work out what the user needs and hand off to the right agent. "
        "Do not answer on the substance yourself: you have no access to the data. "
        "If the request is neither about refunds nor about technical problems, "
        "say so clearly to the user and do NOT hand off to anyone."
    ),
    handoffs=[refunds_agent, technical_agent],
    model=FakeModel([tool_call("transfer_to_refunds")], seen),
)

async def main():
    result = await Runner.run(triage, "Order A-7781 arrived broken, I want my money back.")
    print("answered:", result.last_agent.name)
    print(result.final_output)
    print("triage tools:", seen[0]["tools"], "handoffs:", seen[0]["handoffs"])
    print("Refunds tools:", seen[1]["tools"], "handoffs:", seen[1]["handoffs"])
    print(seen[0]["handoff_descriptions"]["transfer_to_refunds"])

asyncio.run(main())

# Output:
# answered: Refunds
# I refunded 42 EUR on order A-7781.
# triage tools: [] handoffs: ['transfer_to_refunds', 'transfer_to_technical']
# Refunds tools: ['look_up_order', 'issue_refund'] handoffs: []
# Handoff to the Refunds agent to handle the request. Handles refund and return requests on existing orders.

The output answers the question that matters: the triage has no tools, only two handoffs; the agent that answers is Refunds, and its tools are the domain ones. Three lines of the code determine whether the routing works.

  • handoff_description. It is what ends up in the description of the transfer tool: the last line of the output shows that the SDK appends it to a default text. It is not documentation, it is the router's prompt. Writing it badly ("Agent for various things") guarantees random routing; writing it with boundaries ("refunds and returns on existing orders") makes it far more predictable.
  • "Do not answer on the substance yourself". Without this line the triage tends to answer on its own, because for a model answering is more natural than delegating. It is a behaviour to correct in the instructions, and to measure.
  • "If it concerns neither, do NOT hand off to anyone". The out-of-scope case must be declared, otherwise the triage picks the "least wrong" agent and the user ends up at the refunds desk asking for the opening hours.

The last two lines are natural-language instructions: they work as long as the model respects them, and that is why they must be checked with the routing test further down. The first is different, because it is an SDK fact you can read with certainty. One design detail is worth more than many prompts: the triage has no domain tools. It cannot look up orders even if it wanted to. Removing a capability is a structural constraint; asking in words for it not to be used is a hope.

What a handoff costs

A handoff is not free, and it is worth knowing exactly what you pay. The bill has two items: one extra model call (the triage turn) and a different input text on every call. The following block compares a single agent, with three domains in the same catalogue, against a triage plus a specialist that carries only the tools of its own domain. I counted the characters sent to the model (instructions, tool and handoff schemas, history), not tokens: I did not have a tokenizer at hand, and characters are a reproducible measure, proportional to tokens but not equal to them.

import asyncio, json
from agents import Agent, FunctionTool, Runner
from fake import FakeModel, tool_call, say

def create_tool(name: str, description: str) -> FunctionTool:
    async def run(_context, _arguments: str) -> str:
        return json.dumps({"ok": True})
    schema = {"type": "object", "properties": {"number": {"type": "string"}},
              "required": ["number"], "additionalProperties": False}
    return FunctionTool(name=name, description=description, params_json_schema=schema,
                        on_invoke_tool=run)

def catalogue(prefix: str, n: int) -> list[FunctionTool]:
    return [create_tool(f"{prefix}_{i:02d}", f"Operation {i} of the {prefix} domain: reads or "
                        "modifies a record from its number and returns the outcome.")
            for i in range(n)]

REQUEST = "Order A-7781 arrived broken, I want my money back."
INSTRUCTIONS = "You are a customer assistant. Verify the order, then apply the domain policy."

async def chars_sent(agent: Agent, log: list) -> int:
    await Runner.run(agent, REQUEST)
    return sum(v["chars"] for v in log)

async def compare(per_domain: int) -> None:
    # Single agent: three domains in the same catalogue, three turns (tool, tool, answer)
    r1 = []
    single = Agent(name="Single", instructions=INSTRUCTIONS,
                   tools=catalogue("refunds", per_domain) + catalogue("technical", per_domain)
                         + catalogue("shipments", per_domain),
                   model=FakeModel([tool_call("refunds_00", number="A-7781"),
                                    tool_call("refunds_00", number="A-7781"), say("Done.")], r1))
    # Triage + specialist: the triage has no domain tools, the specialist only its own
    r2 = []
    specialist = Agent(name="Refunds", instructions=INSTRUCTIONS, tools=catalogue("refunds", per_domain),
                       handoff_description="Refunds and returns on existing orders.",
                       model=FakeModel([tool_call("refunds_00", number="A-7781"),
                                        tool_call("refunds_00", number="A-7781"), say("Done.")], r2))
    triage = Agent(name="Triage", instructions="Understand the request and hand off to the right agent.",
                   handoffs=[specialist, Agent(name="Technical", instructions="x",
                                               handoff_description="Technical problems.")],
                   model=FakeModel([tool_call("transfer_to_refunds")], r2))
    a = await chars_sent(single, r1)
    b = await chars_sent(triage, r2)
    print(f"{3 * per_domain:>2} tools in total | single agent: {len(r1)} calls, {a:>6} characters"
          f" | triage + specialist: {len(r2)} calls, {b:>6} characters")

async def main():
    for per_domain in (1, 2, 6):
        await compare(per_domain)

asyncio.run(main())

# Output:
#  3 tools in total | single agent: 3 calls,   3315 characters | triage + specialist: 4 calls,   2992 characters
#  6 tools in total | single agent: 3 calls,   5526 characters | triage + specialist: 4 calls,   3721 characters
# 18 tools in total | single agent: 3 calls,  14379 characters | triage + specialist: 4 calls,   6655 characters

In my trial the handoff never cost more than the single agent, and the advantage grows with the catalogue: the difference is small with three tools and more than halves the total with eighteen. The reason is that the triage turn is very short (no domain tool descriptions) while the specialist's calls carry only its own schemas, which repeat on every turn. The exact number depends on the choices made in the trial: descriptions of about a hundred characters per tool, identical instructions for the two agents, two tool calls per conversation. With specialist instructions much longer than the triage's, the break-even point moves. There is no "textbook" threshold: measure on your own catalogue.

What remains is the cost the count does not show: latency. The triage turn is one more network call, and the user feels it; the output says so with the number of calls (four against three). Two mitigations work: use a small model for the triage, since the job is a classification, and announce the transfer in the interface ("I am putting you in touch with the refunds desk") instead of leaving an empty screen.

Measuring whether the routing works

The triage is a classifier, and like every classifier it must be measured instead of judged by feel. You need a set of requests with the correct agent annotated by hand: forty requests taken from the logs are enough to start. Here I use five, and the triage is a keyword rule instead of a model: it serves to show the shape of the matrix, not to say how good a model is. The last case is chosen so that the rule gets it wrong.

import asyncio
from collections import Counter
from agents import Agent, Runner
from fake import FakeModel, tool_call, say

def keyword_rule(user_input):
    """The 'triage' of this trial: a rule, not a model. It serves to show the matrix."""
    last = user_input if isinstance(user_input, str) else user_input[-1]["content"]
    t = last.lower()
    if "refund" in t or "money" in t:
        return tool_call("transfer_to_refunds")
    if "app" in t or "error" in t:
        return tool_call("transfer_to_technical")
    return say("I cannot help you with that.")

refunds = Agent(name="Refunds", instructions="r", handoff_description="Refunds.",
                model=FakeModel(lambda _i: say("ok")))
technical = Agent(name="Technical", instructions="t", handoff_description="Technical problems.",
                  model=FakeModel(lambda _i: say("ok")))
triage = Agent(name="Triage", instructions="Hand off to the right agent.", handoffs=[refunds, technical],
               model=FakeModel(keyword_rule))

ROUTING_CASES = [
    ("Order A-7781 arrived broken, I want my money back",    "Refunds"),
    ("The app closes when I open the settings",              "Technical"),
    ("My refund has not arrived because the app shows an error", "Refunds"),   # ambiguous: you decided
    ("What time do you close?",                              None),            # out of scope
    ("I would like my money back, the Pay button shows an error", "Technical"),  # the rule gets it wrong
]

async def measure_routing(triage, cases) -> dict:
    confusion = Counter()
    for request, expected in cases:
        result = await Runner.run(triage, request, max_turns=4)
        chosen = result.last_agent.name if result.last_agent.name != "Triage" else None
        confusion[(expected, chosen)] += 1
    correct = sum(v for (a, s), v in confusion.items() if a == s)
    return {"accuracy": correct / len(cases), "matrix": dict(confusion)}

async def main():
    outcome = await measure_routing(triage, ROUTING_CASES)
    print("accuracy:", outcome["accuracy"])
    for (expected, chosen), n in sorted(outcome["matrix"].items(), key=str):
        print(f"  expected={expected!s:<9} chosen={chosen!s:<9} n={n}")

asyncio.run(main())

# Output:
# accuracy: 0.8
#   expected=Refunds   chosen=Refunds   n=2
#   expected=Technical chosen=Refunds   n=1
#   expected=Technical chosen=Technical n=1
#   expected=None      chosen=None      n=1

The confusion matrix is the useful part, because routing errors do not all have the same cost. Sending a refund request to technical support is a nuisance: the user repeats themselves. Sending a technical request to the refunds desk, if that agent has the power to refund, is a risk: it might decide to solve the problem with an undue refund. Here the row expected=Technical chosen=Refunds is exactly this case. And not routing anything on an out-of-scope request is the correct behaviour, not an error: the aggregate accuracy counts it as right only if you annotated it properly, with None as the expected value.

The rows to look at first are therefore two: how many out-of-scope requests were routed to someone (a symptom of a triage that cannot say "no") and how many ended up in the agent with the widest permissions (a symptom of a risk, not of a nuisance). Both are corrected in the handoff_description and in the instruction line about the out-of-scope case, that is in the two points seen at the beginning, which now have a measurement behind them.

What the receiving agent sees

By default the agent that receives control sees the whole conversation: the documentation says it is as if the new agent took the place of the previous one and read the entire history, user messages and tool calls included. This is convenient and often wrong: if the receiver reads a call to run_device_diagnostics it may conclude that it has that tool available and try to use it, getting an error. The remedy is the input filters, functions that receive a HandoffInputData and return a new one. The SDK provides one, remove_all_tools; often you need something more selective, for example keeping the user's message and adding a note about why the conversation arrived here. The file filters.py contains it.

def user_messages(input_history):
    """Run with a string: input_history is a string. Run with a list: it is a tuple."""
    if isinstance(input_history, str):
        return [{"role": "user", "content": input_history}]
    return [m for m in input_history if isinstance(m, dict) and m.get("role") == "user"]

def only_user_request(data):
    """Passes only the user messages to the receiver, plus a context note."""
    note = {"role": "user",
            "content": "[Handed off from triage: refund request on an order.]"}
    return data.clone(input_history=(*user_messages(data.input_history), note),
                      pre_handoff_items=(), new_items=())

There is a trap I found by running the code: input_history is a string when you call Runner.run(agent, "text") and a tuple of messages when you pass a list. A filter that iterates over input_history assuming it is always a list, when iterating over a string looks at single characters, finds no dictionary and throws away the user's message: the receiver would have seen only the note. That is why user_messages normalises the two cases. Note also that the filter empties pre_handoff_items and new_items, that is the items generated during the run (tools included), and leaves only what it builds itself. Now let us see what the receiver gets in the three cases.

import asyncio
from agents import Agent, Runner, function_tool, handoff
from agents.extensions import handoff_filters
from filters import only_user_request
from fake import FakeModel, tool_call, say

@function_tool
def run_device_diagnostics(code: str) -> str:
    """Runs a diagnosis of the device."""
    return "firmware ok"

def what_the_receiver_sees(input_filter, request):
    seen = []
    refunds = Agent(name="Refunds", instructions="You are the refunds desk.",
                    model=FakeModel([say("ok")], seen))
    triage = Agent(name="Triage", instructions="Hand off.", tools=[run_device_diagnostics],
                   handoffs=[handoff(agent=refunds, input_filter=input_filter)],
                   model=FakeModel([tool_call("run_device_diagnostics", code="x"),
                                    tool_call("transfer_to_refunds")]))
    asyncio.run(Runner.run(triage, request))
    return [(m.get("role") or m["type"]) + ": " + str(m.get("content") or m.get("name") or m.get("output"))
            for m in seen[0]["input"]]

REQUEST = "Order A-7781 arrived broken, I want my money back."
for title, input_filter in [("no filter", None),
                            ("remove_all_tools", handoff_filters.remove_all_tools),
                            ("only_user_request", only_user_request)]:
    print("==", title)
    for line in what_the_receiver_sees(input_filter, REQUEST):
        print("  ", line)

# Output:
# == no filter
#    user: Order A-7781 arrived broken, I want my money back.
#    function_call: run_device_diagnostics
#    function_call_output: firmware ok
#    function_call: transfer_to_refunds
#    function_call_output: {"assistant": "Refunds"}
# == remove_all_tools
#    user: Order A-7781 arrived broken, I want my money back.
# == only_user_request
#    user: Order A-7781 arrived broken, I want my money back.
#    user: [Handed off from triage: refund request on an order.]

Without a filter the receiver also reads the diagnosis and the transfer itself. With remove_all_tools only the user's message remains. With the custom filter, the message plus the note remain. The explicit note makes a difference in practice: without it the receiving agent has to deduce from the conversation why it was called, and sometimes it deduces wrongly. Do not overdo it in the opposite direction: if you filter too much, the receiver loses the order number the user had already given and asks for it again, producing the "the switchboard already asked me that" experience. The SDK also has an option, nest_handoff_history, which condenses the history into a single message; the documentation describes it as beta, off by default and ignored when you set your own input_filter.

The context that gets lost in the transfers

There is a handoff failure that is not the bounce and that in production shows up more often: information the user has already given and the receiving agent asks for again. It is the digital transposition of the most irritating experience of telephone customer service, and it comes from an input filter set with too heavy a hand. In a long conversation the order number may have been given many messages earlier, and a filter that keeps "only something" can take it away; the refunds agent receives a context in which the number is missing, and does the sensible thing: it asks for it.

The effective remedy is not "filter less", it is extract the state before filtering. Instead of relying on which messages survive, you have the triage fill in a structured object at the moment of the transfer, you save it in the application context and the filter brings it back as a note. The input_type parameter of handoff() does exactly this: it turns the transfer tool into a tool with arguments, and on_handoff receives the arguments already validated against the Pydantic schema.

import asyncio
from dataclasses import dataclass
from pydantic import BaseModel
from agents import Agent, Runner, RunContextWrapper, handoff
from fake import FakeModel, tool_call, say
from filters import user_messages

class ConversationState(BaseModel):
    order_number: str | None = None
    problem: str | None = None
    requested_amount: float | None = None

@dataclass
class AppContext:
    state: ConversationState | None = None

async def on_transfer(context: RunContextWrapper[AppContext], data: ConversationState) -> None:
    """The SDK validates the arguments against the schema and passes them here."""
    context.context.state = data                     # saved outside the model

def with_state_note(data):
    """Filters the history, but brings the structured state back as a final note."""
    s = data.run_context.context.state
    note = {"role": "user", "content":
            "[Handed off from triage. Data already collected: "
            f"order={s.order_number or 'not provided'}, "
            f"problem={s.problem or 'not specified'}, "
            f"amount={s.requested_amount or 'not indicated'}. "
            "DO NOT ask the user again for the data already present here.]"}
    return data.clone(input_history=(*user_messages(data.input_history), note),
                      pre_handoff_items=(), new_items=())

def run(triage_arguments: dict):
    seen, context = [], AppContext()
    refunds = Agent(name="Refunds", instructions="You are the refunds desk.",
                    model=FakeModel([say("Case opened.")], seen))
    triage = Agent(
        name="Triage", instructions="Hand off.",
        handoffs=[handoff(agent=refunds, on_handoff=on_transfer,
                          input_type=ConversationState,      # the triage MUST fill it in
                          input_filter=with_state_note)],
        model=FakeModel([tool_call("transfer_to_refunds", **triage_arguments)], seen))
    asyncio.run(Runner.run(triage, "Order A-7781 arrived broken, I want my money back.",
                           context=context))
    return seen, context

seen, context = run({"order_number": "A-7781", "problem": "arrived broken",
                     "requested_amount": 42.0})
print("arguments of the transfer tool:",
      list(seen[0]["handoff_schemas"]["transfer_to_refunds"]["properties"]))
print("state in the context:", context.state)
print("the receiver reads:", seen[1]["input"][-1]["content"])

seen, _ = run({"order_number": "A-7718"})      # the triage gets a digit wrong
print("wrong triage:      ", seen[1]["input"][-1]["content"])

# Output:
# arguments of the transfer tool: ['order_number', 'problem', 'requested_amount']
# state in the context: order_number='A-7781' problem='arrived broken' requested_amount=42.0
# the receiver reads: [Handed off from triage. Data already collected: order=A-7781, problem=arrived broken, amount=42.0. DO NOT ask the user again for the data already present here.]
# wrong triage:       [Handed off from triage. Data already collected: order=A-7718, problem=not specified, amount=not indicated. DO NOT ask the user again for the data already present here.]

The first two lines of the output show that the transfer tool now has three arguments and that the object filled in by the triage ended up in the context, outside the model. The third is the note the receiver reads at the end of the history, that is at the edge of the context: the experiments in Lost in the Middle show that models use information at the beginning and at the end of a long context better than in the middle, and that is one more reason to put there the data that must not be lost (it is also discussed in Conversational Memory: Sessions, Context Window and Summarisation). The documentation specifies a point that matters here: input_type describes the arguments of the transfer call and does not replace the receiver's main input, which remains the one decided by the filter.

The last line shows the case that is frightening: the triage fills in A-7718 instead of A-7781, and the receiver will work on an order that does not exist, discovering it only when the tool answers "order not found". The defence is not more accuracy in the triage, it is that the field passed in the handoff is treated as a suggestion and not as a truth: the downstream tool must return a useful error, with suggestions, as in the pattern of the lesson on tools. When the components are probabilistic, tolerance to the errors of whoever sits upstream is a property that has to be designed.

The infinite bounce: how it arises and how to break it

The cycle arises from the combination of two things that are harmless taken singly: a handoff graph with edges in both directions, and two agents each convinced that the problem belongs to the other. The user writes "my refund has not arrived because the app shows an error": is it a refunds problem or a technical one? Both agents have a legitimate reading, and they pass the ball. To prove it I wrote two agents whose fake model always replies with a transfer to the other, and I applied the three defences that follow, all three because they cover different cases.

  1. Structural: the graph is a star, not a mesh. Only the triage has handoffs; the specialists do not. The cycle is impossible by construction.
  2. Counter: if you need a mesh, count the transfers. The handoff carries an on_handoff function that records the path and raises an exception on the second passage through the same agent or beyond the third transfer.
  3. Hard cap: max_turns. Every turn of the cycle, handoffs included, counts; beyond the limit the SDK raises MaxTurnsExceeded. The default value is 10, as you can read in the SDK's code.

The three defences are in order of preference, not of strength. The first eliminates the class of bug instead of handling it; the second gives a message that explains why you stopped, with the path; the third is the last net, and also the least informative, because it only says that the limit was exceeded. Note one thing about the code: an on_handoff function without input_type must take a single argument, the context (the SDK raises an error if you declare two), so the name of the destination agent is captured with a function that builds the handoff, with_guard.

import asyncio
from dataclasses import dataclass, field
from agents import Agent, Runner, RunContextWrapper, handoff
from agents.exceptions import MaxTurnsExceeded
from fake import FakeModel, tool_call, say

@dataclass
class RunContext:
    run_id: str
    handoffs_done: int = 0
    agents_visited: list[str] = field(default_factory=list)

def with_guard(agent: Agent, max_handoffs: int = 3):
    """A handoff that records the path and breaks the cycle."""
    async def on_transfer(context: RunContextWrapper[RunContext]) -> None:
        c = context.context
        c.handoffs_done += 1
        c.agents_visited.append(agent.name)
        print(f"[run {c.run_id}] handoff #{c.handoffs_done} to {agent.name}")
        if c.handoffs_done > max_handoffs:
            raise RuntimeError(f"too many handoffs: {c.agents_visited}")
        if c.agents_visited[-4:].count(agent.name) >= 2:
            raise RuntimeError(f"cycle detected on {agent.name}: {c.agents_visited}")
    return handoff(agent=agent, on_handoff=on_transfer)

def two_agents_that_bounce():
    refunds = Agent(name="Refunds", instructions="r",
                    model=FakeModel(lambda _i: tool_call("transfer_to_technical")))
    technical = Agent(name="Technical", instructions="t",
                      model=FakeModel(lambda _i: tool_call("transfer_to_refunds")))
    return refunds, technical

async def main():
    request = "my refund has not arrived because the app shows an error"

    # Defence 2: mesh with counter
    refunds, technical = two_agents_that_bounce()
    refunds.handoffs = [with_guard(technical)]
    technical.handoffs = [with_guard(refunds)]
    context = RunContext("r-1")
    try:
        await Runner.run(refunds, request, context=context, max_turns=10)
    except RuntimeError as error:
        print("interrupted:", error)

    # Defence 3: mesh without counter, only the hard cap
    refunds, technical = two_agents_that_bounce()
    refunds.handoffs, technical.handoffs = [technical], [refunds]
    try:
        await Runner.run(refunds, request, max_turns=6)
    except MaxTurnsExceeded as error:
        print("hard cap:", error)

    # Defence 1: star. Only the triage has handoffs, the specialists do not.
    seen = []
    refunds = Agent(name="Refunds", instructions="r", model=FakeModel([say("done")], seen))
    technical = Agent(name="Technical", instructions="t")
    triage = Agent(name="Triage", instructions="x", handoffs=[refunds, technical],
                   model=FakeModel([tool_call("transfer_to_refunds")], seen))
    await Runner.run(triage, request)
    print("handoffs seen by the triage:", seen[0]["handoffs"], "| by Refunds:", seen[1]["handoffs"])

asyncio.run(main())

# Output:
# [run r-1] handoff #1 to Technical
# [run r-1] handoff #2 to Refunds
# [run r-1] handoff #3 to Technical
# interrupted: cycle detected on Technical: ['Technical', 'Refunds', 'Technical']
# hard cap: Max turns (6) exceeded
# handoffs seen by the triage: ['transfer_to_refunds', 'transfer_to_technical'] | by Refunds: []

The counter stopped the run at the third transfer, when Technical appeared for the second time in the path; without the counter, the hard cap interrupted the run at the sixth turn, and the star removed the possibility of a cycle altogether: Refunds sees no handoff. The first defence is the one I use almost always. It is not as elegant as a system where every agent knows its colleagues, but if you really need a specialist to send things back, the clean form is that it closes with an answer that explains what to do, not that it transfers. The same on_handoff callback is also the place to record the event for the logs: without the list of visited agents, "sometimes the user ends up in the wrong place" is unsolvable, with that list it is a query on a log.

Escalation to a human

The most useful and most underrated case is the agent that recognises it cannot proceed and passes to a person. From the control point of view it is a handoff, but in the SDK it is not implemented with handoffs=[...], because the recipient is not an agent: it is implemented as a function tool that queues the case in a ticket system. The queue in the block is an in-memory stand-in, to be replaced with your own system; the rest is run as it stands.

import asyncio
from typing import Literal
from pydantic import BaseModel, Field, ValidationError
from agents import Agent, Runner, function_tool
from fake import FakeModel, tool_call, say

class HumanQueue:
    """In place of a real ticket system: queues in memory and returns a number."""
    def __init__(self):
        self.requests = []
    async def enqueue(self, request: dict) -> str:
        self.requests.append(request)
        return f"T-{len(self.requests):04d}"

human_queue = HumanQueue()

class Escalation(BaseModel):
    reason: Literal["beyond_authority", "angry_user", "ambiguous_case", "missing_data"]
    summary: str = Field(max_length=400, description="Summary for the human operator")
    urgency: Literal["normal", "high"]

@function_tool
async def pass_to_operator(request: Escalation) -> dict:
    """Passes the conversation to a human operator and END your turn.

    Use this tool when: the amount exceeds your powers, the user explicitly
    asks for a person, the data are contradictory, or you do not know what to do.
    It is not a defeat: it is the correct choice in these cases.
    """
    ticket = await human_queue.enqueue(request.model_dump())
    return {"ok": True, "ticket": ticket,
            "what_to_tell_the_user": f"I have passed the case to a colleague "
                                     f"(reference {ticket}). They will reply shortly."}

async def main():
    agent = Agent(name="Refunds", instructions="You are the refunds desk.", tools=[pass_to_operator],
                  model=FakeModel([
                      tool_call("pass_to_operator", request={"reason": "beyond_authority",
                                "summary": "Refund of 180 EUR on order A-7781.", "urgency": "normal"}),
                      say("I have passed the case to a colleague (reference T-0001). They will reply shortly.")]))
    result = await Runner.run(agent, "I want 180 euros back for order A-7781.")
    print(result.final_output)
    print(human_queue.requests)
    try:
        Escalation(reason="ambiguous_case", summary="x" * 401, urgency="high")
    except ValidationError as error:
        print(error.errors()[0]["type"], "-", error.errors()[0]["msg"])

asyncio.run(main())

# Output:
# I have passed the case to a colleague (reference T-0001). They will reply shortly.
# [{'reason': 'beyond_authority', 'summary': 'Refund of 180 EUR on order A-7781.', 'urgency': 'normal'}]
# string_too_long - String should have at most 400 characters

Three precautions. The summary field has max_length=400 in the Pydantic schema: the last line of the output shows that a 401-character summary is rejected before the tool runs, and the limit thus becomes a constraint of the SDK instead of a comment. The line "it is not a defeat" in the docstring is there because models trained to be helpful may avoid declaring their own helplessness: it is a prompt choice to verify with your own cases, not a guarantee. And the what_to_tell_the_user field returned by the tool stops the agent from improvising a closing message, perhaps promising times that nobody guaranteed. In the run the final text is dictated by the script, of course; with a real model it depends on how the model uses that field.

Three cases where a handoff is the wrong choice

  • You need the specialist's answer to continue. Example: an agent that must consult the legal reviewer and then rewrite the contract taking the opinion into account. With a handoff the reviewer's answer goes to the user and the contract is not rewritten. You need the agent-as-tool.
  • You need two opinions to compare. A handoff goes to one place only and does not come back; to query three specialists and synthesise you need an orchestrator that calls them as tools, possibly in parallel.
  • The routing decision is deterministic. If you route by arrival channel, by pricing plan or by a rule written in the contract, that is one line of code. Asking the model to apply a rule you know for certain is paying to introduce an uncertainty you did not have.

The first two cases share the need to take control back after the specialist's answer, which is exactly what the handoff does not offer; the solution is in the next article, Agent-as-Tool: Hierarchical Orchestration Instead of Delegation. The third is a more general reminder: an if is cheaper and more predictable than a model whenever the rule is known.

It is worth setting all this in a wider frame. The problem of coordination among agents predates LLMs by decades, and classical research on multi-agent systems produced formal protocols for delegation and negotiation: the dedicated series covers the theoretical side in inter-agent communication: FIPA-ACL and protocols and negotiation and auctions: Contract Net Protocol. The comparison is instructive: in those protocols the transfer of a task is a typed message with a defined, verifiable semantics, while here it is a probabilistic decision of a model. You gain flexibility on natural language, you lose every guarantee. For multi-agent architectures in production the practical reference is multi-agent orchestration: architectures for production.

Trade-offs

  • Specialists versus a single agent. Each specialist has its own instructions, policies and tools, and in the trial above the total of characters sent drops. The price is an extra call (the one spent deciding on the transfer) and a graph to maintain.
  • Handoff versus a branch in the code. If classifying the request is easy and stable ("it contains the order number" means refunds), an if costs zero calls and is deterministic. The handoff is for when the classification requires understanding of language.
  • Full history versus filtered. Full: the receiver has all the context and also the confusion. Filtered: it starts clean and may ask for data the user had already given. The practical compromise is "user messages plus a transfer note with the state".
  • Star graph versus mesh graph. Star: no cycle is possible, but every change of topic goes through the router. Mesh: more direct transfers, cycles possible, and a system whose behaviour becomes hard to predict.

The four pairs describe a single axis: how much structural control you want to pay for in flexibility. For an application that moves money or personal data the reasonable answer is on the rigid side (star, separate tools, explicit state); for a consultation-only assistant you can afford more freedom. Start rigid and loosen only where a measurement tells you it is needed.

The traps you really fall into

  1. Missing or generic handoff_description. Without it the model routes with little information, and you spend the afternoon rewriting the triage's instructions when the problem was one line in the receiving agent.
  2. Giving domain tools to the triage. If it can look up the order, it will, and it may answer without transferring. Boundaries are enforced by removing capabilities.
  3. Forgetting that the final output comes from another agent. If the triage has an output_type and the receiving agent does not, the final answer does not have the type your code expects. Always check last_agent before assuming the shape of the result.
  4. Not recording the path. Without the list of visited agents you cannot reconstruct where a user ended up.
  5. A mesh graph without a counter. It is the recipe for the infinite bounce, and you find out about the bill the next day.
  6. Filtering everything, or filtering a string as if it were a list. Removing the entire history restarts the conversation from zero; the second case is the input_history trap seen above.
  7. Confusing handoff and agent-as-tool. If after the specialist's answer you want the first agent to take control back, the handoff is the wrong tool.

The seven traps boil down to two basic mistakes. The first is treating the triage as an object that "understands" instead of as a classifier to be measured (traps 1, 2, 4). The second is forgetting that control, once handed over, does not come back by itself (traps 3, 5, 7). The other two, about the filter, come from never looking at what the receiver actually gets: all you need is to print the history as in this article.

What you can do now

You can declare a triage with two handoffs, read the name of the tool the SDK generates and the description the model sees, and tell when a handoff is the right choice (different mandates or permissions) and when it is not. You can decide what the agent receiving control sees with a filter that handles both the string and the list, and carry the structured state with input_type and on_handoff. You can break a bounce with three different defences, and measure the routing with a confusion matrix. In the next article we look at the symmetric alternative, turning an agent into a tool, so that the caller does not lose control.

The same series also includes What an AI Agent Actually Is (and When You Don't Need One), Your First Working Agent: the Same Code on a Managed SDK and on Ollama, Guardrails: Input and Output Tripwires, The Real Traps: Prompt Injection via Tools, Infinite Loops, Runaway Cost, Unreliable Tests and Model Context Protocol: the Specification, and a Server Wired to an Agent.

Sources

All the listings were run twice with Python 3.14.4 and openai-agents 0.24.0, in a temporary virtual environment, with the SDK's tracing disabled and without calling any model: the outputs shown are identical in the two runs. The routing decisions and the text of the replies come from the fake model, so the listings prove the mechanics of the SDK and not the quality of a real model. The character counts depend on the descriptions invented for the trial. The scenarios (refunds, technical support, order numbers) are invented for the example.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects