The same tool,called twice.
The model asked for the same refund twice and both ran. This guide covers where the second call comes from, why a tool call id doesn't stop it, and the fix, with a script that counts the refunds.
How the same call arrives twice
This is a different problem from a retry after a crash, where the framework runs your tool again. Here the model asked twice, and your tool did what it was told. It happens in a few ways:
- The model puts two identical calls in one step. Nothing in the protocol stops a model from returning the same call twice, under two ids.
- The model asks again in the next step because the first result looked like a failure: a timeout message, an empty body, a result it didn't understand.
- A retry layer above the tool runs it again after a timeout, and the model has also retried. You get one from each.
When one step holds several calls, the AI SDK runs them concurrently by default (opens in a new tab). A toolCallConcurrency setting caps how many run at once, and a value of 1 runs them in the order the model wrote them. That matters in a moment.
You can't stop the model from asking. A line in the prompt such as "only call this once" doesn't help, because the model can't know whether its earlier call landed. The tool has to make the second ask harmless.
A call id is the wrong key
Every call the model makes has its own id. The AI SDK passes it to your tool as toolCallId, and other frameworks hand your tool something similar. It is tempting to use it as an idempotency key, since it is unique and always there. It is unique for each call, which is the problem: two identical asks get two ids, so the provider sees two different requests.
The key has to name the thing being done, so that two asks for the same thing give the same key and two different things give different ones. For a refund that is the run and the order:
TypeScript
// the call id is new for every call the model makes
provider.refund(order, usd, toolCallId);
// the same for every ask for this refund: what the action is, in this run
provider.refund(order, usd, `${runId}:refund:${order}`);Choose the parts with care. Leave out anything the model makes up, such as a free-text reason, since it will word it differently the second time. If the same order could legitimately be refunded twice in one run, add a counter that your own code assigns, not the model.
A check inside the tool can race
The usual alternative to a key is a check: look in the database for a refund on this order, and do nothing if one exists. It works when the calls come one at a time. When two calls from the same step run together, both look before either writes, both find nothing, and both refund.
The script below scripts a model that asks twice in one step under two ids and tries six versions of the tool. Each version gets a fresh stand-in for a payment provider that keeps the first refund made under a key.
dup.ts
import { generateText, tool, isStepCount } from "ai";
import { MockLanguageModelV4 } from "ai/test";
import { z } from "zod";
// A stand-in payment provider, like Stripe: the first refund under a key is kept, a repeat gets the same one.
const refunds = new Map();
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
async function providerRefund(order, usd, key) {
await wait(20); // the network
if (key && refunds.has(key)) return refunds.get(key);
const r = { id: `re_${refunds.size + 1}`, order, usd };
refunds.set(key ?? `nokey-${refunds.size}`, r);
return r;
}
// A model that asks for the same refund twice in one step, under two different tool call ids, then answers.
function model() {
let call = 0;
return new MockLanguageModelV4({
doGenerate: async () => {
call++;
const usage = { inputTokens: { total: 10, noCache: 10 }, outputTokens: { total: 5, text: 5 } };
if (call === 1) {
const c = (id) => ({ type: "tool-call", toolCallId: id, toolName: "refund_order", input: JSON.stringify({ order_id: "1182", usd: 42 }) });
return { content: [c("call_a"), c("call_b")], finishReason: { unified: "tool-calls", raw: undefined }, usage, warnings: [] };
}
return { content: [{ type: "text", text: "Refunded order 1182." }], finishReason: { unified: "stop", raw: undefined }, usage, warnings: [] };
},
});
}
async function attempt(label, execute, extra = {}) {
refunds.clear();
await generateText({
model: model(),
prompt: "Refund order 1182.",
stopWhen: isStepCount(3),
tools: { refund_order: tool({ description: "Refund an order", inputSchema: z.object({ order_id: z.string(), usd: z.number() }), execute }) },
...extra,
});
console.log(`${label.padEnd(46)} refunds on file: ${refunds.size}`);
}
// 1. what most tools look like: the key is the tool call id, or there is no key
await attempt("no key", ({ order_id, usd }) => providerRefund(order_id, usd));
await attempt("key = tool call id", ({ order_id, usd }, { toolCallId }) => providerRefund(order_id, usd, toolCallId));
// 2. key = what the action is: this refund, on this order, in this run
await attempt("key = run + order", ({ order_id, usd }) => providerRefund(order_id, usd, `run-1:refund:${order_id}`));
// 3. check-then-act without a key: the two calls run at the same time, so both pass the check
const seen = new Set();
await attempt("check, then act (no lock)", async ({ order_id, usd }) => {
if (seen.has(order_id)) return { note: "already refunded" };
const r = await providerRefund(order_id, usd);
seen.add(order_id);
return r;
});
seen.clear();
// 4. the same check, but claimed before the call, in one step
await attempt("claim first, then act", async ({ order_id, usd }) => {
if (seen.has(order_id)) return { note: "already refunded" };
seen.add(order_id); // JS runs this line to the end before the other call starts
return providerRefund(order_id, usd);
});
seen.clear();
// 5. one tool at a time
await attempt("toolCallConcurrency 1, check, then act", async ({ order_id, usd }) => {
if (seen.has(order_id)) return { note: "already refunded" };
const r = await providerRefund(order_id, usd);
seen.add(order_id);
return r;
}, { toolCallConcurrency: 1 });no key refunds on file: 2
key = tool call id refunds on file: 2
key = run + order refunds on file: 1
check, then act (no lock) refunds on file: 2
claim first, then act refunds on file: 1
toolCallConcurrency 1, check, then act refunds on file: 1- With no key, or with the tool call id as the key, there are two refunds.
- A key built from the run and the order gives one. The provider sees the second call as a repeat of the first.
- A check followed by the call gives two, because the check is not atomic with the action.
- Claiming the order first, then calling, gives one. In this single process the claim finishes before the other call starts. Across several processes, the claim has to be an insert that fails on a duplicate, such as a unique key in Postgres, and it should record whether the call finished, as
once()does in the retry guide. A bare claim that is never cleared would block the retry after a real failure. - Setting
toolCallConcurrencyto 1 makes the plain check work again for calls in the same step. It does nothing about a duplicate in a later step, a second worker or a retry from another layer, so don't treat it as the fix.
If your provider accepts a key, pass yours and let it do the deduplicating. Keep your own record as well for anything that can wait longer than the provider remembers a key.
What to tell the model
When the tool sees a repeat, return the result of the first call and say it was already done: the refund id, the amount, and a line like "already refunded, no further action needed". Don't return an error. A model that reads an error may try again, perhaps with a different argument, and now you have two different refunds.
Treat that message as a courtesy. It lowers the odds of a third call and doesn't replace the key, because nothing in a prompt or a result forces a model to stop.
Test it
The script above is the test. The AI SDK ships MockLanguageModelV4 in ai/test, so you can script a model that returns whatever calls you like and run your real tools against it with no API key. Cover three cases: two identical calls in one step, the same call in two steps, and one tool call that raises after the action. Count what reached the provider each time, not what the model said at the end. If your tool takes the key from the framework's context, check that the value is the same when the same ask comes in under a new call id.
If you'd rather not build this
The Postgres version of the claim is a small table and one function, and is worth writing yourself for the few tools that spend money or message people. Durable-execution tools such as Temporal, Restate, DBOS and Inngest record each action and can answer a repeat from the record, and the AI SDK's WorkflowAgent is built to run an agent on the Workflow DevKit. Argentic is one more option: it journals each tool call and gives your tool a key that stays the same when the action is retried. That key is per action in the journal, so two separate asks from the model are two actions, and you still choose a key like the one above for those.
What this doesn't cover
- The model is scripted. A real model produces duplicates less predictably, so the script shows what your tool does when one arrives and says nothing about how often it will.
- The code ran with
ai7.0.150 on Node 22. We did not run it against Mastra, whose tools takeexecute(inputData, context)according to its docs. Check what your context object carries and key on your own ids. - The sample provider is a stand-in. A real one that ignores keys will take both calls.
- Two calls that differ in an argument are two calls. If the model asks for a refund of 42 and then 41, no key stops both, so cap refunds per order in the tool.