DEV Community

Cover image for Your Agent Will Call the Same Tool Twice. Make Side-Effecting Tools Idempotent
M.T.Ramkrushna
M.T.Ramkrushna

Posted on

Your Agent Will Call the Same Tool Twice. Make Side-Effecting Tools Idempotent

Here's a bug I keep seeing in agent code: a tool call times out, the framework retries, and the customer gets two refunds. Or two emails. Or two tickets.

The model didn't do anything wrong. The tool did exactly what it was told, twice. The fix isn't a better prompt. It's a decades-old backend pattern: idempotency keys.

Why agents duplicate side effects

An agent loop gives you more ways to run the same action twice than a normal API client does:

  1. Network retries. Your HTTP client or agent framework retries a call that timed out, but the first attempt actually succeeded on the server.
  2. The model asks again. OpenAI's function-calling guide says a response "can include zero, one, or multiple calls," so you should assume there are several. The same guide even notes that one gpt-4.1-nano snapshot "can sometimes include multiple tool calls for the same tool" when parallel tool calls are enabled, and recommends disabling that feature for it.
  3. Crash and resume. Your worker dies after the side effect but before saving the tool result. On restart, the agent replays the step.
  4. The model didn't see the result. If the tool output never made it back into context (truncated, errored, dropped), the model reasonably tries again.

Reads are fine to repeat. Writes are not.

Split your tools by side effect

Before you add any retry logic, label every tool:

Class Example Safe to retry blindly?
Read get_order, search_docs Yes
Idempotent write set_ticket_status(id, "closed") Yes, same end state
Non-idempotent write issue_refund, send_email, create_ticket No

The third row is where you need idempotency keys.

How idempotency keys work (Stripe's version)

Stripe's API docs describe the pattern clearly:

  • The client generates a unique key per logical operation and sends it with the request (Idempotency-Key header). Stripe suggests V4 UUIDs or another random string with enough entropy; keys can be up to 255 characters.
  • For API v1, Stripe saves the status code and body of the first request with that key once execution begins. Retries with the same key get the saved response, including 500 errors.
  • The idempotency layer compares the incoming parameters to the original request and errors if they differ, so a reused key can't silently do something different.
  • Keys can be pruned once they're at least 24 hours old; after that, a reused key is treated as a new request.
  • Don't put sensitive data (emails, personal identifiers) in the key.

The important part for agents: the key must be stable across retries of the same intent. A fresh UUID per HTTP attempt defeats the whole point.

Where the key comes from in an agent

Derive it from something that stays the same when the same action is retried:

  • the agent run ID + step number, or
  • the model's tool call_id (OpenAI returns one per function call), or
  • a business key like refund:{order_id}, when "at most one refund per order" is the real rule.

Here's a minimal wrapper for your own tool layer:

import hashlib, json

def idem_key(run_id: str, tool: str, args: dict) -> str:
    payload = json.dumps(args, sort_keys=True)
    return hashlib.sha256(f"{run_id}:{tool}:{payload}".encode()).hexdigest()

def run_side_effect(store, run_id, tool, args, fn):
    key = idem_key(run_id, tool, args)
    saved = store.get(key)              # e.g. a Postgres table with a UNIQUE key
    if saved is not None:
        return saved                    # replay: return the first result, don't redo it
    result = fn(**args, idempotency_key=key)  # pass through if the downstream API supports it
    store.put(key, result)
    return result
Enter fullscreen mode Exit fullscreen mode

Two details matter:

  1. Use a unique constraint, not a "check then insert". Two parallel calls can both see "not found" otherwise. Insert a pending row first and let the database reject the duplicate.
  2. Pass the key downstream when the provider supports it (Stripe does). Your store protects you from your own retries; the provider's key protects you when your store write fails after the provider succeeded.

Tell the model what happened

Return a clear tool result on replay, for example:

{"status": "already_done", "refund_id": "re_123", "note": "This refund was already issued for this order. Do not retry."}
Enter fullscreen mode Exit fullscreen mode

A model that sees "already done" stops. A model that sees a vague error tries again.

Checklist

  • Label every tool: read, idempotent write, non-idempotent write.
  • Every non-idempotent write takes an idempotency key derived from run + step, call_id, or a business key.
  • Store results under a unique constraint and return the saved result on replay.
  • Pass the key to downstream APIs that support it.
  • Return explicit "already done" results to the model.
  • If a model snapshot duplicates parallel calls, set parallel_tool_calls to false for it.
  • Test it: kill the worker right after the side effect and replay the run. You should see one refund, not two.

Your agent will retry. Make sure the world only changes once.

References

Top comments (0)