Lab 02 — Handoffs (the SDK's multi-agent mechanism)
Phase 21 · Lab 02 · Phase README · Warmup
The problem
The OpenAI Agents SDK has two ways to compose agents, and telling them apart is a classic interview probe:
- Agents-as-tools — an orchestrator calls a sub-agent like a tool; the sub-agent runs, its
result returns, and the orchestrator keeps control. (That's just Lab 01 with a tool whose body
is another
Runner.run.) - Handoffs — one agent delegates control to another. The conversation transfers, the new agent takes over the loop, and it produces the final output. Control does not come back.
Build handoffs — the SDK's headline feature. The elegant mechanism: a handoff is exposed to the
model as a tool named transfer_to_<agent>. When the model "calls" it, the Runner doesn't run
a function — it switches the active agent and continues the same loop, so RunResult.last_agent
is whoever finished. Two production knobs: on_handoff (a callback on transfer) and
input_filter (reshape the conversation the specialist inherits).
What you build
| Piece | What it does |
|---|---|
default_handoff_tool_name | "Billing Agent" → "transfer_to_billing_agent" (the SDK convention) |
Handoff / handoff() | target agent + custom tool_name + on_handoff + input_filter |
Agent.handoff_map | normalize handoffs (bare agents or Handoffs) and index by transfer_to_* name |
Runner._run_turns | the loop, now classifying each tool call as a real tool or a handoff, and switching the active agent on a handoff |
HandoffOutputItem | the trace marker for a control transfer (source → target) |
File map
| File | Role |
|---|---|
lab.py | your implementation (fill the # TODOs) |
solution.py | reference + a worked example (python solution.py): triage → billing specialist, multi-hop, and an unwired-handoff error |
test_lab.py | 20 tests: naming, the switch, last_agent, unknown handoff, on_handoff, input_filter, multi-hop, tools-alongside-handoffs, max_turns across handoffs, determinism |
requirements.txt | pytest only |
Run it
pytest test_lab.py -v # your lab.py (red until you implement)
LAB_MODULE=solution pytest test_lab.py -v # the reference (green)
python solution.py # the triage->specialist worked example
Success criteria
-
A handoff is exposed as
transfer_to_<agent>; a bareAgentinhandoffsnormalizes toHandoff(agent)with that default name. -
A triage agent that emits
transfer_to_<specialist>switches the active agent; the specialist produces the final output andRunResult.last_agentis the specialist. -
A
transfer_to_*call to an agent that isn't wired up raisesModelBehaviorError. -
on_handofffires exactly once with the source/target;input_filterreshapes what the new agent inherits (drop history → the specialist sees fewer items). -
Multi-hop (router → tier1 → tier2) ends with
last_agent == tier2; real tools still run alongside handoffs;max_turnsis enforced across handoffs so two agents can't bounce control forever. -
All 20 tests pass under both
labandsolution.
How this maps to the real stack
- Handoffs are real and central.
Agent(handoffs=[...])accepts bare agents orhandoff(agent, ...)objects; the SDK auto-generates thetransfer_to_<agent>tool and, when the model calls it, swaps the running agent — exactly this.RunResult.last_agentis the SDK's real field and the reason you can persist "who to resume as" for the next turn. on_handoffandinput_filterare the SDK's real parameters.input_filterreceives aHandoffInputData(the full item history) and returns a filtered one; the SDK ships helpers likehandoff_filters.remove_all_tools.on_handoffcan also declare aninput_typeso the LLM must pass structured handoff arguments — the SDK validates them with pydantic before firing.- Two multi-agent styles, one decision. Handoffs = decentralized control (each agent owns its turn; good for triage/routing where the specialist should fully take over). Agents-as-tools = centralized control (an orchestrator stays in charge; good when you need to combine several sub-results). This is the same axis as LangGraph's explicit supervisor graph (Phase 18) vs CrewAI's role delegation — the SDK's answer is "handoffs are just tools, so the loop is unchanged."
Limits. A real handoff carries the whole Responses-API item history and a run context; the
model emits native tool-call JSON for the transfer, and a bad input_type payload is a validation
error, not a silent pass. Cycles are bounded by max_turns exactly as here. The control-transfer
mechanism — a tool call that switches the active agent — is the faithful core.
Extensions (your own machine)
- Add an
input_typeto a handoff (a dataclass of{reason: str}) and have the Runner validate the transfer arguments before firingon_handoff. - Implement the agents-as-tools alternative: wrap
Runner.run_sync(sub_agent, x)inside a@function_tooland compare the traces — control returns to the orchestrator instead of transferring. - Build a real triage bot with the SDK (
pip install openai-agents): atriage_agentwithhandoffs=[billing_agent, refund_agent]and confirmresult.last_agentafter a routed run.
Interview / resume signal
"Implemented the OpenAI Agents SDK handoff mechanism: a handoff is a
transfer_to_<agent>tool that, when called, makes the Runner switch the active agent and continue the same loop, solast_agentreflects who finished. Addedon_handoffcallbacks andinput_filterconversation-reshaping, multi-hop delegation, and amax_turnsbudget enforced across handoffs to bound control cycles — all with injected, deterministic per-agent policies."