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

Phase 21 — Core Contributor Notes: the OpenAI Agents SDK

Notes from the perspective of someone who has read the real openai-agents source, not just the docs. The goal here is to be accurate about how the actual library implements the primitives you rebuilt in the labs, where our stdlib miniature diverges from it on purpose, and which of its sharp edges you will only learn by getting cut. Where an exact internal detail is version-sensitive, I describe the pattern the SDK commits to rather than inventing a symbol.

The Swarm lineage, and why the SDK re-shaped it

The Agents SDK is the productized descendant of Swarm, OpenAI's earlier experimental framework. Swarm's central insight was already the important one: a "routine" is an agent, and handing off is done by a function that returns another agent. But Swarm was explicitly labeled educational/experimental — thin on typing, tracing, guardrails, sessions, and streaming, and not meant to be run in production. The Agents SDK kept the philosophy ("few primitives, Python-first, orchestrate with the language, not a DSL") and hardened it: pydantic-driven schemas, a first-class RunResult, built-in tracing with exporters, real session backends, guardrails as a typed primitive, and a Runner with a defined turn budget and streaming surface. The lineage matters because it explains a design choice people find strange — why handoff is a tool. It was a tool-shaped function return in Swarm; the SDK formalized the shape rather than replacing it.

@function_tool: pydantic does the real work

Our Lab 01 derives the schema with stdlib inspect, mapping a handful of scalar annotations (int"integer", str"string", bool"boolean") and treating a parameter-without-default as required. That is enough to make the signature-to-schema step legible, and it is deliberately a miniature. The real SDK builds the tool schema through pydantic: it constructs a model from the function signature and emits that model's JSON schema. The consequences of that choice are the reason it is not "just inspect":

  • Rich types compose for free — list[int], nested BaseModels, Literal[...], enums, Optional, defaults, and constrained fields all flow into the schema because pydantic already knows how to serialize them.
  • Per-argument descriptions get parsed from the docstring and attached to the right properties, so the model sees documentation at the parameter level, not just the function level.
  • The arguments the model sends back are validated against the schema by pydantic before your function is invoked. A wrong-typed or missing-required argument becomes a structured validation error, not a Python TypeError deep in your code.

That last point is the load-bearing one for the trust boundary: validation happens at the boundary, in the framework, on data the model produced. Our miniature skips it (we cover the common scalars); the real SDK makes it the default. If you internalize one difference between the lab and the library, make it this.

on_invoke_tool: the single execution seam

In the SDK, a tool is not just a callable — it is an object whose executor is a field, conventionally the thing that actually runs the underlying function. Everything the framework does around a tool call funnels through that one seam: argument validation, the actual invocation, and turning exceptions into observations. This is why the trust boundary is not a slogan in this codebase — it is a location. When you want to add an allow-list, a sandbox, an authorization check, or a human-approval step, you are wrapping or gating that executor, never editing a prompt. A committer knows that if a safety control is not expressible as something around the tool executor, it does not belong in the tool layer.

ModelBehaviorError vs a tool exception — a real distinction, not pedantry

