Development

Letting the Model Choose the Key: Why It Usually Cannot

Antonio Evans9 min read

A split scene: on the left, a model icon emits three different key strings into three identical tool-call boxes; on the right, a single fixed input block (tenant, operation.
Key points

Key points

  1. 1A model produces sampled text, not a deterministic function, so the same request can yield a different idempotency key on a retry and the downstream service treats it as a new operation.
  2. 2Identifier fields such as idempotency keys should be absent from the tool schema the model sees, and generated by the orchestration layer at dispatch from stable inputs.
  3. 3The key has to stay the same across transport retries, model-requested retries and orchestrator restarts, which means persisting it next to the logical operation before the first call goes out.
  4. 4The three triggers that produce retries (transport loss, malformed output, model judgment) need to be separated, because only one of them is evidence that nothing happened.

Why a generated key drifts between attempts

Ask a model to fill an idempotency_key field and you get a string that looks convincing. Ask twice and you often get two different strings. That is not a bug in the model. Under the hood, tool calls are text that the provider's inference layer parses into JSON: Anthropic returns toolu_ prefixed IDs, OpenAI uses call_ prefixes, and MiniMax publishes its raw XML output along with the regex that converts it, which shows the translation plainly the SylphAI guide to agentic tool calling. Sampling controls which token gets chosen at each position, so any field the model writes, including a UUID-shaped one, is one sample among many.

The provider-supplied tool call ID does not rescue you either. Those IDs identify a call, not a business operation, and a fresh identifier is not automatically a stable business-operation identifier Formation's analysis of agent tool retries. If the orchestrator restarts, replays a step or re-plans, a new call ID appears. Deduplication keyed on it fails.

There is a second drift path that has nothing to do with sampling. A model may decide a valid result was inadequate and request another call. That request is a judgment, not evidence that the previous operation failed Formation's analysis of agent tool retries. If the model regenerates the key on that second attempt, you have created two payments for one intent.

A concrete way to see the difference: run the same tool call twice against a real model with the key in the schema and log the two strings. They will differ at most positions. Run the same call twice with the key derived in code and log the two hashes. They will be byte-identical. That is the whole argument in one experiment, and it takes a few minutes to run.

The drift also survives temperature changes. Lowering temperature narrows the distribution, it does not collapse it to a function. Nothing in the model contract promises that a field is deterministic across calls, and no prompt wording turns a sampled field into one.

Why the schema still contains the field

Most tool schemas grow the field because it is convenient. The model is already producing a JSON object, the downstream API wants an Idempotency-Key header, so someone adds it to the parameters and moves on. Stripe's API documents exactly that header for safe retries Stripe's idempotent requests reference, and many teams mirror the upstream requirement into their own tool definitions without thinking about who should own the value.

The pressure to do this comes from the retry loop itself. Agent frameworks encourage plan, call tool, observe, plan again, and when a call fails the model is rewarded for persistence Tool Calling Has a Tax You're Not Seeing. Persistence without a stable identity becomes duplication. The field stays in the schema because removing it feels like removing safety, when in fact the opposite is true.

There is a subtler version of the same mistake: putting the field in the schema, then instructing the model in the system prompt to "copy the key from the previous message." That instruction is prompt text. It is not enforced, it does not survive a restart, and it does not survive the model deciding the previous call did not happen. The schema is where the guarantee either exists or does not.

A useful test: for each field in a tool schema, ask whether the correct value is a function of (tenant, operation, arguments). If it is, the model cannot add information by choosing it, and every token spent generating it is a chance to get it wrong. Fields like amount, customer_id and currency do carry information from the conversation. Fields like idempotency_key, request_id and trace_id do not.

The retry scenario, with numbers

Take a payment tool with arguments {customer_id, amount_cents, currency}. The orchestration layer has a durable step ID for the charge, for example ord_8812:charge:1.

Attempt one: the dispatch layer derives the key sha256("acme|charge|ord_8812:charge:1"), sends the request, and the payment provider accepts the charge but the response is lost in a network timeout. The charge succeeded server side. A timeout after dispatch does not establish whether the side effect committed, which is why retries need a key the provider can deduplicate Formation's analysis of agent tool retries.

Attempt two: 1.8 seconds later, the client retries. Because the key is derived from the persisted step rather than sampled, the same hash goes out. The provider recognizes it and returns the original charge instead of creating a second one. Monday's API shows the same contract in concrete terms: a replayed request returns a header marking it as a replay, and a concurrent duplicate returns 409 Conflict with a Retry-After header rather than processing twice Monday's idempotency documentation.

Now the failure case. If the model had generated the key, attempt two would carry a different string. The provider would see a brand new request with the same amount, no deduplication record and no reason to refuse. A retry storm of three attempts on a $49.00 charge becomes $147.00 of exposure on a single order, and reconciliation happens after the customer has already seen the statement.

The same numbers apply to email sends, ticket creation and refunds. Anywhere the effect is visible outside your database, a drifted key is a duplicate.

TriggerIs the effect known to have failed?Correct action
Timeout or transport lossNoReuse the persisted key and retry
Malformed or unparseable responseNoRe-read or repair the result before retrying
Model judges result inadequateNo, and not evidence of failureRefuse to redispatch blindly, reconcile first
Known rejection with validation errorYesFix the arguments, do not retry unchanged
Throttling with Retry-AfterYes, per the contractWait the stated interval, then retry

That table is worth committing to code as a decision rule rather than leaving it to prompt instructions.

Moving key derivation outside the model

The derivation function should be boring, deterministic and independent of plan position. A workable version:

import hashlib, json

def derive_key(tenant: str, operation: str, step_id: str, args: dict) -> str:
    payload = json.dumps(
        {"tenant": tenant, "op": operation, "step": step_id, "args": args},
        sort_keys=True, separators=(",", ":"),
    )
    return hashlib.sha256(payload.encode()).hexdigest()

Four rules make this safe.

First, sort the arguments so that key ordering does not change the hash. JSON object key order is not a stable input otherwise.

Second, treat step_id as a durable occurrence identity, not a counter that resets on restart. The charge for this order needs one identity; a separately approved second purchase needs another Formation's analysis of agent tool retries.

Third, persist the derived key and the approved arguments before the first dispatch, in the same transaction as the step record where possible. A crash between changing state and recording completion leaves a gap. Monday's documentation makes the related point directly: store the key alongside the request so retries use the same value, and do not generate a new key on every retry attempt Monday's idempotency documentation.

Fourth, handle changed arguments as a separate decision. If the model changes the amount while the original charge is unresolved, do not silently mint a new key and dispatch. Both amounts could otherwise be charged for one order.

One more consideration: client-side deduplication that lives only in the agent's memory does not survive a crash. If the process dies after dispatch, the persisted key is the only thing that lets the recovered process recognize the operation as already in flight Tool Calling Has a Tax You're Not Seeing.

Steps to strip the field and inject it at dispatch

Do the change in the schema first, then in the dispatcher, then in the tests. Removing the field last is how teams end up with a period where neither side owns the key.

  1. Remove idempotency_key (and any similar identifier field such as request_id, trace_id or dedupe_token) from every tool schema the model can see. If the model never sees the field, it cannot fill it in badly.
  2. Add the key as a required argument of the tool's server-side handler, not of the model-facing schema. The handler signature becomes the contract.
  3. Derive the key in the dispatcher from tenant, operation, durable step ID and sorted arguments, using a function like the one above.
  4. Persist the key with the step record before dispatch, and read it back on every retry path, including model-requested retries and recovery after an orchestrator restart.
  5. Send the key in the header or field the downstream API expects. Stripe expects the Idempotency-Key header Stripe's idempotent requests reference; Monday documents the same header with a 30 minute cache window and a 1 MB cap on cached responses Monday's idempotency documentation.
  6. Encode the trigger table above as code so the dispatcher decides whether an attempt is a retry of an existing operation or a new operation.
  7. Add a test that runs the same tool call twice through the dispatcher with different random model outputs and asserts one key and one side effect. Add a second test that restarts the orchestrator between attempts and asserts the key is unchanged.

A useful sanity check on step five is whether the downstream service actually stores and checks the key. Some APIs accept the header and ignore it on certain endpoints. Monday, for example, ignores it on queries because reads are inherently idempotent Monday's idempotency documentation.

There is also a schema-level payoff beyond correctness. Removing a field from every tool definition cuts input tokens on every request in a long conversation, and it removes a category of validation error where the model emits a key in the wrong format or an empty string. Teams that have described the "tool calling tax" point at retries, compensation and support load as the hidden costs Tool Calling Has a Tax You're Not Seeing; removing the model's ability to produce a drifting key addresses the first two directly.

Reusing the pattern for events and webhooks

The same reasoning applies to outgoing events. Standard Webhooks is a set of conventions for webhook producers so that consumers get a secure, consistent interface Standard Webhooks. In that scheme, consumers deduplicate on the event ID, which arrives in the webhook-id header Svix's webhook retry best practices. If your agent triggers an outbound event, that ID should come from the durable step, not from the model, for exactly the same reason.

Generating keys for webhook-driven mutations follows the same rule: derive from the triggering event ID rather than a fresh random value per attempt Monday's idempotency documentation. Reliable delivery and safe retries pull in the same direction: exponential backoff with jitter, a published schedule, and a dead letter queue for exhausted attempts Svix's webhook retry best practices Hookdeck's guide to webhook retry best practices. None of that survives a drifting key: the receiver will happily process ten deliveries it cannot recognize as the same event.

The failure classification also transfers cleanly. Senders distinguish retriable failures such as 5xx and connection timeouts from permanent ones such as 400 or 404, and treat 429 as a case for honoring Retry-After Hookdeck's guide to webhook retry best practices. A webhook provider that exhausts its schedule should move the event to a dead letter queue rather than drop it, which only makes sense if the deduplication identity was stable in the first place Hookdeck's dead letter queue guide. Svix publishes an exact retry schedule as an example of what receivers need to reason about delivery Svix's webhook retry best practices.

If you would rather hand the tool layer, key derivation and dispatcher to someone who has built it before, Quanta can take the requirement and build it with you: https://quantaintelligence.ai/

When the model may legitimately choose the key

There is one narrow case. A customer asks for two identical purchases in two separate intents, and the model's job is to decide that a second intent exists. Even then, the model should decide that an operation is new, not what the key is. The orchestration layer mints the identity.

The distinction matters because identical arguments alone do not establish identical intent, a point that surfaces in the same guidance on caller-generated identity Formation's analysis of agent tool retries. The model is good at intent. Code is good at identity. Keep the split there.

A second legitimate case is classification. If you need the model to say "this looks like a duplicate of the invoice from earlier in the conversation," that is intent recognition, and the answer can feed a dispatcher decision. The dispatcher still owns the key. The model's opinion changes which step gets created, not what the step's identity hash is.

FAQ

FAQ

Why does a model-generated idempotency key drift?

Because the model generates text and sampling picks different tokens on each run, so the same request can produce a different string. Even with a low temperature, nothing guarantees an identical output, and provider-supplied tool call IDs are also not stable across restarts or re-plans.

Can I just tell the model to reuse the key from the previous message?

It can help, but it cannot be relied on. The instruction is prompt text, and a model-requested retry or an orchestrator restart puts the key outside the conversation where the model may not see it. Deriving the key in code removes the dependency entirely.

What should go into the derived key?

A tenant identifier, the operation name, a durable step ID and the sorted arguments, hashed together. Sorting matters because JSON key order is not a stable input, and the durable step ID matters because the same order charged twice for two approved purchases needs two identities.

What if the model changes an argument between attempts?

Treat it as a new decision, not a retry. If the amount changes while the original charge is unresolved, both could be charged, so the dispatcher should block or reconcile rather than silently mint a new key.

Does a downstream idempotency header guarantee no duplicates?

Only if the service actually enforces it and only for the window it caches. Monday caches responses for 30 minutes and ignores the header on reads Monday's idempotency documentation. Beyond that window, or behind a wrapper that reports success without forwarding the key, you get no protection at all.

Sources

Sources

  1. The Ultimate Guide to Agentic Tool Calling - SylphAI Blog - AdaL
  2. Agent Tool Call Retries: Idempotency and Recovery
  3. Idempotent requests | Stripe API Reference
  4. Tool Calling Has a Tax You're Not Seeing | by Duckweave
  5. Idempotency
  6. Standard Webhooks
  7. Webhook Retry Best Practices | Svix Resources
  8. Webhook Retry Best Practices for Sending Webhooks
  9. Dead-Letter Queues for Webhook Reliability
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles