PARAS MADAN
All writing

Latest in AI

AI Agent Calling Tools Twice? How to Prevent Duplicate Actions

A timeout can hide a successful action, so the fix starts with how your application identifies and recovers a piece of work.

If an AI agent creates the same ticket or sends the same email twice, start by checking the execution history. The model may have requested another action, but your application may also have replayed a call whose first attempt succeeded. Both paths need protection at the system doing the work.

Telling the model “never repeat a tool call” is a weak fix because it cannot see everything that happened outside its conversation. A request can reach your service, change something, and lose its response on the way back. The agent sees a timeout while the customer sees two emails.

This is a concrete developer problem rather than a hypothetical objection to agents. An October 30 OpenAI community discussion describes repeated mutations after timeouts and asks where the application should create the identity for each action. That report is useful context, not evidence of how frequently the problem occurs.

Find which layer repeated the action

Record three identifiers separately: the model's tool-call ID, your application's operation ID, and the downstream provider's request or object ID. Add timestamps and attempt numbers. Keep credentials and unnecessary personal data out of these logs.

The tool-call ID matches a result to a particular model request. Your operation ID should represent the actual piece of work, such as sending one approved onboarding email. A new model turn, worker restart or HTTP attempt must not silently turn that same piece of work into a new operation.

What the trace showsLikely place to investigate
One tool call, two outgoing HTTP requestsSDK, worker or transport retries
Two tool calls for the same intended actionAgent loop and operation identity
Two workers execute the same queued taskQueue delivery and concurrent claims
An action repeats after resuming a runCheckpoint boundary and replay behavior

These are diagnostic leads, not proof. Trace the downstream object before changing the retry policy. Disabling every retry can reduce duplicates while leaving users with work that never finishes.

Give each intended action a durable identity

Create an opaque operation ID in application code and persist it before execution. Bind it to the authenticated tenant, action type, approved target and canonical payload. The model can propose arguments, but it should not choose a new identity every time it wants another attempt.

For example, suppose a user approves sending onboarding message version 3 to customer 42. Store that approval as operation op_7f2, along with the recipient and message content. A timeout, repeated queue delivery or resumed agent run continues op_7f2. A later, genuinely separate email gets another operation ID, even if its text happens to be identical.

Identical arguments are not enough to establish identical intent. Hashing the recipient and body alone would accidentally suppress two legitimate messages. Conversely, adding the current timestamp to every key would make every retry look new. Keep the operation identity stable and compare a canonical payload fingerprint to detect accidental changes within that operation.

Claim the operation atomically, using a database uniqueness constraint or equivalent transactional mechanism. A separate “does this exist?” read followed by an insert leaves a race where two workers can both decide to proceed. Record the downstream object ID and result when execution is confirmed.

A pending record does not close the crash window

Consider this illustrative sequence. The application records op_7f2 as pending, the email service accepts the request, and the worker dies before saving the returned message ID. On restart, the database still says pending. Sending again would be a guess.

The important recovery state is unknown outcome. A timeout tells you that an acknowledgement did not arrive, not that the action failed. Keep that distinction in the tool result returned to the model, so it does not interpret a generic error as permission to try the whole task again.

LangGraph's fault-tolerance documentation makes the boundary explicit: retries re-execute node functions, while external effects sit outside graph checkpoint transactions. A stable key must already be durable before the effect happens. A checkpoint can help recover the workflow without making every external API call exactly-once.

If the downstream service supports idempotency, pass the same persisted key on every retry within its documented rules. If it offers a reliable operation-status lookup, reconcile that result first. An empty search result may reflect a delay in the provider’s records, so only treat it as proof of non-execution when the API contract supports that conclusion. When neither exists, stop automatic replay of an uncertain write and route it for reconciliation. A local lock can prevent concurrent attempts, but it cannot prove what a remote service did before a connection broke.

Check the provider's actual idempotency contract

Idempotency means repeated attempts at the same operation do not create additional effects. The details still belong to the provider, including how long keys are retained, which endpoints support them, and how failures behave.

Stripe's API documentation is a useful example, not a contract you can assume for an email service. In API v1, it stores the first response after endpoint execution begins, including a 500 response. Keys can be pruned after at least 24 hours, and reusing a pruned key can create a new request. Changed parameters under the same retained key are rejected. API v2 has different recovery semantics for failed or partially failed work.

That means an old pending operation needs reconciliation rather than an unlimited retry loop. Store the provider contract and retry deadline with your operational policy. A successful deduplication layer also does not establish that an action was authorized: validate permissions, recipient and payload before execution, separately from duplicate protection.

Parallel-call settings solve a narrower problem

For OpenAI function calling, parallel_tool_calls: false limits a response to zero or one tool call, according to the function-calling guide. It can simplify a write-heavy loop, but the next turn can still request the same action. Strict schemas constrain arguments; they do not make the external effect unique.

For Claude's Messages API, use tool_choice: {"type": "auto", "disable_parallel_tool_use": true} to allow at most one tool call per response. The setting belongs inside tool_choice. Claude's parallel-tool documentation also distinguishes this from Managed Agents, where parallel tool use is always on.

Keep independent reads parallel when appropriate and serialize dependent writes. Neither setting replaces an atomic operation claim or downstream idempotency. I would fix those execution boundaries before spending another afternoon rewriting the prompt.

Test the failures that a happy-path demo misses

Use a sandbox or fake provider before enabling automatic writes. The following is a proposed test checklist, not a claim that a particular production integration has passed it:

  • Deliver the same task to two workers simultaneously. Confirm one intended effect.
  • Drop the response after the provider commits. Confirm recovery uses the original operation identity.
  • Restart before saving the result. Confirm the pending record triggers reconciliation.
  • Replay an operation with changed arguments. Confirm it is rejected rather than treated as an approved update.
  • Submit two separately authorized, identical actions. Confirm both remain possible.
  • Retry after the provider's key-retention window. Confirm the system does not assume deduplication still applies.

Count actual downstream objects, not just successful tool responses. A retry that returns a clean JSON result can still have created a duplicate, and a timeout can still conceal a completed action.

The useful goal is an agent that knows when it has enough evidence to continue. If your system cannot distinguish “failed” from “may already have happened,” giving the model more attempts only makes that uncertainty more expensive.

I'm Paras. I work at the intersection of AI, engineering and distribution.

I help technical teams turn implementation decisions and product lessons into clear stories that developers and founders read and share.

Book a 30-minute call