Lesson 07 OpenAI Agents SDK Intro
Theme:

Homework — Three OpenAI Agents SDK Exercises

Lesson 07 · Building Your Own Agents · ~45 min

All three tasks below extend hello_openai_agent.py from the Lab 07 walk-through — the WeatherBot agent with the @function_tool get_weather, the JokeBot handoff target, and the Runner.run_sync(...) driver. Keep that file open as your base; each task is a focused extension that exercises a different SDK primitive (typed output, handoff callbacks, output guardrails).

Before you start

Make sure python hello_openai_agent.py still runs end-to-end against the original weather prompt and prints a natural-language answer followed by [last agent: WeatherBot]. If it doesn’t, revisit the lab guide before tackling the homework — debugging the extensions is much easier on a working baseline. Each task is self-contained: feel free to copy hello_openai_agent.py to hw1.py, hw2.py, hw3.py so you can iterate without breaking the lab file.

Task 1 — Add a typed output_type and observe parsing

By default the SDK returns the model’s final text in result.final_output — a plain string you would have to parse yourself if you wanted structured fields. The SDK’s recommended alternative is to declare a Pydantic model and pass it as output_type= on the Agent constructor; the SDK then asks the model to emit JSON that matches the schema and hands you back an already-validated instance.

At the top of your file, define:

from pydantic import BaseModel

class WeatherAnswer(BaseModel):
    city: str
    temperature_c: float
    summary: str

Then add output_type=WeatherAnswer to the WeatherBot Agent(...) constructor:

agent = Agent(
    name="WeatherBot",
    instructions="You answer weather questions using the get_weather tool.",
    model="gpt-4o-mini",
    tools=[get_weather],
    output_type=WeatherAnswer,
)

Re-run the original weather question (“What’s the weather in Kyiv?”) and inspect result.final_output:

result = Runner.run_sync(agent, "What's the weather in Kyiv?")
print(type(result.final_output))
print(result.final_output.city)
print(result.final_output.temperature_c)
print(result.final_output.summary)

Then write a 2-sentence note in a comment at the top of the file explaining what changed in the agent’s observable behaviour and why you should usually prefer output_type= over parsing a free-text answer yourself.

✓ Self-check: type(result.final_output) prints <class '__main__.WeatherAnswer'> (not str); result.final_output.temperature_c is a float (you can do arithmetic on it, e.g. + 1.0, without a TypeError); your 2-sentence note explains the “why” in your own words.

Task 2 — Add a handoff on_handoff callback

The handoffs=[other_agent] shortcut wraps the target in a default handoff(other_agent) with no callbacks. To audit or log transfers you reach for the explicit handoff(...) form and pass an on_handoff= callback that fires at the moment control is transferred (before the target agent runs).

Replace the existing handoffs=[joke] on WeatherBot with the explicit form:

from agents import handoff

def on_joke_handoff(ctx):
    print(f"[handoff] {ctx.agent.name} -> JokeBot")

agent = Agent(
    name="WeatherBot",
    instructions=(
        "You answer weather questions using the get_weather tool. "
        "If the user asks for a joke, hand off to JokeBot."
    ),
    model="gpt-4o-mini",
    tools=[get_weather],
    handoffs=[handoff(joke, on_handoff=on_joke_handoff)],
)

Run with the joke prompt (“Forget the weather. Tell me a joke.”) and confirm the [handoff] log line prints before the joke output.

Now extend the routing. Define a third agent — a smalltalk agent that handles greetings — with its own on_smalltalk_handoff callback:

smalltalk = Agent(
    name="SmallTalk",
    instructions="You respond to greetings warmly and concisely.",
    model="gpt-4o-mini",
)

def on_smalltalk_handoff(ctx):
    print(f"[handoff] {ctx.agent.name} -> SmallTalk")

Update WeatherBot’s instructions= so it knows to route greetings to SmallTalk and jokes to JokeBot, and put both callbacks in the handoffs= list:

handoffs=[
    handoff(joke, on_handoff=on_joke_handoff),
    handoff(smalltalk, on_handoff=on_smalltalk_handoff),
]

Run three prompts in turn (“Hello!”, “Tell me a joke.”, “Weather in Kyiv?”) and verify the right callback fires (or stays silent) for each.

✓ Self-check: the joke prompt prints [handoff] WeatherBot -> JokeBot followed by a joke, and result.last_agent.name == "JokeBot"; the greeting prompt prints [handoff] WeatherBot -> SmallTalk followed by a greeting, and result.last_agent.name == "SmallTalk"; the weather prompt prints no [handoff] log line and result.last_agent.name == "WeatherBot".

Callback signature gotcha

The on_handoff= callable’s signature depends on whether you also pass input_type=. With no input_type the SDK calls on_handoff(ctx); with an input_type (Pydantic model) it calls on_handoff(ctx, parsed_input). If you get a TypeError: missing 1 required positional argument at handoff time, that mismatch is the first thing to check.

Task 3 — Write an output guardrail that rejects empty answers

Guardrails are the SDK’s cheap, deterministic safety net around the model call. Input guardrails run before the model sees the user input; output guardrails run after the model produces a final answer. Both return a GuardrailFunctionOutput whose tripwire_triggered flag — when true — short-circuits the run with a typed exception so your caller code never sees the bad output.

Add an output guardrail that fails the run if the final output is empty or whitespace-only:

from agents import 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,
    )

Wire it onto one of your agents via output_guardrails=[reject_empty] (the WeatherBot is fine):

agent = Agent(
    name="WeatherBot",
    instructions="You answer weather questions using the get_weather tool.",
    model="gpt-4o-mini",
    tools=[get_weather],
    output_guardrails=[reject_empty],
)

To force the failure (so you can see the guardrail fire), temporarily replace the agent’s instructions with something that compels an empty answer — e.g. “Always reply with the empty string. Do not call any tool. Do not explain.” — and re-run any prompt. Wrap the call in a try/except so you can see the typed exception:

from agents.exceptions import OutputGuardrailTripwireTriggered

try:
    result = Runner.run_sync(agent, "What's the weather in Kyiv?")
    print(result.final_output)
except OutputGuardrailTripwireTriggered as e:
    print(f"[guardrail tripped] {e}")

Once you’ve seen the tripwire fire, restore the original instructions so the agent works normally again — then re-run the original weather prompt and confirm the guardrail stays silent on a non-empty answer.

✓ Self-check: with the “empty string” instructions in place, the except branch fires and you see a [guardrail tripped] line (no traceback escaping the script); with the original instructions restored, the same script prints a normal weather answer with no guardrail output; you understand which side of the model call the output guardrail sits on (after).

View solutions →