Two dark teal glacial streams converge into one channel between textured white and pale blue ice banks, seen from directly above
All posts

Idempotent tool calls: stop an LLM agent retry from creating a duplicate ticket

Idempotency in AI agents starts outside the model: tool_call_id is not a business key. Mint the operation ID in your backend, claim it with the write, and replay the stored result.

A thread on the OpenAI developer forum from late October describes the failure better than most guides: the model calls create_order, the HTTP round-trip times out at 8 seconds, the agent loop retries the same tool call, and now there are two orders. The author's point that stuck with us is that the model's tool_call_id identifies a call inside a conversation, not a mutation inside your system. Key on the arguments alone and two legitimately identical tickets collide. Key on nothing and every timeout becomes a double write.

We think the fix is smaller than most agent frameworks make it look. The key comes from your backend and never from the model, and claiming it shares one database transaction with the write. Below it runs as an AIVAX Protocol Function callback in Bun and SQLite.

A timeout leaves you not knowing whether the write happened

A ticket can exist even when its confirmation never reached the caller. That makes an error response ambiguous: the write may or may not have happened. Agent loops make the ambiguity worse because the retry is a decision, not a fixed policy. The model sees a failed call and may call again with the same arguments, slightly different arguments, or not at all, and a prompt that says "only call this once" gives you no lock and no record. Tian Pan's write-up frames this as the distributed-systems problem it is, and MCP has the same exposure: since revision 2026-07-28 a broken response stream loses the in-flight request and the client reissues it, as covered in migrating an MCP server off Mcp-Session-Id.

The transaction boundary therefore has to live in the backend that performs the write, next to both the key and the effect.

What a Protocol Function callback receives

AIVAX makes one HTTP POST per Protocol Function invocation and does not retry that POST internally. The body carries function.name, the model-generated arguments in function.content, and a context object with externalUserId, metadata, callSource, conversationToken, and moment. There is no unique callback ID in it, and the platform's internal tool-call IDs are not forwarded.

So the stable identifier has to come from you. Your application creates metadata.request_id for one logical operation, sends it with the inference request or chat session, and keeps it across retries. It changes only when the user deliberately starts another operation; a session-wide value reused forever would swallow later, legitimate identical actions. This matches the forum reply that suggests minting the key in the orchestrator and never letting the model overwrite it.

conversationToken looks tempting and is the wrong field. It correlates a whole conversation and is shared by every call in it. On /v1/chat/completions it now also drives session reuse: AIVAX restores server-side tool calls and results from earlier turns into the history for an hour, so the model does not repeat a call merely because it forgot the first one happened. That helps, and it still does nothing to stop a second callback with the same arguments. The idempotency_key request field, despite its name, is a deprecated alias of conversation_token and does not deduplicate tool execution. moment changes on every callback and does not belong in a key either.

Identity is a separate check. Resolve externalUserId against your own authorized user records and take the tenant from that record, never from model arguments or a metadata.tenant_id the client chose. Authorize before you return a cached result too, or the deduplication path becomes a way to read someone else's ticket. When the account has a hook key, AIVAX also sends X-Request-Nonce, a fresh BCrypt hash of that key with work factor 9; verify it with BCrypt rather than comparing against one fixed value. It proves the call came from your account. It does not sign the body or prevent replay, and the same split between identity metadata and enforced permission runs through our post on connecting third-party MCP servers safely.

A key that changes when the intent changes

The excerpts below come from the complete, tested server.js, which also holds the HTTP handler, access checks, validation, and logging. Arguments are canonicalized so that object key order does not change the key:

function canonicalJson(value, depth = 0) {
  if (depth > 64 || (typeof value === "number" && !Number.isFinite(value))) {
    throw new TypeError("JSON exceeds the allowed limits.");
  }
  if (Array.isArray(value)) {
    return `[${value.map(item => canonicalJson(item, depth + 1)).join(",")}]`;
  }
  if (value !== null && typeof value === "object") {
    return `{${Object.keys(value).sort().map(
      key => `${JSON.stringify(key)}:${canonicalJson(value[key], depth + 1)}`,
    ).join(",")}}`;
  }
  return JSON.stringify(value);
}

