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

PieceWhat 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_mapnormalize handoffs (bare agents or Handoffs) and index by transfer_to_* name
Runner._run_turnsthe loop, now classifying each tool call as a real tool or a handoff, and switching the active agent on a handoff
HandoffOutputItemthe trace marker for a control transfer (source → target)

File map

FileRole
lab.pyyour implementation (fill the # TODOs)
solution.pyreference + a worked example (python solution.py): triage → billing specialist, multi-hop, and an unwired-handoff error
test_lab.py20 tests: naming, the switch, last_agent, unknown handoff, on_handoff, input_filter, multi-hop, tools-alongside-handoffs, max_turns across handoffs, determinism
requirements.txtpytest 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 bare Agent in handoffs normalizes to Handoff(agent) with that default name.
  • A triage agent that emits transfer_to_<specialist> switches the active agent; the specialist produces the final output and RunResult.last_agent is the specialist.
  • A transfer_to_* call to an agent that isn't wired up raises ModelBehaviorError.
  • on_handoff fires exactly once with the source/target; input_filter reshapes 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_turns is enforced across handoffs so two agents can't bounce control forever.
  • All 20 tests pass under both lab and solution.

How this maps to the real stack

  • Handoffs are real and central. Agent(handoffs=[...]) accepts bare agents or handoff(agent, ...) objects; the SDK auto-generates the transfer_to_<agent> tool and, when the model calls it, swaps the running agent — exactly this. RunResult.last_agent is the SDK's real field and the reason you can persist "who to resume as" for the next turn.
  • on_handoff and input_filter are the SDK's real parameters. input_filter receives a HandoffInputData (the full item history) and returns a filtered one; the SDK ships helpers like handoff_filters.remove_all_tools. on_handoff can also declare an input_type so 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_type to a handoff (a dataclass of {reason: str}) and have the Runner validate the transfer arguments before firing on_handoff.
  • Implement the agents-as-tools alternative: wrap Runner.run_sync(sub_agent, x) inside a @function_tool and compare the traces — control returns to the orchestrator instead of transferring.
  • Build a real triage bot with the SDK (pip install openai-agents): a triage_agent with handoffs=[billing_agent, refund_agent] and confirm result.last_agent after 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, so last_agent reflects who finished. Added on_handoff callbacks and input_filter conversation-reshaping, multi-hop delegation, and a max_turns budget enforced across handoffs to bound control cycles — all with injected, deterministic per-agent policies."