Retry Semantics: Which Tools Are Safe to Call Twice
Agents repeat tool calls for at least five ordinary reasons — none of them bugs. Whether that's harmless or a duplicate charge is a property of the tool, and usually one nobody wrote down.
Agents repeat tool calls. Not because of a bug — because the loop retries on error, because a resumed run replays a step, because a summarization pass dropped the earlier result and the model asked again, because the user re-ran something that had partially completed.
Whether that repetition is harmless or a duplicate charge is a property of the tool, and it's one nobody writes down until it matters.
Classify every tool
Three categories, and every tool belongs to one:
Naturally idempotent. Reads, and writes that set an absolute value. get_order, set_status(id, "shipped"). Calling twice produces the same end state. Most read tools land here, which is why read-only agents are so much easier to reason about.
Idempotent with a key. Operations that create or accumulate, made safe by a caller-supplied deduplication key. create_ticket, issue_refund, send_email. The second call with the same key returns the first call's result instead of doing it again.
Not safely repeatable. Relative operations — increment_balance(id, 100), append_note — and anything with an external effect that can't be deduplicated. These need to be designed away where possible.
⚠️ The dangerous case is the third category masquerading as the second, because retries look safe until someone reads the implementation.
Making a write tool idempotent
The key must be deterministic from the work, not random. A random key generated per call is a new key on the retry, which defeats the entire mechanism:
❌ key = uuid4() # different every attempt
✅ key = f"{run_id}:{item_id}:refund" # same across attempts
Then the handler:
issue_refund(order_id, amount, idem_key):
if existing := refunds.find(idem_key):
return {**existing, replayed: true} # no second refund
result = payment_provider.refund(order_id, amount, idem_key)
refunds.record(idem_key, result)
return result
Two details worth getting right. Return the original result, flagged as a replay — the model needs to know the action happened, not that it failed. And pass the key through to the downstream provider if it supports one, so the guarantee holds even if your record-keeping fails between the call and the write.
Declare it in the tool spec
Retry policy shouldn't live in someone's head:
issue_refund:
side_effects: money_out
idempotent: with_key
retry: safe_with_same_key
max_attempts: 1_per_key
search_orders:
side_effects: none
idempotent: true
retry: safe
The loop can then make decisions automatically — retry the safe ones, refuse to auto-retry the unsafe ones, escalate instead. A retry policy expressed as data is enforceable; one expressed as a convention is a bug waiting for a busy afternoon.
Where repetition comes from
Worth enumerating, because each needs a different guard:
- Transport retries — handled by the key.
- Loop-level retries after an error — handled by the key, provided the key is stable.
- Model repetition after context loss — the model genuinely doesn't know it already called the tool. → The result of a consequential call should stay in context or in run state, and a replay flag in the response tells it what happened.
- Run resumption — the key must be derived from durable state, not from anything regenerated at resume time.
- Queue redelivery — a visibility timeout shorter than the run means the whole task runs twice. Keys scoped to the run make this survivable.
🔍 Auditing
For each tool with a side effect:
- What happens if it's called twice with identical arguments? Read the implementation; don't infer from the name.
- Is the key derived deterministically from the work?
- Does the response distinguish "did it" from "already done"?
- Is the deduplication record durable, and does it outlive the run?
- Is there a test that calls it twice and asserts a single effect?
✅ That last one is the whole audit in a line. Every side-effecting tool should have a called-twice test, and most don't.
The takeaway
Agents repeat calls for at least five ordinary reasons, so idempotency isn't a defensive nicety — it's a requirement for any tool that changes something. Classify every tool, give write tools deterministic keys derived from durable state, return the original result flagged as a replay, declare the retry policy as data the loop can act on, and write the called-twice test. Then repetition becomes a non-event rather than a duplicate refund.