The handler hashes an unambiguous tuple of tenant, request ID, function name, and canonical arguments with createHash from node:crypto:

const key = createHash("sha256").update(JSON.stringify([
  user.tenantId, requestId, body.function.name, argumentsJson,
])).digest("hex");

This is deterministic for the JSON it accepts, though not a full RFC 8785 implementation, and it deliberately does not treat paraphrases as equal. Rewording a title or reordering an array produces a new key, which forces a policy decision: under the same request_id, changed arguments can be a correction the user wanted or a semantically duplicate ticket the model invented. If your rule is one ticket per approved request, make tenant plus request ID plus function the unique key, store the argument fingerprint beside it, and reject a mismatch. The tested demo keeps the argument-sensitive version, and switching requires changing the code.

Claim the key and create the ticket in one transaction

An in-memory Set forgets everything on restart and cannot coordinate two processes. SQLite gives us a durable constraint instead. operations.idempotency_key is the primary key, and tickets.operation_key is NOT NULL UNIQUE REFERENCES operations(idempotency_key). The store runs in WAL mode with foreign keys, full synchronous writes, and a bounded busy timeout. The transaction claims the key first and, on conflict, returns what is already stored:

const createTicket = db.transaction((key, user, argumentsJson) => {
  const claimed = db.query(`
    INSERT INTO operations (idempotency_key, user_id, state)
    VALUES (?, ?, 'pending') ON CONFLICT(idempotency_key) DO NOTHING
  `).run(key, user.userId);

  if (!claimed.changes) {
    const original = db.query("SELECT * FROM operations WHERE idempotency_key = ?").get(key);
    if (original.user_id !== user.userId) {
      return reject("request_id_conflict", "request_id already belongs to another user in this tenant.", 403);
    }
    if (original.state === "pending") {
      return Response.json({ status: "in_progress", retryable: true, message: "Operation pending; do not create another one. Check again with the same data." });
    }
    return Response.json({ ...JSON.parse(original.result_json), deduplicated: true });
  }

  const result = { ticket_id: randomUUID(), status: "created" };
  db.query(`
    INSERT INTO tickets (ticket_id, operation_key, tenant_id, user_id, arguments_json)
    VALUES (?, ?, ?, ?, ?)
  `).run(result.ticket_id, key, user.tenantId, user.userId, argumentsJson);
  db.query("UPDATE operations SET state = 'completed', result_json = ? WHERE idempotency_key = ?")
    .run(JSON.stringify(result), key);
  return Response.json({ ...result, deduplicated: false });
});

The handler runs it as createTicket.immediate(key, user, argumentsJson), so SQLite takes the write lock before the claim. The operation row, the ticket, and the saved result commit together, and an exception inside the transaction rolls all three back. A failure after commit, while writing the response or the log, cannot undo the ticket; the next call with the same key recovers it. A repeat returns the original ticket_id and status with deduplicated: true. A row left in pending answers in_progress and is never taken over, which is the forum's other good suggestion: when a replay finds a pending key, check the downstream system before running anything again.

What the model sees when the callback says no

For a non-2xx response or an exception, AIVAX passes the model exactly "The tool call resulted in an error." Your error body is not forwarded to the model. The model may try again, or may not, and either way that is a model decision, not an HTTP retry.

That shapes the status codes. Use HTTP 200 with a structured rejection for input errors the model can correct, keep 401 and 403 for authentication and authorization, and 500 for internal failures. A 200 does not mean the business action succeeded, so the body has to say what happened.

Callback responses and the information delivered to the model
SituationCallback responseWhat the model seesBackend action
Correctable validation failureHTTP 200, {status: "rejected", error: {code, message}}The rejection body, including the correction neededDo not create a ticket
Authentication or authorization failureHTTP 401 or 403The tool call resulted in an error.Reject before any write or cached-result disclosure
Internal failureHTTP 500The tool call resulted in an error.Roll back uncommitted writes; after commit, recover the outcome with the same key
Retained pending operationHTTP 200, status: "in_progress"The pending status and instruction not to create another operationReturn pending; reconcile rather than re-execute
SQLite writer unavailableHTTP 200, status: "busy", retryable: trueA retryable busy resultRetry the same operation later, with a bounded policy
Completed duplicateHTTP 200, stored result plus deduplicated: trueThe original ticket ID and outcomeNo new ticket

