Lesson 07 OpenAI Agents SDK Intro
Theme:

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:

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.

← Back to homework