Skip to main content

Idempotency Keys Don't Stop AI Agents From Doing Things Twice

Your duplicate check is keyed to the request. Agent duplicates are new requests with the same intent. Here's the key that catches them.

Updated 10 min readMarkdown
Idempotency Keys Don't Stop AI Agents From Doing Things Twice

Here's a refund tool that most teams would call safe:

def issue_refund(order_id: str, amount_cents: int, note: str):
    key = sha256(f"{order_id}|{amount_cents}|{note}".encode()).hexdigest()
    return payments.refund(order_id, amount_cents, idempotency_key=key)

Same arguments, same key, so a retry can't refund twice. Now hand it to an AI agent.

The provider is slow, the call times out, and the model tries again. First attempt's note: "Refund for damaged item". Second attempt's note: "Customer refund: item arrived damaged". New hash, new key, and if the first refund actually completed after the timeout, the customer gets two.

The code isn't wrong for the caller it was designed for. It's wrong for this one.

A retry resends a request. An agent re-makes a decision. Your duplicate check has to survive the difference.

Why request-based keys assume a boring caller

Classic idempotency keys work because the client generates the key once and resends it unchanged. Stripe's docs spell out the contract: the server saves the result for a key, a repeat gets the saved response, a reused key with different parameters gets an error, and keys can be pruned once they're at least 24 hours old.

An agent loop breaks the "resends it unchanged" part. The model writes a tool call, your code executes it, the result is appended, and the model decides what's next (here's the full tool-calling loop). Every retry is a new generation: a new tool-call ID and freshly written arguments.

The five ways an agent does something twice

The Duplicate Ladder: five ways one action happens twice, and which dedupe key catches each

# How the second call happens Tool-call ID key Argument-hash key Effect key
1 Transport retry (same bytes) Caught Caught Caught
2 Model retries after timeout or error Missed Caught if wording matches Caught
3 Framework replays a step after resume Depends Depends Caught
4 Receipt trimmed or summarized out of context Missed Missed Caught
5 User asks again in a new session Missed Missed Caught (with a durable ledger)

