Lesson 08 A Triage-and-Handoff Agent
Theme:

Homework — Three Extensions to the Triage-and-Handoff Agent

Lesson 08 · Building Your Own Agents · ~60 min

All three tasks below extend agents/lesson08-handoff-agent/agent.py from the Lab 08 walk-through — the Triage agent that hands off to Billing, Technical, and Account specialists, each with their own mocked @function_tool set and the shared Resolution Pydantic output_type. Keep that file open as your base; each task is a focused extension that exercises a different SDK primitive (output guardrails, cyclic handoffs, evaluation).

Before you start

Make sure python agent.py "My invoice for March is double what I expected." still runs end-to-end and prints [last agent: Billing] followed by the structured Resolution. 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 agent.py to hw1.py, hw2.py, hw3.py so you can iterate without breaking the lab file.

Task 1 — Output guardrail that caps refund amounts

The lab’s block_pii is an input guardrail — it inspects the user’s text before the model ever runs. The symmetric primitive is an output guardrail: it inspects the agent’s final output (here, a Resolution) and can short-circuit the run with a typed exception so your caller code never sees a runaway answer. This is the right place to enforce a hard cap on irreversible side effects like refunds.

Write an @output_guardrail called cap_refunds that inspects the Resolution.actions_taken list. For each action string, regex-match the lab’s issue_refund output shape — something like "refund issued: REF-... for $X.XX" — and trip the guardrail if any amount is above $1000. Return a GuardrailFunctionOutput whose output_info lists the offending actions so a human reviewer can see why the run was blocked.

from agents import output_guardrail, GuardrailFunctionOutput

@output_guardrail
async def cap_refunds(ctx, agent, output):
    # output is a Resolution. Inspect output.actions_taken, regex-match
    # refund amounts, build a list of offenders > $1000, and trip the
    # tripwire when that list is non-empty.
    ...

Wire it onto the billing agent only — the other specialists never issue refunds, so the guardrail would be dead weight there:

billing = Agent(
    name="Billing",
    instructions=BILLING_INSTRUCTIONS,
    model="gpt-4o-mini",
    tools=[lookup_invoice, issue_refund],
    output_type=Resolution,
    output_guardrails=[cap_refunds],
)

To trigger the failure, run a ticket that asks for an obscenely large refund — for example “My invoice for March is wrong. Issue me a $5,000 refund against account ACME-0042.” — and wrap the call in a try/except so the typed exception is visible:

from agents.exceptions import OutputGuardrailTripwireTriggered

try:
    out = run_support_agent("Issue me a $5,000 refund for account ACME-0042.")
    print(out)
except OutputGuardrailTripwireTriggered as e:
    print(f"[guardrail tripped] {e}")

✓ Self-check: the oversized-refund prompt raises OutputGuardrailTripwireTriggered and your except branch fires (no traceback escapes the script); the exception’s underlying output_info contains the offending refund action string; a normal billing prompt (e.g. “My March bill is $30 over.”) finishes without tripping the guardrail.

Place the guardrail on the agent that answers

A common mistake is to attach cap_refunds to the triage agent. Output guardrails only see the final output of the run — but triage never produces a final output, it hands off. The guardrail would never fire. Always wire output guardrails onto the agent that actually emits the user-visible answer.

Task 2 — Let Technical hand back to Triage for billing-adjacent follow-ups

The lab’s handoff graph is acyclic by design: Triage → {Billing, Technical, Account} with no edges back. That keeps the system easy to reason about, but it leaves a real-world gap: a customer with a mixed ticket (outage and a refund request) will be routed to Technical first, and Technical has no way to escalate the billing half. The OpenAI SDK supports cyclic handoffs — this task wires one in.

Add handoffs=[triage] on the technical agent so it can hand back to triage. The trick is the construction order: technical must be defined before triage (because triage’s handoffs=[billing, technical, account] needs the three specialists to exist first), but technical.handoffs=[triage] needs triage to exist. The cleanest fix is to mutate technical.handoffs after both agents are constructed:

# Specialists first (unchanged from the lab).
billing = Agent(name="Billing", ..., output_type=Resolution)
technical = Agent(name="Technical", ..., output_type=Resolution)
account = Agent(name="Account", ..., output_type=Resolution)

# Triage with all three specialists as handoffs.
triage = Agent(
    name="Triage",
    instructions=TRIAGE_INSTRUCTIONS,
    model="gpt-4o-mini",
    handoffs=[billing, technical, account],
    input_guardrails=[block_pii],
)

