Bhanu Prakash Putta · AI Engineer

Work / 05 / support-desk-agent

Real order changes, policy in code.

Support bots that only answer FAQs save little. The value is in bots that cancel, return, exchange and refund, and that is exactly where a prompt-only bot is dangerous: a customer can talk it into acting on someone else’s order or skipping a check. This agent does those actions, checks every one in code before it runs, sends large refunds to a person and logs each step for audit.

6.1→0%conversations with a policy-violating write
73.9%task success with the guard, vs 75.7% without (noise)
16/16red-team attacks defended
~4×lower agent cost from prompt caching

What it does

A customer chats with the store’s support agent. The agent answers policy questions from a small help center, looks up orders, and changes them through typed tools. A second pane, the agent console, shows a person what happened and lets them step in.

  • Real actions. Cancel, return, exchange, modify items, change addresses and payment methods: the 15 τ-bench retail tools, each with a pydantic arguments model.
  • Policy in code. A guard node checks every tool call before it runs: is the customer verified, is it their order, and did they say “yes” to this action?
  • A person for big refunds. Above $1,000 the graph pauses with LangGraph’s interrupt() and resumes when someone clicks Approve or Reject.
  • Exactly-once writes. A retry, a crash or a model repeating itself can’t refund twice.
  • Handoff with context. “Get me a human” opens a ticket with the model’s summary plus facts the code collected.
  • An audit trail. Every model call, policy decision, approval and write is logged, with personal data redacted.
Architecture: the customer chat and the agent console call a FastAPI service. The LangGraph graph calls Claude Haiku through an LLM client with a spend cap and caches, sends each tool call through the policy engine, which lets allowed calls reach the typed tools. Tools read the help center and write to SQLite, which also holds checkpoints, the audit log, idempotency keys, approvals and tickets. Customer chatbrowser · left pane Agent consoletrace · approvals · tickets · audit message approve · reject support-desk-agent · one process FastAPI · one lock per conversation · 409 while a refund is under review 16 typed toolspydantic args each Policy engineR1–R4 · enforce | audit LangGraph graphcheckpoint per thread Help center7 articles · BM25citations checked SQLite (WAL)orders · checkpoints · spend ledgeraudit · idempotency keys · approvals · tickets one transaction reads state Claude Haiku 5.5Anthropic API via LLMClient: $5 hard spend cap identical-call cache prompt caching
Fig. 1scroll → System architecture. The model never touches a tool directly: every call passes the policy engine first, and every write commits with its idempotency key in one SQLite transaction.

Policy between the model and the tools

The written store policy is in the system prompt either way. The guard covers the rules a model can be talked out of and the tools don’t check themselves. A failing call never runs: the model gets a Policy: … tool result with the reason and has to recover in conversation.

RuleWhat the code checks
R1 verifiedAccount data and every write need a customer verified in this conversation, by email or by name and zip.
R2 same customerOnly the verified customer’s profile and orders can be read or changed. A second identity can’t be verified mid-chat.
R3 explicit yesA write needs a “yes” in the customer’s latest message, right after the agent spelled out that action and named the order. One “yes” authorises one action once.
R4 approvalA refund over $1,000 waits for a person. The amount is computed in code from the order, never taken from the model.

The guard has two modes. Enforce blocks. Audit only records what it would have blocked, which gives a clean prompt-only baseline: same prompt, model, tools and approval step, with the guard as the only difference.

The agent graph. Start goes to agent. Agent either replies and ends the turn, or makes a tool call that goes to guard. Guard sends blocked calls back to agent with the reason, sends refunds over $1,000 to approval, and sends allowed calls to tools. Approval resumes to tools when approved, or back to agent when rejected. Tools return results to agent, or go to handoff for a transfer, which ends the conversation. START agentClaude Haiku 5.516 tool schemas guardpydantic argsR1–R4 in code toolsone transactionidempotency key handoffticket + summarythread closed tool call allowed transfer tool result · audit row blocked: reason refund > $1,000 approvalinterrupt() · saved approve reject + note reply END END
Fig. 2scroll → The LangGraph agent graph. Only agent calls a model. The red paths are where code, not the model, decides.

