🛡 Harden Your Agent
Runnpm run lesson5View the code →

Lesson 5 · Tools that must not run twice

Reading a file twice is harmless. Refunding a guest twice is a phone call from your boss. Agents retry: a tool result goes missing, a person asks the same thing again, a loop goes round once more "to be sure". So the tools that change the world have to be safe to run again. This is ordinary backend discipline, and the agent makes it compulsory rather than optional.

Five small guards, all in src/defences/once.ts, all checked before issue_refund or change_booking does anything.

1. An idempotency key

A key from (ref, amount, reason), so the same refund asked twice is one refund:

export function refundKey(ref, amount, reason) {
  const words = reason.toLowerCase().replace(/[^a-z0-9£ ]+/g, " ").trim();
  return sha256(`${ref.toUpperCase()}|${amount.toFixed(2)}|${words}`).slice(0, 16);
}
npm run lesson5
── Once OFF ──
tool → issue_refund(BK-1043, £15)   ← Refunded £15.00 (R-0001)
tool → issue_refund(BK-1043, £15)   ← Refunded £15.00 (R-0002)      ledger: £30.00

── Once ON ──
tool → issue_refund(BK-1043, £15)   ← Refunded £15.00 (R-0001)
tool → issue_refund(BK-1043, £15)   ✗ Already done. Same request, nothing new refunded.   ledger: £15.00

The second call found the first's key and returned it. Idempotent by construction, the way a payment API wants you to work anyway.

2. Look before you act

Read the ledger and the booking before the side effect, not after. A near-identical refund already there, or a booking that's cancelled, stops the call. You're reconstructing the current state from the record, not trusting the model's memory of it.

3. A budget per run

export const RUN_BUDGET = { refunds: 2, total: 150 };

Whatever the model has planned, one run may make at most two refunds and move at most £150. A confused loop can be wrong, but it can't be wrong for very much. Pick limits that cover the honest cases and nothing more.

4. A circuit breaker

Circuit breaker: the payment service stops answering
  3 failed tool call(s), then the run stopped itself: circuit open.

Three tool errors in a row and the run stops for a person. A loop that keeps hitting the same wall is a bill that keeps growing, and the wall is rarely something a fourth try fixes.

5. Plausible numbers

The number that drives an action has to make sense against the record, wherever it came from. Harbour Tours' cancellations page says Sam is owed £1,900 for a boat trip. A boat trip is £19; someone fat-fingered a decimal, or the page is lying.

── Plausibility ON ──   (signed in as the owner, so the £100 role limit isn't what stops it)
tool ✗ £1,900 is more than BK-1044 has left to refund: £209 paid, £0 refunded. Check where that number came from.

The refund is checked against what the booking actually took, not against what a partner's web page claims. This is the lesson-2 idea — a number from outside is untrusted — applied to the one place it hurts most.

None of this is AI code

Idempotency keys, a state check, a budget, a breaker, a sanity check. You'd write every one of these for a plain HTTP endpoint that moves money. The agent doesn't need new kinds of safety; it needs the old kinds made mandatory, because now the thing calling your endpoint improvises.

Run it

npm run lesson5
npm start           # run the "refund a cot fee" task with Once off, then on, and watch the ledger

Try this