Lab 01 — Agent + Runner Loop + Function Tools

Phase 21 · Lab 01 · Phase README · Warmup

The problem

The OpenAI Agents SDK is famous for being tiny: a few primitives (Agents, Handoffs, Guardrails, Sessions) over one engine — the agent loop inside Runner. If you can only call Runner.run(agent) you are a user; if you can rebuild the loop, you can own the deployment. So build it: an Agent, a @function_tool decorator that auto-derives the tool schema from a Python signature, and a Runner whose loop calls the model, runs the tools it asked for, feeds the results back, and repeats until a final output — with a max_turns guard. The model is injected as a deterministic policy, so the whole thing is offline and testable.

What you build

PieceWhat it does
function_tooldecorator: Python function → FunctionTool, deriving the JSON schema from the signature (types + which params are required) and the description from the docstring
Agentname, instructions, tools, model (injected policy), output_type; rejects duplicate tools
Runner._run_turnsthe loop: model → tool calls → observations → repeat → final output; max_turns guard
Runner.run_sync / run / run_streamedthe three entry points, all driving the one loop
RunResultfinal_output, new_items, last_agent — the real result surface

File map

FileRole
lab.pyyour implementation (fill the # TODOs)
solution.pyreference + a worked example (python solution.py) driving a tool-using agent
test_lab.py20 tests: schema derivation, the loop, max_turns, unknown-tool + tool-exception, streaming, async, output_type, 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 worked example

Success criteria

  • @function_tool turns def add(x: int, y: int, note: str = "") into a schema whose properties carry the right JSON types and whose required is exactly ["x", "y"].
  • A no-tool agent returns its final output in one turn; a tool-using agent loops (tool call → ToolCallOutputItem fed back → final message).
  • RunResult.new_items contains the ToolCallItem and ToolCallOutputItem, and last_agent is the agent that finished.
  • max_turns raises MaxTurnsExceeded; an unknown tool raises ModelBehaviorError; a tool that itself raises becomes an ERROR: observation the loop can recover from.
  • run_streamed().stream_events() yields the items in order and matches run_sync; the async Runner.run drives the same loop; output_type=int coerces the final output.
  • All 20 tests pass under both lab and solution.

How this maps to the real stack

  • Agent / Runner are the SDK's real classes. Runner.run is async, run_sync wraps it, run_streamed returns a RunResultStreaming you iterate with async for event in result.stream_events() — exactly the surface here. The real loop also threads a run context, a tracing span per turn, and ModelSettings; the control flow is what you built.
  • @function_tool is real. The SDK derives the JSON schema from the signature via pydantic (so list[int], nested models, enums, Literals, and rich descriptions from docstring arg sections all work); we use inspect to make the signature→schema step legible. on_invoke_tool is the SDK's real field name for the executor.
  • The loop — "call the model; if it returned tool calls, run them and feed results back; else it's the final output; stop at max_turns" — is verbatim the SDK's Runner behavior, and it is the same loop as a LangGraph ReAct node (Phase 18) and the ReAct runtime (Phase 01). The framework's value is the ergonomics around it, not a different algorithm.
  • output_type maps to the SDK's structured-output feature: pass a pydantic model and the final output is parsed/validated into it. Our callable (int) is the one-line stand-in.

Limits. A real model is non-deterministic and emits native tool-call JSON that must be parsed and validated (Phase 02); real tools are async and may need retries/timeouts (Phase 08); the real schema derivation is far richer than six scalar types. The engine you built — the loop, the turn budget, tool dispatch, the result surface — is the faithful part.

Extensions (your own machine)

  • Install the real SDK (pip install openai-agents), define the same get_weather tool with @function_tool, and diff your derived schema against tool.params_json_schema.
  • Add ModelSettings (temperature, tool_choice, parallel_tool_calls) as a dataclass and have the loop respect tool_choice="required" (must call a tool) vs "none" (must not).
  • Add a RunContextWrapper passed to each tool so tools can read run-scoped dependencies — the SDK's dependency-injection story.

Interview / resume signal

"Rebuilt the OpenAI Agents SDK core — Agent, a @function_tool decorator that auto-derives the JSON schema from the Python signature, and a Runner implementing the agent loop (model → tool dispatch → feed results back → repeat, guarded by max_turns) with sync, async, and streaming entry points over one shared generator, plus structured output_type coercion. The model is injected as a deterministic policy, so the loop is unit-tested offline."