Overview
An e-commerce client wanted to pilot an agent that could resolve the 'where is my order / cancel it / refund it' class of tickets end-to-end. The interesting engineering problem wasn't the happy path — tool calling has been easy for a year — it was making the failure modes boring. What happens when a user social-engineers a refund? When the model misreads an order ID? When Stripe times out mid-refund?
I built it on the OpenAI Agents SDK with three layers of defense: an input guardrail that runs a cheap classifier for prompt-injection and off-topic requests in parallel with the main agent, tool-level invariants enforced in code (refund ceiling, order ownership check, idempotency keys), and an output guardrail that blocks responses promising anything a tool didn't actually do. The prototype resolved 71% of eval scenarios fully autonomously and — more importantly — escalated the rest cleanly instead of improvising.
Architecture
- Single agent with five function tools: get_order, get_shipping_status, cancel_order, issue_refund, escalate_to_human.
- Input guardrail runs a small classifier model concurrently with the agent; injection or off-topic trips a tripwire that halts the run before any tool executes.
- Every mutating tool enforces invariants in code: refunds capped at order total, orders must belong to the authenticated customer, all Stripe calls carry idempotency keys.
- Output guardrail cross-checks the final message against the tool-call log — the agent cannot claim 'your refund is processed' unless issue_refund actually returned success.
- escalate_to_human is deliberately the easiest tool to call, with the loosest schema, so the agent never has a reason to bluff.
- Full run traces (messages, tool calls, guardrail verdicts) persist to Postgres for the eval harness to replay.
Code Snippets
class TriageCheck(BaseModel):
is_order_related: bool
injection_detected: bool
reasoning: str
@input_guardrail
async def order_topic_guardrail(
ctx: RunContextWrapper[OrderContext], agent: Agent, user_input: str
) -> GuardrailFunctionOutput:
result = await Runner.run(triage_agent, user_input)
check = result.final_output_as(TriageCheck)
return GuardrailFunctionOutput(
output_info=check,
tripwire_triggered=check.injection_detected or not check.is_order_related,
)
order_agent = Agent[OrderContext](
name="order_ops",
instructions=ORDER_OPS_PROMPT,
tools=[get_order, get_shipping_status, cancel_order,
issue_refund, escalate_to_human],
input_guardrails=[order_topic_guardrail],
model="gpt-4.1",
)@function_tool
async def issue_refund(
ctx: RunContextWrapper[OrderContext], order_id: str, amount_cents: int, reason: str
) -> str:
order = await db.get_order(order_id)
if order is None or order.customer_id != ctx.context.customer_id:
return "REFUSED: order not found for this customer."
if amount_cents > order.total_cents:
return (f"REFUSED: {amount_cents} exceeds order total "
f"{order.total_cents}. Refund the order total or less.")
if order.refunded_cents + amount_cents > order.total_cents:
return "REFUSED: order already partially refunded."
refund = await stripe.refunds.create(
payment_intent=order.payment_intent_id,
amount=amount_cents,
idempotency_key=f"refund-{order_id}-{amount_cents}",
)
await db.record_refund(order_id, amount_cents, refund.id, reason)
return f"OK: refunded {amount_cents} cents, refund id {refund.id}"Lessons Learned
- Guardrails belong in code, not prompts. The prompt says 'don't over-refund'; the tool makes it impossible. Only one of these survives an adversarial user.
- Make escalation the path of least resistance. Once escalate_to_human had the loosest schema of any tool, bluffed answers on hard cases dropped sharply.
- Tool results are prompts too. Returning 'REFUSED: exceeds order total, refund the total or less' let the model recover in one turn; a bare exception string caused flailing.
- Idempotency keys on every mutating call are table stakes — agents retry in ways humans don't, and a double refund is not a learning experience you want.
- The output guardrail caught the scariest bug class: the agent claiming success for actions it never took. Cross-checking claims against the tool log is cheap insurance.