Solutions — Three SDK Exercises
Lesson 07 · Building Your Own Agents · worked answers with commentary
Below is one clean answer per homework task, plus the why-this-not-that commentary that makes the difference between a passing solution and an idiomatic one. Compare your attempt against these before you read — even a different-but-working solution is fine, but the commentary often catches subtle traps you may not have noticed.
How to read the solutions
Each section opens with the full code you can drop into
hello_openai_agent.py, then a common mistake callout, then the
broader lesson. The snippets are meant to replace the corresponding piece of
the lab’s hello-world file with minimal edits — copy-paste, restart the process,
and you should see the new behaviour on the next Runner.run_sync(...).
Solution to Task 1 — typed output_type with Pydantic
The full snippet, dropped into hello_openai_agent.py in place of the
original hello-world block:
from pydantic import BaseModel
from agents import Agent, Runner, function_tool
class WeatherAnswer(BaseModel):
city: str
temperature_c: float
summary: str
@function_tool
def get_weather(city: str) -> str:
"""Get current weather for a city as 'temp°C, conditions'."""
return f"{city}: 4°C, light rain"
agent = Agent(
name="WeatherBot",
instructions="You answer weather questions using the get_weather tool.",
model="gpt-4o-mini",
tools=[get_weather],
output_type=WeatherAnswer,
)
result = Runner.run_sync(agent, "What's the weather in Kyiv?")
print(type(result.final_output).__name__, result.final_output)
print(f"temperature is a {type(result.final_output.temperature_c).__name__}")
Expected output is something like:
WeatherAnswer city='Kyiv' temperature_c=4.0 summary='Light rain'
temperature is a float
What changed. The SDK injects a synthetic final-answer tool keyed on
WeatherAnswer’s schema, validates the LLM’s args against the Pydantic
model, and returns the parsed instance as result.final_output. If the model
returns an invalid shape, the SDK retries (default once); if it retries-and-fails you get a
typed exception (UserError or a Pydantic ValidationError, depending
on the failure mode).
Why prefer output_type over text parsing. Parsing free-text
answers is the leading source of “looks right in dev, breaks in prod” agent bugs.
With output_type the contract is enforced at the SDK boundary — if the
agent produces something off-shape, you fail fast at the run rather than at a downstream
json.loads. It is also how the SDK shapes data for handoff history: typed outputs
flow naturally into the next agent’s input.
Common mistake — mismatched query and schema
Declaring output_type=WeatherAnswer but then asking a question that does not
fit (e.g. “Tell me a joke”). The agent will try to coerce the joke into
WeatherAnswer fields and likely fail validation. Either widen the type (a
Union over each shape) or route off-topic queries away via a handoff —
which is exactly what Task 2 sets up.
Solution to Task 2 — on_handoff callbacks for two targets
The full handoff wiring, with one closure-built callback per target so each handoff logs a distinct line:
from agents import Agent, Runner, function_tool, handoff
joke = Agent(
name="JokeBot",
instructions="Tell one short clean joke.",
model="gpt-4o-mini",
)
smalltalk = Agent(
name="SmallTalk",
instructions="Make small talk in one sentence.",
model="gpt-4o-mini",
)
def log_handoff(target_name):
def _cb(ctx):
print(f"[handoff] {ctx.agent.name} -> {target_name}")
return _cb
agent = Agent(
name="WeatherBot",
instructions=(
"Answer weather questions with the get_weather tool. "
"If the user asks for a joke, hand off to JokeBot. "
"If they greet you, hand off to SmallTalk."
),
model="gpt-4o-mini",
tools=[get_weather],
handoffs=[
handoff(joke, on_handoff=log_handoff("JokeBot")),
handoff(smalltalk, on_handoff=log_handoff("SmallTalk")),
],
)
for q in ["Tell me a joke.", "Hi there!", "What's the weather in Kyiv?"]:
result = Runner.run_sync(agent, q)
print(f"Q: {q}")
print(f" final: {result.final_output}")
print(f" last_agent: {result.last_agent.name}\n")
Expected: the joke prompt prints
[handoff] WeatherBot -> JokeBot before the joke text, the greeting prints
[handoff] WeatherBot -> SmallTalk before the small-talk reply, and the
weather question stays on WeatherBot with no handoff log line. The
last_agent.name echo confirms each route.
Each handoff target gets its own callback via the closure — that is the cleanest way
to add per-target observability without losing the handoff(...) brevity. The
ctx.agent attribute on the callback’s argument is the source
agent — useful for tracing chains (triage → tier-1 → tier-2). The SDK calls
the callback synchronously before swapping the active agent, so any side effect (a log line,
a metric increment) is guaranteed to fire before the next model call.
Common mistake — mutating control flow from on_handoff
Trying to rewrite the run’s input from inside on_handoff by reassigning
ctx or returning a value. The hook is for observability, not control
flow; the SDK ignores its return value. If you need to rewrite the input that the target
sees, use input_filter= on the same handoff(...) call —
that is the documented seam for trimming or rewriting history before the next agent runs.
Solution to Task 3 — an output guardrail that rejects empty answers
The full output-guardrail wiring plus a deliberately broken agent to make it fire:
from agents import Agent, Runner, output_guardrail, GuardrailFunctionOutput
@output_guardrail
async def reject_empty(ctx, agent, output):
is_empty = not str(output).strip()
return GuardrailFunctionOutput(
output_info={"empty": is_empty},
tripwire_triggered=is_empty,
)
broken = Agent(
name="BrokenBot",
instructions="Always reply with the empty string.",
model="gpt-4o-mini",
output_guardrails=[reject_empty],
)
try:
Runner.run_sync(broken, "Hi")
except Exception as e:
print(type(e).__name__, getattr(e, "output_info", None))
Expected:
OutputGuardrailTripwireTriggered {'empty': True}
The guardrail’s output_info dict is preserved on the exception —
your handler can log structured details about why the guardrail tripped without
having to re-parse the failed answer. To restore normal behaviour either drop the
output_guardrails=[...] argument or fix instructions so the agent
produces a real reply.
Why output guardrails matter. They are the last line of defence before a bad answer reaches a user. The model may have gone off-rails despite a careful system prompt; a one-line guardrail catches the obvious failures. The pattern composes — most production agents end up with 2–4 output guardrails (length sanity, format sanity, content policy, vertical-specific invariants).
Common mistake — guardrail as a substitute for the prompt
Treating the guardrail as an alternative to the system prompt. The prompt is your first-line guidance; the guardrail is your last-line enforcement. Drop the prompt and the model improvises bad outputs more often, even with the guardrail catching them — you trade a quiet correctness problem for a noisy exception problem. Use both: a sharp prompt to bias the model toward correctness, and a guardrail to fail loudly when bias is not enough.
What you’ve built
Across three small extensions you have made the hello-world agent typed
(Pydantic output_type), observable
(on_handoff callbacks with per-target logging), and
self-defending (an output guardrail that fails fast on broken answers).
Those are the three knobs you will reach for in every real OpenAI Agents SDK app:
- Typed outputs — push the parsing contract into the SDK boundary, not the caller.
- Handoff hooks — instrument routing decisions without changing control flow.
- Output guardrails — turn silent quality failures into typed exceptions you can catch precisely.
Closing tip: Now you have the SDK in your fingertips. Lesson 08 puts it to work with a customer-support triage that hands off to billing, technical, and account specialists — the canonical OpenAI Agents SDK use case.