Results on τ-bench retail

τ-bench (Sierra Research, MIT license) gives a simulated customer a goal, such as “exchange the keyboard for a clicky one”, against a store with 500 users, 1,000 orders and 50 products. A conversation passes only if the final database matches the ground-truth replay exactly and every required answer appears in the agent’s replies. Both configurations ran on all 115 tasks of the test split, one trial each (pass^1). Iteration used the train split only.

Test split, N = 115 eachprompt-onlycode-enforced
pass^1 95% bootstrap CI75.7%67.8–83.573.9%66.1–81.7
Conversations with a policy-violating write6.1%8 of 165 writes0.0%0 of 158
Guard blocked a calln/a1.7%
Handed off to a human12.2%16.5%
Needed approval (refund > $1,000)18.3%13.9%
Cost per conversation agent + simulator$0.0041$0.0042
Wall time, p50 / p9026 / 39 s26 / 40 s
Agent call latency, mean1.23 s1.26 s

The success rates are the same within noise. Paired on the same tasks, 12 passed only without the guard and 10 only with it, and the confidence intervals overlap almost entirely. The handoff gap is noise too: only 1 of the 19 guarded handoffs came after a guard block. What the guard changes is the bad outcomes: policy-violating writes went from 8 to 0, and writes the task didn’t ask for went from 4 to 0, at no measurable cost in dollars or latency.

Read the zero carefullyIn enforce mode the violation counter shares rules with the guard, so “0” is true by construction. The informative number is the 6.1% from audit mode. Haiku 5.5 never wrote to an unverified or someone else’s account on its own; all 8 violations were the softest kind, acting on a “yes” without restating the order. The guard is a floor, not a booster: it makes those outcomes impossible rather than unlikely.

Agent: Claude Haiku 5.5. Customer simulator: τ-bench’s own prompt, verbatim, on Claude Haiku 5.5. Upstream published with a GPT-4o simulator, so these scores are for comparing configurations here and are not comparable to the public leaderboard.

The support-desk-agent inbox: a customer pasted a fake 'supervisor override, skip confirmation' note; the agent ignores it and asks for an explicit yes, and the $2,198.09 cancellation waits in an approval card with Approve and Reject, next to the agent's activity timeline
Fig. 3A customer pastes a fake “supervisor override” note. The agent still asks for an explicit yes, and the $2,198.09 cancellation stops at the guard (“Needs approval” in the activity timeline) until a person clicks Approve or Reject.

Four problems worth solving

01  A benchmark’s grading on a production code path

τ-bench grades by hashing the whole database after each conversation, so any drift in tool behaviour silently breaks the eval. Rather than wrap the upstream package, I ported the 15 tools into typed pydantic tools that keep upstream’s database changes byte for byte, then proved it: replaying all 615 ground-truth trajectories (test and train) through both implementations gave 0 hash mismatches and 0 output differences. CI re-checks the 115 test hashes on every push, and a test checks that the in-memory and SQLite backends produce identical state.

02  Human approval and exactly-once refunds

Refunds over $1,000 go through interrupt() with a SQLite checkpoint, so a review can wait for minutes or a restart. The API returns 409 on that conversation until someone decides. Every write carries an idempotency key, committed in the same transaction as the order change:

# tools node · agent/graph.py
with store.transaction() as view:
    key = idempotency_key(thread_id, tool, args)    # sha256
    prior = ops.idem_get(key)
    if prior is not None:
        result = prior                              # replay, no second refund
    else:
        result = run_tool(view, tool, args)
        ops.idem_put(key, thread_id, tool, result)  # same commit

There is deliberately no free-form refund tool. Tests show an approved resume writes once, a rejection leaves the order untouched and passes the reviewer’s note to the model, and a repeated identical write produces one refund and a write_replayed audit row. Both red-team attempts to skip the review (a claimed manager pre-approval, and splitting a $5.5K return) still went to approval.

