« Phase 21 README · Warmup · Hitchhiker's · Principal Deep Dive · Core Contributor · Staff Notes

Phase 21 — Deep Dive: the OpenAI Agents SDK

The whole SDK is a lie you tell yourself so you can stop thinking about the loop. There is one load-bearing idea — the Runner loop — and every "primitive" (tools, handoffs, guardrails, sessions, streaming, structured output) is a branch, a filter, or a wrapper on that one loop. If you can hold the loop's data flow and its invariants in your head, nothing in the SDK can surprise you. This document is about the mechanism, not the ergonomics.

The one data structure: the item list

The Runner owns exactly one piece of mutable state per run: an ordered list of items. An item is a tagged record of something that happened — a model message, a tool call the model proposed, the tool's output, a handoff. The list is the conversation as the model will see it next turn, and it is append-only within a turn. Everything else (final_output, last_agent, streaming events) is derived from this list.

Get the taxonomy right, because the tests in Lab 01 assert on it:

  • message item — the model's text/JSON output for a turn.
  • tool-call item — a proposal: {name, arguments}. It is not an execution. It carries an id so its output can be correlated.
  • tool-output item — the result of running one tool-call, on the trusted side. This is the observation fed back.
  • handoff item — records that control switched agents.

The critical property: a tool-call item and its tool-output item are two separate entries, produced in different phases of the same turn. The model emits the call; the Runner emits the output. Conflating them is the single most common way people mis-model the loop, and it is exactly why a hallucinated tool name is a structural error, not a recoverable one — there is nothing on the trusted side that can produce a matching output item.

Control flow: one generator, three surfaces

The naive design ships three functions — run, run_sync, run_streamed — and duplicates the loop three times. That is a maintenance disaster and a correctness hazard: three copies drift, and streaming behaves subtly differently from sync. The SDK's move, which Lab 01 mirrors exactly, is to write the loop once as a generator that yields each new item and returns the RunResult, then wrap it three ways:

  • run_sync — drain the generator to exhaustion, keep the return value.
  • run — the async form; await the same drive.
  • run_streamed — expose the yielded items to the caller as stream_events(), then surface the final fields.

One implementation, three surfaces. The invariant this buys you is that streaming and non-streaming are observably identical in what happens and in what order — streaming just lets you watch. If you ever find a bug that reproduces under run_streamed but not run_sync, the generator abstraction has leaked, and that is a real bug, not a feature of streaming.

The loop, stated as invariants

items = to_items(input)
current = agent
for turn in range(1, max_turns + 1):
    output = current.model(items)     # untrusted proposal
    items += output
    if output.has_tool_calls():
        for call in output.tool_calls:
            items += run_tool(call)   # trusted execution -> observation
        continue                      # model MUST see results before ending
    if output.is_handoff():
        current = output.handoff.target
        continue                      # same loop, new driver
    return RunResult(final_output=..., new_items=items[start:], last_agent=current)
raise MaxTurnsExceeded(...)

The invariants worth naming:

  1. Termination is bounded. The loop runs at most max_turns model calls. Every non-terminal branch (tool_calls, handoff) continues, so the only exits are a plain message or the budget guard. Without the guard, a model that always calls a tool is a non-terminating program.
  2. The model never touches the trusted side. output is a proposal object. run_tool is the only place a real function executes, and it validates arguments against the schema before dispatch. Schema-validation-precedes-execution is not politeness; it is the boundary that keeps stochastic arguments from becoming arbitrary calls.
  3. A tool phase must feed back. After running tools you continue; you cannot return a final output in the same turn as a tool call, because the model has not yet seen the observation. This is why the loop, not the model, decides when a run is "done."
  4. current is the only handoff state. A handoff mutates one variable. There is no stack, no return address — which is the whole point of §"why a handoff is not a special case" below.

A worked trace

Take the phase's support scenario: a triage agent, a refund specialist, a lookup_order tool on triage, and a process_refund tool on the specialist. Input: "I want a refund for order 42."

  • Turn 1. current = triage. triage.model(items) proposes a tool call lookup_order(order_id=42).
    • items: [msg?] → append tool-call lookup_order.
    • run_tool validates {order_id: 42} against the schema, dispatches, gets {"status":"delivered"}. Append tool-output. continue.
    • items now: [tool-call lookup_order, tool-output {...}].
  • Turn 2. triage.model(items) sees the order is delivered and proposes transfer_to_refund_agent. The Runner recognizes the name as a registered handoff, appends a handoff item, fires on_handoff, applies any input_filter, sets current = refund_agent. continue.
  • Turn 3. current = refund_agent now drives. It proposes process_refund(order_id=42). Validate, dispatch, append tool-output {"refund_id": 9001}. continue.
  • Turn 4. refund_agent.model(items) emits a plain message: "Refunded, id 9001." No tool calls, no handoff → return.

RunResult.final_output = "Refunded, id 9001.", new_items is the ordered five-item trail (two tool-call/output pairs plus the handoff), and — this is the tell — last_agent = refund_agent, not the triage agent you started with. You persist last_agent so the next turn resumes as the specialist. The turn budget is consumed across the handoff: four model calls counted against one max_turns, not two per-agent budgets. If triage and refund bounced control back and forth, that shared counter is the only thing that stops an infinite ping-pong.

Why the naive approaches fail at the mechanism level

Why a handoff is a tool, not a special case. The tempting design is a dedicated handoff field the loop checks separately — a first-class control-transfer op. It fails because the model has to choose to hand off, and the only channel the model has to express structured choices is the tool-call interface. If handoff were a special case, you would need a second, parallel decision channel the model doesn't have. By exposing handoffs as transfer_to_<agent> tools, the model's entire decision surface is uniform — "which of my available tools do I call" — and the Runner disambiguates on the trusted side by name lookup. The loop body doesn't grow a branch per feature; it grows one branch total (is_handoff), keyed on a naming convention. That is why adding a tenth specialist changes zero lines of loop code.

Why schema validation must precede execution. If you dispatch first and validate never, a wrong-typed argument (order_id="forty-two") crashes inside your function with a stack trace the model can't act on — or worse, silently coerces and does the wrong thing. Validate-then-dispatch converts a malformed proposal into a structured error item the model can observe and retry, and keeps the failure inside the trust boundary. The ordering is the boundary. Reverse it and the model's stochastic output reaches your code unfiltered.

Why "just raise max_turns" is not a fix. The budget is an invariant on termination, not a throttle. A run that hits the cap is telling you the model is looping without converging; more turns buys more identical loops. The mechanism-level fix is to change what the model sees (add a verification step, a handoff, a better tool result), not to loosen the guard that keeps the program terminating.

The mental compression

Hold three things: the item list is the only state; the loop is the only control flow; validation-before-execution is the only boundary. Tools append output items, handoffs mutate current, guardrails wrap the entry and exit, sessions wrap the whole call with a read/write. Every primitive is a small perturbation of one generator. Rebuild that generator — which is precisely Lab 01 — and the SDK stops being a framework and becomes a data structure you understand.