BlogAI agents
Stopping an agent from carrying out the same business action twice
Give every business action a key derived from what the action is, not from the attempt. Agent frameworks can save progress and resume, but they cannot make a call to an external API take effect exactly once. Only the system that performs the side effect can reliably refuse a repeat: the receiving API deduplicates, or you control the write and the key in one transaction. A wrapper in front of an external API that does neither reduces duplicates but cannot rule them out, so some outcomes stay ambiguous and a reconciliation job has to resolve them.
Five ways an agent repeats an action
- A transport retry after a timeout, although the first request had already been applied
- The model calls the tool again because it did not see or trust the first result
- A framework replay: LangGraph re-runs an interrupted node from its start on resume, and replaying from a checkpoint re-runs later nodes including their API calls
- A queue delivers the same case twice, or two workers pick it up
- The same request arrives twice, for example an invoice sent by email and uploaded to a portal
Only the last one is a business duplicate that needs domain rules. The first four are the same action attempted again, and an idempotency key handles all of them if it stays the same across attempts.
Derive the key from the business action
A random value generated at call time changes on every attempt the model or framework makes, so it protects only against the narrowest retry. Build the key in code from stable facts: the case, the kind of action, its target and its position in the case. The model never chooses it.
import hashlib
import json
def action_key(case_id: str, action: str, target: str, step: int) -> str:
"""Same business action, same key, across retries, replays and restarts."""
# A JSON array keeps the fields apart: ("a|b", "c") and ("a", "b|c")
# produce different strings, which a plain "|".join would not.
canonical = json.dumps([case_id, action, target, step],
ensure_ascii=False, separators=(",", ":"))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:40]
# action_key("claim-88213", "create_payment", "payee:SUP-2291", 1)
# returns the same 40-character key every time it is called with these valuesEncode the fields unambiguously before hashing. Joining free-form identifiers with a separator such as a vertical bar lets two different actions produce the same input string, for example when a case ID itself contains the separator, and they would then share a key. That is a flaw in the encoding, separate from collisions of the hash function. A canonical JSON array, as above, or length-prefixed fields avoid it; a separator is only safe if every identifier is validated never to contain it.
Hashing keeps raw values such as bank details out of the key, but it does not make them anonymous: a hash of an identifier can often be linked back to the person, so treat keys and the action record as pseudonymised personal data and agree their handling with your data protection officer. Stripe's documentation, for example, advises against using sensitive data as idempotency keys. The step number lets a case legitimately make two similar payments, such as two instalments, without them colliding.
The receiving side has to enforce it
A key the target ignores protects nothing. Check which of three situations you are in for each write tool.
The API supports idempotency keys
Read the provider's rules closely, because they differ. Stripe's API v1, for example, accepts keys on POST requests, stores the first response including 500 errors, rejects a reused key with different parameters and may prune keys after at least 24 hours. Those details belong to Stripe and say nothing about your ERP. Rely only on the guarantees the provider documents, including how long keys are kept and how concurrent or failed requests are treated.
You own the write path
If the tool or MCP server writes to a database you control, store the key, a hash of the parameters and the result in the same transaction as the change. The Amazon Builders' Library article on idempotent APIs describes this pattern: record the request identifier and the mutation atomically, return an equivalent response for a repeat, and reject the same identifier with different parameters. This works only because the key and the change commit together. A tool that merely forwards a call to an external API cannot do the same: if it loses the response, it does not know whether the effect happened.
Neither is possible
Use a natural unique reference that the target already checks, such as supplier plus invoice number, and read back before writing. If even that is unavailable, the action needs a person or must be accepted as occasionally duplicated and reconciled. Reading back narrows the window for duplicates but does not close it, because the first request may still be in progress. Cases where the check cannot settle the outcome go to a person or to reconciliation.
Long pauses outlive short-lived keys
Agent runs can wait days for an approval. If a provider keeps keys for a limited time, a resume after a long pause may fall outside that window. Keep your own action record with states such as pending, done and failed, and keep it at least as long as a case can stay open. A pending entry older than its expected duration means the outcome is unknown: check the target before trying again.
Reconcile, then test the failure points
Run a regular job that compares the action record with the target system: actions recorded without an effect, effects without a record and effects that appear twice. Duplicates are corrected through the business process for that action, such as a cancellation or refund, and Microsoft's compensating transaction pattern notes that such corrective steps must be idempotent too.
Then test the exact failure points: stop the process after the external call but before anything is recorded, resume, and assert one record. Deliver the same case to two workers at once. A good first step is to list every write tool and note which of the three enforcement situations applies to it.
Questions
Doesn't durable execution already prevent duplicates?
No. Checkpoints record the agent's own progress so a run can resume. They cannot know whether an external system applied a call made just before a crash, and resumed or replayed steps can call it again.
Can we use the model's tool call ID as the key?
It identifies one call, so a new call by the model or a replayed step will usually carry a different ID. Use a key built from the business action instead.
What about actions that cannot be made idempotent, such as sending an email?
Record the intent to send in your own table under the action key and let a separate sender deliver each entry once. A rare duplicate can still occur after a crash at the wrong moment, so decide whether that is acceptable for the message type.
Related services
More from the blog
Recovering from agent tool failures
What a process agent should do when an API times out or a tool returns partial data, with typed failures, bounded retries and a clean handoff.
Human approval in agent workflows
Which agent actions on business records need a person's approval, what the approver must see and how to keep approvals from stalling every case.
Versioning a process agent
What needs a version when a process agent changes, how to stamp it on every case and how to explain a result months later without rerunning it.
Talk to an engineer
Bring one piece of work you would like to improve. The call takes 30 minutes and needs no access to your systems or your data.
Booking runs on Google Calendar.
Prefer to write first? Leave your email and a few lines, and we reply with times.
Look for the draft in your email program
Send it from there, and an engineer replies in person with times for a 30-minute call. If no draft has opened, write to us at miki@cloudsail.com.