The demo, for instance, answers a missing externalUserId with a structured rejection and no write, and an unknown user with 403. In production the application supplies authenticated identity; the assistant must never invent one to get past the error. retryable and the status names are our conventions, not AIVAX fields, and they schedule nothing. Bounded retries and escalation stay in your application, a responsibility the stateless MCP post discusses at more length. Return only what the next step needs: the callback response is read up to 20 MB, and anything larger reaches the model as the same generic error.

Two identical calls against the demo

Download server.js and config.json.example into an empty directory. With Bun installed:

cp config.json.example config.json
bun server.js --config config.json

The configuration uses tickets.sqlite, port 3210, and a synthetic user-a mapped to tenant-a and user alice, and it binds only to 127.0.0.1. The fixed nonce exists only to simulate authentication locally, so do not expose this demo as a real callback; a deployment needs BCrypt verification, protected transport, and identity bound to the application's own session. In another terminal, send the same payload twice:

for attempt in 1 2; do
  curl --fail-with-body --silent --show-error \
    http://127.0.0.1:3210/callback \
    -H 'Content-Type: application/json' \
    -H 'X-Request-Nonce: local-demo-nonce-0123456789' \
    --data '{
      "type": "tool_call",
      "function": {
        "name": "create_ticket",
        "content": {"title": "Cannot sign in"}
      },
      "context": {
        "externalUserId": "user-a",
        "metadata": {"request_id": "request-example"}
      }
    }'
  printf '\n'
done

On a fresh database the first response has status: "created" and deduplicated: false, and the second returns the same ticket_id with deduplicated: true. Run the pair again and both calls return the stored ticket. debug.log records the operation key and response status, not the payload.

When we wrote this we ran it with Bun 1.3.14 and curl. Ten concurrent calls from two processes sharing one database produced one creation and nine deduplicated responses. The same run confirmed argument-sensitive keys, independence from object key order, tenant isolation, nonce rejection, identity and argument validation, authorization, busy under a held write lock, and no re-execution of injected pending records. An injected insert failure rolled back both writes, and a restart reused the saved result.

The external system has to deduplicate too

Swap the SQLite insert for a CRM request and the transaction stops protecting you. If the CRM creates the ticket and the callback dies before saving the result, the next attempt creates a second one. Recording the key first only converts that failure into an unresolved pending row; it does not tell you whether the remote write happened.

If the destination accepts idempotency keys, forward the same stable key, persist the ticket ID it returns, and match its key scope and retention window. If it supports lookup by a unique operation identifier, use that to resolve a pending row, after checking its uniqueness and consistency guarantees. For asynchronous delivery, write the operation and an outbox record in one transaction and let a worker deliver with bounded retries, the same admission-and-retry discipline we describe for running LLM requests in batch. An outbox on its own still produces duplicates when the destination cannot deduplicate or be queried, and in that case we would stop and surface the uncertainty to a person rather than promise exactly-once behaviour.

Retention is the last decision. The deduplication window lasts exactly as long as you keep the keys, so deleting one makes its next delivery look new. Keep keys and results for the retry and reconciliation horizon your business needs, and protect the stored results as carefully as the tickets themselves. A user who types "try again" after a failed mutation is the hard case the forum thread ends on; our answer is to reuse the key while the original is pending or unresolved and mint a new one only once the first outcome is known.

The two-identical-calls demo was run again on November 10, 2026 with Bun 1.3.14 and curl: the first call returned created with deduplicated: false and the second the same ticket_id with deduplicated: true. The callback was called directly with the request body documented for Protocol Functions. AIVAX behaviour was checked against the source and the Protocol Functions documentation on 9 November 2026. Abrupt process death during commit, BCrypt verification, the callback's timeout and a distributed store are outside what the demo covers.