These are two different failure classes and the SDK treats them differently, on purpose:

  • A tool raised an exception. The tool exists, the model called it correctly, the code failed (network down, record missing). By default the SDK routes this through a failure handler (a failure_error_function) that converts the exception into a tool-output message fed back to the model — the error becomes an observation, and the agent gets a chance to recover. This is recoverable and, in a well-designed agent, expected.
  • The model called a tool that does not exist (a hallucinated name, or a transfer_to_ handoff to an agent that isn't wired up). There is nothing on the trusted side that can produce a matching output. The SDK raises a ModelBehaviorError, because the model did something structurally impossible — it broke the contract of "only call tools you were given." This is not recoverable by feeding an observation back; it is a signal that the model is malfunctioning.

Lab 01 implements both paths and tests them, which is the right instinct: if you collapse them into one "tool error," you lose the ability to tell "the world failed" from "the model is broken," and those want different alerts and different retries.

Guardrails: concurrent-with-cancellation, and the tripwire object

Each guardrail returns a GuardrailFunctionOutput(output_info, tripwire_triggered) — the boolean is the decision, output_info is for logs and downstream context. Two implementation facts the docs state and the source enforces:

  • Input guardrails run concurrently with the first model call, and a tripwire cancels the in-flight agent work, raising InputGuardrailTripwireTriggered. The point is latency: on clean traffic you pay max(guardrail, model), not the sum. Our Lab 03 runs the guardrail first, sequentially — same safety guarantee (no output escapes a tripwire) with simpler mechanics, at the cost of adding guardrail latency to the happy path. That is the one behavioral simplification in the lab you should be able to name in an interview.
  • Output guardrails run on the final output and raise OutputGuardrailTripwireTriggered, gating the answer before it reaches the caller. A blocked run must not leave side effects behind — in the lab, a tripwired run writes nothing to the session, which mirrors the intent that a rejected turn is as if it never happened.

Sessions: a four-method protocol with swappable backends

The SDK ships SQLiteSession (rows in a real sqlite table keyed by session_id), OpenAIConversationsSession, and the ability to bring your own backend — Redis, Postgres, SQLAlchemy — behind the same tiny protocol: get_items, add_items, pop_item, clear_session. The Runner reads before the turn and writes after a successful turn; that is the entire "memory" mechanism. The design decision worth appreciating is that the interface is deliberately small enough that a custom backend is a weekend, and deliberately item-oriented (not string-oriented) so pruning/summarization has a place to live. Our lab implements SQLiteSession for real on stdlib sqlite3, ordering rows by an autoincrement id rather than timestamps or uuids — a determinism choice, so the same run replays identically. The real SDK does not have to be that strict about ordering, but if you plan to wrap it in Temporal, you will re-derive exactly that constraint.

run_streamed event ordering — the surface that leaks

Runner.run_streamed returns a streaming result immediately; you iterate stream_events() to receive items as they are produced — token deltas, tool-call items, tool-output items, handoff items — and then read the final fields. The gotcha a committer knows: the events are the loop's items in loop order, which means a tool-call event arrives before its tool-output event, and a handoff event marks the exact point the active agent changed. Consumers that assume they can read final_output mid-stream, or that reorder events, break in ways that do not reproduce under run_sync. The SDK goes to some length to keep streaming and non-streaming observably identical in what happens and in what order; when they diverge, it is a bug in the generator abstraction, not a property of streaming. Our lab preserves this by driving all three surfaces from one generator that yields each item — the same design decision, for the same reason.

Tracing: spans and exporters as a first-class layer

Tracing is not bolted on; the SDK wraps every run in a trace of spans — per run, agent turn, model generation, tool call, handoff, and guardrail — and ships it to the OpenAI dashboard by default, with a processor/exporter interface so you can fan out to Logfire, Braintrust, LangSmith, AgentOps, or OpenTelemetry backends. The maintainer's framing: the span tree is the same structure as new_items, enriched with timing, token counts, and parent/child nesting. In the lab, asserting on new_items is asserting on the trace — the tests verify the exact sequence of events, which is only possible because the loop records every step as it happens. If you understand why the lab can make those assertions, you understand what tracing buys in production: the run becomes inspectable after the fact without any instrumentation you had to write.

What our miniature simplifies, in one list

  • inspect scalars instead of pydantic (no rich types, no argument validation).
  • Guardrails run first (sequential) instead of concurrent-with-cancellation.
  • The injected model is a pure policy over the item list instead of a real LLM — the substitution that makes the whole thing testable offline.
  • Deterministic ids/ordering (counters, autoincrement) instead of timestamps/uuids.

None of these change the mechanism. They strip the production hardening so the mechanism is legible. That is the point of building the miniature: once you have written the loop, the real SDK reads as your loop plus pydantic, plus concurrency, plus exporters — and none of those are mysteries anymore.