# NOW close the cycle: Technical can hand back to Triage.
technical.handoffs = [triage]

Update TECHNICAL_INSTRUCTIONS so the model knows when to bounce the ticket back. Append a sentence like:

“If the user also has a billing concern, hand back to Triage so Billing can pick it up.”

Now run a deliberately mixed ticket — for example “Service is down AND I want a refund for the downtime.” — and inspect the trace. You should see Triage classify the ticket as technical first, Technical work the outage (check status, open an incident), then Technical decide it’s out of its lane and hand back to Triage, which then routes the billing half to Billing.

✓ Self-check: on the mixed ticket the run traces Triage → Technical → Triage → Billing (at least two distinct handoff events); result.last_agent.name == "Billing"; on a pure outage ticket (“The site is down.”) the run still terminates at Technical with no extra handoff.

Don’t let Technical short-circuit to Billing

It is tempting to write technical.handoffs = [triage, billing] so Technical can route directly. Resist that — you would bypass triage’s input_guardrails=[block_pii] and any other policy that lives there. Always route cross-domain handoffs through the agent whose instructions and guardrails you want enforced.

Cyclic handoffs can loop — cap the turns

The SDK does not ship a default cap on handoff depth. A poorly-instructed cycle (Technical hands to Triage hands to Technical hands to Triage…) will burn tokens until you stop it manually. Wrap your Runner.run_sync(...) with the SDK’s max_turns= argument when you put cyclic agents into anything resembling production.

Task 3 — Write a route_accuracy evaluator

With three specialists and one triage agent, the most important quality signal is also the simplest: did the triage agent route the ticket to the right specialist? That is a one-line check on result.last_agent.name — and a benchmark of ten labelled tickets gives you a percent-accuracy number you can track as you tighten TRIAGE_INSTRUCTIONS.

Define a BENCHMARK dict mapping ten realistic tickets to the expected specialist name (lowercase — matching the values you would compare against result.last_agent.name.lower()):

BENCHMARK = {
    "My invoice is wrong.":         "billing",
    "March bill is double.":        "billing",
    "Cancel auto-renewal.":         "billing",
    "The site is down.":            "technical",
    "Checkout returns 500 errors.": "technical",
    "API latency spiked.":          "technical",
    "I can't log in.":              "account",
    "Lost my password.":            "account",
    "Account is locked.":           "account",
    "My email address changed.":    "account",
}

Loop over the benchmark, run each ticket through run_support_agent from agent.py, and compare the agent’s last_agent to the expected specialist. Print one line per ticket showing PASS/FAIL, then a final accuracy line:

def route_accuracy(benchmark):
    matches = 0
    for ticket, expected in benchmark.items():
        out = run_support_agent(ticket)
        actual = out["last_agent"].lower()
        ok = actual == expected
        matches += int(ok)
        marker = "PASS" if ok else "FAIL"
        print(f"  [{marker}] expected={expected:10s} actual={actual:10s}  {ticket}")
    acc = matches / len(benchmark)
    print(f"\nroute_accuracy = {acc:.0%} ({matches}/{len(benchmark)})")
    return acc

if __name__ == "__main__":
    route_accuracy(BENCHMARK)

Run it and read the failures — the per-ticket FAIL lines tell you exactly which category triage is weak on. Most likely you’ll see ambiguous account/billing cases (“Cancel auto-renewal” can read as account-management) misrouted.

Bonus. If your accuracy is below 100%, tighten TRIAGE_INSTRUCTIONS with one or two extra rules targeting the worst category (for example: “Subscription cancellations and renewals are billing, not account.”) and re-run. Track the before/after number — that is what a real eval loop feels like.

✓ Self-check: your driver prints ten [PASS]/[FAIL] lines and one summary line of the form route_accuracy = NN% (N/10); you can point at the specific tickets the agent gets wrong; (bonus) after tightening TRIAGE_INSTRUCTIONS your re-run shows a strictly higher score on the same benchmark.

Compare last_agent, not final_output.specialist

Resolution.specialist is a string the specialist writes itself — it tells you what the specialist thinks it is, not what triage routed to. A misrouted Account ticket handled by Billing might still come back with specialist="billing". The trustworthy signal is result.last_agent.name, which the SDK sets from the actual handoff trace.

View solutions →