Making agent actions idempotent
An agent that retries a tool call can book the same load twice. The fix belongs in the action, not the prompt.
An agent calls a tool. The call times out. The agent, reasonably, tries again. If that tool books freight, you have now committed the same truck twice.
Prompting the model to "be careful not to double-book" does not fix this. The model is not the one retrying — the runtime is, and a timeout is indistinguishable from a failure at that layer.
Keys, not prompts#
Every mutating action takes a caller-supplied idempotency key derived from the intent, not the attempt:
key = hash(conversation_id, tool_name, canonical_args)
@idempotent(key)
def book_load(load_id: str, rate_cents: int) -> Booking:
...
Same intent, same key, one effect. The second call returns the first result rather than performing the action again.
canonical_args matters: serialise with sorted keys and normalised types, or
{"rate": 900} and {"rate": 900.0} produce different keys for the same
intent.
Three states, not two#
A retry needs to distinguish failed from unknown:
| Outcome | Safe to retry |
|---|---|
| Rejected by upstream | Yes — nothing happened |
| Succeeded | No — return the stored result |
| Timed out | Only with the key; the write may have landed |
The third row is the one that bites. Write the key and an in_flight marker
before calling the upstream, so a crash mid-call is recoverable rather than
ambiguous.
Letting the agent see it#
When a retried call returns the stored result, say so in the tool response:
{ "booking_id": "bk_8821", "replayed": true }
Otherwise the model sees two successes and may conclude it booked two loads — which it will then cheerfully tell the broker.