A few notes on the rungs that surprise people:

  • Rung 2 is designed in. The MCP spec says clients should implement timeouts for tool calls. Timeouts guarantee a class of calls whose outcome is unknown. (More on that in Timeouts Don't Cancel Work)
  • Rung 3 is documented. LangGraph's interrupt docs state that on resume the runtime restarts the entire node from the beginning, and recommend that side effects before an interrupt be idempotent, moved after it, or put in a separate node.
  • Rung 4 is a memory problem, not a retry problem. Summaries keep the narrative and drop state changes like "refund issued", the same pattern as AI summaries deleting the exception. The model sees an open complaint and resolves it again, in new words.

What about MCP's idempotentHint?

The annotation means repeated calls with the same arguments have no additional effect. That covers rungs 1–2. Rungs 4–5 have different arguments by construction. The spec also says clients must treat annotations as untrusted unless they come from a trusted server. Useful documentation, not enforcement.

The fix: key the effect, not the call

Ask "has this already happened in the world?" instead of "have I seen this request?" The answer has a name, the effect key: the identity of the thing that must happen at most once.

Tool Request-based key (fragile) Effect key
issue_refund hash of args / call ID refund:return_req_77
send_welcome_email hash of subject + body welcome_email:user_311
schedule_followup call ID followup:ticket_5521:day3

Rules:

  1. Build it in code from stable business identifiers.
  2. Never include model-written free text (notes, reasons, bodies).
  3. Never ask the model to generate it. A model inventing a key on attempt two invents a new key.
def issue_refund(return_req_id: str, amount_cents: int, note: str):
    key = f"refund:{return_req_id}"                 # effect, not call
    prior = ledger.get(key)

    if prior and prior.status == "done":            # duplicate: return the receipt
        return ok(status="already_done", refund_id=prior.refund_id,
                  amount_cents=prior.amount_cents, at=prior.at)
    if prior and prior.status == "pending":         # unknown: look it up, don't re-fire
        return settle_unknown(key)

    ledger.put(key, status="pending", amount_cents=amount_cents)   # record intent first
    r = payments.refund(return_req_id, amount_cents, idempotency_key=key)
    ledger.put(key, status="done", refund_id=r.id, at=r.created)
    return ok(status="done", refund_id=r.id, amount_cents=amount_cents, at=r.created)

Why each line is there:

  • ledger lives in the tool, not the transcript. Context trimming can't delete it.
  • pending is written first. The refund and the ledger row are two writes with no safe order; intent-first leaves a reconcilable record if you crash mid-call.
  • The same key goes downstream. It covers the pending window. And since Stripe errors on a reused key with different parameters, an attempt that drifted from $40 to $45 is rejected at the boundary instead of becoming a second, different refund.

Return "already done" as a success, not an error

An error result reads as an obstacle the agent may work around; a receipt reads as a finished fact

If the duplicate comes back as isError: true, the model reads an obstacle between it and the user's goal. It may retry, rephrase, or reach for another tool (store credit, say) that gets the user what they asked for. Your block held; the agent walked around it.

Return a normal result instead:

{
  "status": "already_done",
  "refund_id": "re_1042",
  "amount_cents": 4000,
  "at": "14:02"
}

The model now reads a fact and reports it. Block in code; describe the world, not the refusal.

For timeouts, return a third state, pending, with an instruction the model can act on ("may already be processing; don't issue another; check get_refund_status") and give it that read-only tool, keyed by the same effect key.

When a repeat is legitimate

Effect keys block real repeats too. A second returned item from order 8812 deserves a second refund, which is why the examples above key on return_req_77, not order_8812.

  • The discriminator must be a business record. A legitimate second refund has a second return request. A duplicate only has new wording.
  • No record, no repeat. If nothing distinguishes the second action, a human or workflow step creates that record first. Not the model's confidence.
  • Key lifetime matches how long people come back. Provider key stores may forget after a day; your ledger should last as long as someone might ask "did it go through?"
  • Prefer converging tools. ensure_refunded(return_req_77) is safe to repeat by design. send_email isn't, so it needs a key.

If you can't name a tool's effect key, the tool isn't ready to hand to an agent.

The Once-Only Tool: five rules for any tool an agent might call twice

The Rephrase Test (one test per rung)

Run this against every tool that changes something outside the conversation.

def test_refund_happens_once(fake_payments, ledger):
    base = dict(return_req_id="rr_77", amount_cents=4000)

    r1 = call_tool("issue_refund", call_id="c1", note="Refund for damaged item", **base)
    r2 = call_tool("issue_refund", call_id="c1", note="Refund for damaged item", **base)  # rung 1
    r3 = call_tool("issue_refund", call_id="c2", note="Refund for damaged item", **base)  # rung 2
    r4 = call_tool("issue_refund", call_id="c3",
                   note="Customer refund: item arrived damaged", **base)             # rung 4
    r5 = call_tool("issue_refund", call_id="c9", session="next-day",
                   note="Re-issuing refund per customer", **base)                    # rung 5

    assert fake_payments.refund_count("rr_77") == 1
    for r in (r2, r3, r4, r5):
        assert not r.is_error
        assert r.data["refund_id"] == r1.data["refund_id"]


def test_timeout_after_success(fake_payments):
    fake_payments.complete_then_hang(seconds=60)      # does the work, never answers
    first = call_tool("issue_refund", call_id="c1", return_req_id="rr_78",
                      amount_cents=4000, note="x")
    assert first.data["status"] == "pending"          # unknown, not failed
    again = call_tool("issue_refund", call_id="c2", return_req_id="rr_78",
                      amount_cents=4000, note="y")
    assert fake_payments.refund_count("rr_78") == 1

Pass = one effect in the world, and every later call returns the first receipt as a success. The timeout-after-success case is the one teams skip, because it needs a fake that succeeds and lies about it. It's also the one production produces on a slow afternoon.

Key Takeaways

  • Agents don't retry requests; they re-make decisions, with new call IDs and new wording.
  • Tool-call IDs catch only transport retries. Argument hashes break the moment the model rephrases.
  • Key the effect (refund:return_req_77), built in code from business identifiers, never from model text.
  • Keep a ledger in the tool layer; the model's memory of the chat is a hint, not a record.
  • Return "already done" as a success with the receipt. Errors invite workarounds.
  • Treat timeouts as pending, and check status by effect key before acting again.

FAQ

What is idempotency for AI agent tool calls?

It means a tool that changes something (a refund, an email, a record) has the same effect whether the agent calls it once or many times. For agents, that has to hold even when the repeated calls have different IDs, different wording, or come from a different session.

Why do AI agents call the same tool twice?

Five common reasons: a transport retry, the model retrying after a timeout or error, a framework replaying a step after a resume, the earlier result being trimmed or summarized out of context, and the user asking again in a new session.

Why doesn't an idempotency key based on the tool-call ID work?

Each time the model emits a tool call it gets a new ID. That key only catches retries of the exact same call, which is the rarest kind of agent duplicate.

What is an effect key?

The identity of the action that must happen at most once, built by your code from stable business identifiers, such as refund:return_req_77. It deliberately excludes model-written text, because that text changes between attempts.

Should a blocked duplicate tool call return an error?

Usually not. An error reads to the model as an obstacle, and it may retry or route around it with another tool. Return a normal result with status already_done and the original receipt so the model reports the fact instead.

Does MCP's idempotentHint make a tool safe for agents?

No. It's a hint that repeated calls with the same arguments have no extra effect, and the MCP spec says clients must treat annotations as untrusted unless the server is trusted. Agent duplicates often have different arguments, so enforce idempotency in the tool itself.

Keep reading

The bottom line

Request-based idempotency trusted the caller to remember what it sent. An agent is a caller with excellent manners and no reliable memory of what it did ten minutes ago. Stop asking it to remember. Make the tool remember.

Block the duplicate in code. Then describe the world, not the refusal.

Get one of these in your inbox each week: subscribe to Simply Explained.

Adam Jaber is a software engineer who writes Simply Explained: complex topics, made simple. No jargon, no hype.

About the author

  • I'm a software engineer who spends most days building systems that solve real problems. When I'm not shipping code, I'm either untangling a tricky problem or writing about what I learned doing it. Currently exploring AI on the side.

No comments yet. Be the first to comment.

More from this blog