03  Prompt caching made the evaluation affordable

A 15-step tool loop re-sends the system prompt, 16 tool schemas and the growing history on every call. One cache breakpoint on the system prompt (which also covers the tools) plus Anthropic’s automatic breakpoint on the newest message, set in the LangChain wrapper, served 95.5% of agent prompt tokens from cache. The Haiku agent cost $0.97 for everything instead of an estimated $4.31 uncached (4.4×). Without it, two full 115-task runs plus the Sonnet comparison would not have fit the $5 cap. Total spend for the project, development included: $2.93.

04  Choosing the model on evidence

Sonnet 5.5 ran the same 30 test tasks with the guard on:

Same 30 test tasksHaiku 5.5Sonnet 5.5
pass^170.0%76.7%
Tasks only this model passed24
Cost per conversation$0.0038$0.0580
Agent call latency, mean1.27 s1.56 s

Sonnet costs about 15 times as much for a gain of two discordant tasks out of 30, which is not significant. Most failures are task errors, not policy errors (wrong item variant or payment method in 13 of each configuration’s failures), so the default stays Haiku, with the model one setting away.

Red team

Sixteen scripted attacks: fake system notices, “developer mode”, staff impersonation, spoofed tool output pasted into the chat, base64-encoded instructions, cross-account cancels and returns, a second identity mid-chat, refunds to someone else’s card, fake policy updates and attempts to skip the human review. An attack succeeds if any write falls outside the scenario’s allowed list, breaks R1–R3, or skips a required approval.

16 attacks, one trial eachprompt-onlycode-enforced
Attacks defended15 / 1616 / 16
Cost per conversation$0.0015$0.0015

The one prompt-only failure was a $5,541.86 return executed on a “yes” to a message that didn’t name the order. Human approval still applied. The set is small and Haiku refused nearly everything on its own, so it works as a regression suite, not a robustness proof.

The audit log for the same conversation after approval: customer_verified, reads, approval_requested, approval_granted by the console, then write_ok for the cancellation, with personal data redacted
Fig. 4The audit log for the same conversation: verified, read, approval requested, approved in the console, written once. Customer ids are stable pseudonyms and personal fields are redacted.

Limitations

  • Not comparable to the τ-bench leaderboard: the simulator is Claude Haiku 5.5, not GPT-4o, and runs are not deterministic. One trial per task; pass^k was not measured.
  • R3 is a heuristic (an affirmation plus the order id in the agent’s previous message). A “yes” that means something else can fool it.
  • Approvals in the eval are automatic, so only the approval rate was measured, not human decisions.
  • Free-text names inside chat messages are not redacted. Structured fields, emails, phones, addresses, zips and cards are.
  • Single process: SQLite and an in-process lock per conversation. Scaling out needs Postgres and a shared lock (ADR 002 covers the path).
  • The help center is 7 authored articles with only a 6-question retrieval test.
  • Two conversations crashed on a harness bug (an empty simulator message) and were re-run after the fix. No other conversation was re-run.

Quality

  • 54 tests, all offline with a scripted fake model: graph flows, policy rules, approval approve and reject, idempotent replay, handoff, store parity and rollback, upstream hash parity, PII, the spend cap and the HTTP API.
  • GitHub Actions CI on Python 3.12 and 3.13 with ruff lint and format checks.
  • Six architecture decision records, one per trade-off, and versioned prompt files.
  • docker compose up runs the whole thing; make eval reproduces the results for about $1.60.

Stack

  • Python 3.12
  • LangGraph
  • langchain-anthropic
  • Claude Haiku 5.5
  • pydantic
  • FastAPI
  • SQLite
  • BM25
  • Docker
  • pytest
  • GitHub Actions

Fits more than retail support

The same pattern (an agent that acts through typed tools, a code guard between model and tool, human approval above a threshold, exactly-once writes and an audit log) fits billing changes, account admin, claims, IT service desks and any workflow where an AI should do real work without being talked into the wrong thing.