Derive idempotency keys for AI agent tool calls
Hash the task id, tool name and canonical arguments in the harness, and pass the key to the system that owns the side effect, not only to a local ledger.
Derive the key in the harness, not in the model: hash the task id, the tool name and the canonicalized arguments, and pass it to the tool, which forwards it to the system that owns the side effect. A local ledger alone cannot guarantee that a retried step, or a replay after a crash, does not repeat the effect.
import { createHash } from "node:crypto";
type Ctx = { idempotencyKey: string; signal: AbortSignal };
const canonical = (v: unknown) =>
JSON.stringify(v, (_, x) =>
x && typeof x === "object" && !Array.isArray(x)
? Object.fromEntries(Object.entries(x).sort(([a], [b]) => (a < b ? -1 : 1)))
: x,
);
export const idempotencyKey = (taskId: string, tool: string, args: unknown) =>
createHash("sha256").update(canonical([taskId, tool, args])).digest("hex");
// Write tool: forward the key to the owner of the side effect.
export async function openPullRequest(args: unknown, ctx: Ctx) {
const res = await fetch("https://git.example.com/api/pulls", {
method: "POST",
headers: { "Content-Type": "application/json", "Idempotency-Key": ctx.idempotencyKey },
body: canonical(args),
signal: ctx.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
// Harness side: the model never supplies the key.
export async function callWrite(
taskId: string, name: string, args: unknown, signal: AbortSignal,
run: (args: unknown, ctx: Ctx) => Promise<unknown>, ledger: Map<string, unknown>,
) {
const key = idempotencyKey(taskId, name, args);
if (ledger.has(key)) return ledger.get(key); // retried step: skip the world
const result = await run(args, { idempotencyKey: key, signal });
ledger.set(key, result); // crash before this line: the owner must dedupe
return result;
}Why does it work?
- The model does not own the key. The MCP schema has no idempotency-key field (
idempotentHintonly describes behavior), and I do not trust a model to repeat a key across attempts. - Each input has a job. The task id scopes the key, sorted keys make
{a, b}and{b, a}equal, and hashing an array keeps("a", "bc")apart from("ab", "c"). - The ledger is a cache, not the guarantee. If the process dies after the side effect and before
ledger.set, the replay repeats it. Only the owner of the effect closes that window, through anIdempotency-Keyheader or a unique constraint. A payment API that honors idempotency keys returns the first saved response for a repeated key and errors on the same key with different parameters.
When should you not use it?
- Where an effect has a natural identity, such as one pull request per task, key on that: a rephrased replay gets a new key here, and identical legitimate writes in one task collapse into one.
- If the downstream system can neither take a key nor enforce a unique constraint, keep that write out of the loop.