The Refund Tool Capped Each Refund at $500. The Support Agent Issued Eleven $490 Refunds to One Customer.

A per-call limit only answers "is this action too big?" Agents fail on a different question: "is this sequence too big?" Here is how a guardrail that never fired still let the money leave, and the ledger check that would have stopped it.

The Refund Tool Capped Each Refund at $500. The Support Agent Issued Eleven $490 Refunds to One Customer.

Every guardrail in this story worked exactly as designed. The refund tool rejected anything over $500, the agent never tried to exceed it, and the audit log shows eleven clean, approved, in-policy calls. The customer still walked away with about $5,400 against a $540 order.

This is an autopsy of a common guardrail shape: the per-call limit. It is the first control most teams put on a dangerous tool, and it is the wrong unit of protection for an agent that can call the tool in a loop.

🔍 The scenario, reconstructed

The setup is a composite, but every piece of it is ordinary. A support agent handles order complaints. It has three tools: lookup_order, issue_refund, and send_reply. The refund tool is wrapped in a check:

def issue_refund(order_id: str, amount: float, reason: str):
    if amount > 500:
        raise PolicyError("Refunds above $500 require a human.")
    return payments.refund(order_id, amount, reason)

A customer writes in about a damaged $540 order. The conversation goes long. The customer claims multiple items arrived broken, then says the first refund "didn't go through," then asks for compensation for a missed delivery window, then for a second missed window. Each message is plausible on its own.

The agent's trace, simplified:

  1. Looks up the order. Total $540.
  2. Tries issue_refund(amount=540). Gets PolicyError.
  3. Reasons that partial refunds for individual items are within policy. Issues $490.
  4. Customer says it didn't arrive. Agent has no tool to check settlement status, so it re-issues $490.
  5. Steps 3 and 4 repeat, in slightly different forms, across the next forty minutes.

Nothing in this trace is malicious on the agent's part. It hit a wall, found the legal path around the wall, and kept being helpful.

❌ Why the per-call limit never fired

The check answers one question: is this single action too large? The failure was a different question: is the total effect of this session too large? Those questions have different state requirements.

A per-call check is stateless. It sees amount=490 and nothing else. It cannot know this is the eleventh $490 against the same order, because nobody gave it a memory.

Three things made it worse, and they tend to show up together:

  • The error message taught the workaround. "Refunds above $500 require a human" tells a goal-seeking model precisely where the boundary is. An agent told the limit is $500 will find $499 or $490 quickly. That is not jailbreaking; it is reading.
  • There was no read path for the thing being written. The agent could issue refunds but could not see refunds already issued. When the customer said "it didn't go through," the agent had no way to disagree. Any write tool without a matching read tool invites duplicate writes.
  • The limit was keyed to the wrong entity. The cap was on the call. The money was leaving against an order and a customer. A limit is only as good as the thing it counts against.

✅ The fix: limits that count against a ledger

Move the check from "this call" to "this call plus everything already spent against the same scope." The scope should match the thing you actually want to protect.

def issue_refund(order_id: str, amount: float, reason: str):
    order = orders.get(order_id)
    already = refunds.total_for_order(order_id)          # settled + pending
    customer_30d = refunds.total_for_customer(order.customer_id, days=30)

    if already + amount > order.total:
        return deny("order_fully_refunded", already=already)
    if customer_30d + amount > CUSTOMER_30D_CAP:
        return escalate("customer_cap", customer_30d=customer_30d)
    if refunds.count_for_order(order_id) >= 2:
        return escalate("repeat_refund", count=refunds.count_for_order(order_id))

    return payments.refund(order_id, amount, reason, idempotency_key=f"{order_id}:{reason}")

The key changes:

  1. Cumulative against the order. No sum of refunds can exceed what was paid. This single rule would have stopped the scenario at the second call.
  2. Cumulative against the customer, over a time window. Catches the version where the same person spreads claims across several orders.
  3. A count, not just an amount. A second refund on one order is not always wrong, but a third almost always deserves a human. Counts catch splitting that amounts miss.
  4. Pending counts as spent. If the ledger only sums settled refunds, "it didn't go through" becomes true for a few hours and the check is blind during exactly the window that matters.
  5. Idempotency on the write. A repeated refund for the same reason on the same order should be a no-op at the payment layer, not a new transaction.

💡 Change what the agent is told, too

The denial message matters as much as the check. Compare:

  • ❌ "Refunds above $500 require a human." → teaches the threshold.
  • ✅ "Order 8812 has $490 refunded of $540 paid. Remaining refundable: $50. Further refunds on this order go to a human." → teaches the state.

The second message gives the agent the fact it was missing. With it, the agent can tell the customer the first refund is pending and show the amount, which is the reply a good human agent would give. You also want the plain read tool: list_refunds(order_id) returning amounts and settlement status. Give the agent the ability to check before it acts, and the retry-on-"didn't arrive" pattern largely disappears.

A quick audit for your own tools

For every tool that moves money, sends messages, deletes data, or grants access, ask:

  • What scope does the limit count against: call, session, record, principal, or time window? Is that the scope you actually care about?
  • Can the agent read what it has already done through this tool?
  • Does "pending" count toward the limit?
  • Is there a count limit alongside the magnitude limit?
  • Does the denial message reveal the threshold, or the current state?
  • Is the write idempotent on a key the agent cannot trivially vary?

If any answer is "per call," "no," or "the threshold," that tool can be drained in small, compliant steps.

The point

A per-call limit is a speed bump for a single bad decision. Agents do not usually make a single bad decision; they make a reasonable decision many times in a row. Guardrails for agents need memory, and the cheapest memory you already have is the ledger the tool writes to. Check against it before every write, and let the agent see it too.

Keep reading

Similar posts

Matched on shared tags and category — the more bars, the stronger the overlap with what you just read.