AI agent retries need one durable action identity
The model can ask again after a timeout. Persist the business operation before dispatch, bind its input and keep uncertain outcomes blocked until evidence resolves them.
Quick answer
An AI agent tool needs a durable identity for each approved business operation, created before the first attempt and reused across retries. Atomically claim that identity, bind it to the tool and input, and replay confirmed results. If a remote action may have committed, record an unknown outcome and reconcile it before authorizing another execution.
On this page
An AI agent retry should reuse the identity of the approved business operation, even when the model makes another tool call or a worker restarts. Persist that identity before dispatch, bind it to the tool and validated input, and return a saved result when completion is known. A timeout after dispatch leaves an uncertain outcome; it does not authorize another write. AWS’s idempotency design similarly uses caller-provided request identity to express intent. [1]
Scenario: an agent creates a report job for a customer. The destination accepts the request and records the job, but the response connection fails. The agent sees a tool error and asks to create the job again. The application now needs a durable answer to a specific question: is this another attempt at the report already approved, or a request for another report?
Which identity should survive an agent retry?#
The identity should belong to the business operation that the application has approved. Store it in durable workflow state before the first outbound request. Reuse it when the same operation is resumed, retried or represented by another model call. Generate a new operation only when the user or application intends a new effect, not merely because an attempt failed.
A protocol tool-call identifier associates a tool response with a model call. It should not be your only business identity. A later model turn can propose the same action with a different call identifier, and a restarted worker can lose an in-memory mapping. Conversely, two calls with identical arguments can be legitimate separate requests. AWS explicitly discusses why inferring identity from a parameter hash alone can confuse duplicate requests with intentionally identical resources. [1]
Keep the model-facing schema narrow. Anthropic’s documentation describes client tools through a top-level tools request parameter and a schema for their inputs. That describes how the model requests an action; it does not replace your application’s authorization or durable execution state. Attach the approved operation identity in trusted application code rather than asking the model to invent or preserve it. [3]
Scenario: approved-job-42 identifies one approved report request. The same tenant resuming that request keeps that operation identity. A deliberate second report with identical options receives another identity. The ledger key includes the tenant, while a fingerprint binds the selected tool and validated arguments. Reusing the identity with changed arguments is an error that must be resolved, rather than silently interpreted as a new attempt.
| Candidate identity | Failure mode | Use instead |
|---|---|---|
| New random value per attempt | Every retry looks like new work | Persist a value once with the approved operation |
| Hash of tool arguments alone | Intentionally identical actions collapse together | Business identity plus an input fingerprint |
| Model protocol call identifier alone | Another turn or worker recovery can represent the same action differently | Durable operation identity outside the conversation |
| Tenant and approved operation | Defines the local deduplication scope | Also authorize access and bind tool/input |
Claim the action before contacting the destination#
Claim the identity in one database transaction before dispatch. Competing workers must see the retained claim instead of independently deciding that they are first. The small ledger below uses a primary key across tenant and operation, and an explicit SQLite write transaction around reading or creating the claim. SQLite allows a write transaction to read and write; BEGIN IMMEDIATE acquires a write transaction rather than waiting to upgrade after a read. [5]
The sample stores only unresolved or confirmed-success states. pending means an owner claimed execution but a confirmed result has not been recorded. unknown means dispatch may have produced an effect and the result needs investigation. Neither state is permission for another caller to execute. A crashed owner can leave pending behind, so recovery must treat stale pending records conservatively too.
import hashlib
import json
import sqlite3
def encode(value):
# Restricted, already-validated JSON inputs; reject NaN and infinity.
return json.dumps(value, sort_keys=True, separators=(',', ':'), allow_nan=False)
class Ledger:
def __init__(self, path):
self.db = sqlite3.connect(path, isolation_level=None, timeout=5)
self.db.execute('''CREATE TABLE IF NOT EXISTS action (
tenant TEXT NOT NULL, operation TEXT NOT NULL,
fingerprint TEXT NOT NULL,
state TEXT NOT NULL CHECK(state IN ('pending','unknown','succeeded')),
result TEXT,
PRIMARY KEY (tenant, operation)
)''')
def claim(self, tenant, operation, tool, arguments):
# The caller must authenticate and authorize before calling this method.
fingerprint = hashlib.sha256(encode([tool, arguments]).encode()).hexdigest()
self.db.execute('BEGIN IMMEDIATE')
try:
row = self.db.execute(
'SELECT fingerprint,state,result FROM action WHERE tenant=? AND operation=?',
(tenant, operation),
).fetchone()
if row:
if row[0] != fingerprint:
raise ValueError('operation identity reused with changed input')
answer = (False, row[1], json.loads(row[2]) if row[2] else None)
else:
self.db.execute('INSERT INTO action VALUES(?,?,?,?,NULL)',
(tenant, operation, fingerprint, 'pending'))
answer = (True, 'pending', None)
self.db.execute('COMMIT')
return answer
except BaseException:
self.db.execute('ROLLBACK')
raise
def mark_unknown(self, tenant, operation):
self.db.execute('''UPDATE action SET state='unknown'
WHERE tenant=? AND operation=? AND state='pending' ''',
(tenant, operation))
def complete(self, tenant, operation, verified_result):
# Trusted execution/reconciliation code only; never model-controlled.
changed = self.db.execute('''UPDATE action SET state='succeeded',result=?
WHERE tenant=? AND operation=? AND state IN ('pending','unknown')''',
(encode(verified_result), tenant, operation)).rowcount
if changed != 1:
raise ValueError('no unresolved action to complete')
def close(self):
self.db.close()The inputs to claim() must already be authenticated, authorized and validated. The tenant cannot be accepted from an arbitrary model argument, and knowing an operation identifier must not grant access to its result. The sample intentionally leaves those service responsibilities outside the ledger so its local execution rule remains visible. In a real tool gateway, authorization belongs before both new execution and replay of a stored response.
The fingerprint is a check against accidental identity reuse, not a source of authority. This example serializes a restricted JSON input deterministically by key order and rejects nonfinite numbers. Validate types and normalize business fields before computing it; do not assume this is a universal cross-language canonicalization format. Include the tool name so a retained identity cannot drift into a different action.
The explicit transaction is deliberate. Python’s sqlite3 connection context manager does not implicitly open a transaction or close a connection. This sample uses explicit SQL transaction boundaries on a connection configured for that style, rolls back claim errors, and closes the connection itself. Catch lock-contention errors in your application policy instead of treating them as permission to bypass the claim. [4][5]
What does the ledger do when a response disappears?#
It preserves the unresolved identity and refuses to make a fresh claim. That prevents another local caller from repeating the action automatically, but it does not tell you what happened remotely. Local ownership and remote completion are different facts. A database commit can make the claim durable while the network call still sits outside that database transaction.
Scenario: the first attempt creates a report job in a fake destination. The fixture then discards the response, marks the local operation unknown and recreates the ledger object from its database file. A repeated claim returns unknown and the application does not dispatch again. The fake destination still contains the original job. This tests a recovery decision, rather than a model’s likelihood of making another call.
from ledger import Ledger
# Disposable local fixture; never use this dictionary as a real service.
ledger = Ledger("actions.sqlite")
tenant, operation = "tenant-a", "approved-job-42"
arguments = {"queue": "reports", "document": "doc-a"}
fake_remote = {}
fresh, state, result = ledger.claim(
tenant, operation, "create_job", arguments
)
assert fresh
fake_remote[(tenant, operation)] = {"job": "job-receipt-a"}
# Simulate a response lost after the destination committed.
ledger.mark_unknown(tenant, operation)
ledger.close()
ledger = Ledger("actions.sqlite")
fresh, state, result = ledger.claim(
tenant, operation, "create_job", arguments
)
assert (fresh, state, result) == (False, "unknown", None)
# No second dispatch here. A trusted lookup supplies evidence instead.
receipt = fake_remote[(tenant, operation)]
ledger.complete(tenant, operation, receipt)
assert ledger.claim(tenant, operation, "create_job", arguments) == (
False, "succeeded", receipt
)
ledger.close()Run that example against a fresh disposable database. Its fake destination supports an exact lookup by the same tenant and operation identity. Many real destinations do not provide that lookup, so the example does not establish that your existing integration can reconcile safely. The question to answer before deployment is which authoritative receipt or record can connect the destination’s effect to this operation.
The local fixture checks restart recovery, changed arguments and tool names, tenant scope, rejection of nonfinite JSON input, stale pending behavior and competing database connections. The concurrency check lets separate connections claim the same identity and asserts that only one reports a fresh claim. These are tests of the sample’s local rules with a fake destination. No model API, payment system, email delivery or production failure rate was tested.
How do you reconcile without replaying the action?#
Query authoritative destination state using the operation identity or a destination receipt that is uniquely tied to it. If you confirm the intended effect and its parameters, store that verified result in the original ledger row. Then replay the stored success to later callers. Do not delete the row simply because a timeout or lease duration has elapsed.
The destination can commit its job before the worker saves the receipt. A crash in that gap leaves the ledger without proof of completion. SQLite’s atomic commit means all database changes inside a transaction occur or none occur; it does not make an outbound HTTP request part of that transaction. Holding a database lock open across a network request cannot turn the two systems into a shared atomic commit. [6]
Absence can be harder to establish than presence. A destination list endpoint may be eventually consistent, a record may be visible only in another account, or a lookup may be too broad to distinguish this action from another legitimate action. A negative search result alone is not always sufficient evidence to replay. Define how long the destination can take to expose a committed operation and which query is authoritative.
If the destination has no reliable idempotency contract or authoritative lookup, preserving unknown may be the only honest automated outcome. A trusted operator then needs a decision record: the evidence reviewed, the original operation identity, whether the effect was confirmed, and the authorization for any replacement execution. Do not let the model clear the ledger by describing the timeout as a failure.
A local transactional outbox is useful when recording a business change and an intent to send work must happen together. It does not, by itself, make an external non-idempotent endpoint safe to call repeatedly. The outbox checklist covers that local boundary; this ledger covers recognizing a business action and preserving uncertainty at the destination boundary.
What does the destination promise for a reused key?#
Read the contract for the exact endpoint and API version. Some destinations save a response for a key, some continue incomplete work, and others do not implement an idempotency key at all. Sending a header to an endpoint that ignores it does not provide duplicate protection. Your local ledger and the destination’s policy solve related problems at different boundaries.
Stripe’s current documentation separates its API families. For API v1, it saves the initial status and body after endpoint execution begins, and reused keys return that saved result, including server-error responses. For API v2, the documentation says retries do not repeat successful operations and may continue failed or partially failed work, returning an updated response. Treating all Stripe retries as a permanently identical cached response would erase that distinction. [2]
The destination contract also needs a retention decision: how long does it recognize an identity, what parameters must match, which account scopes it, and what happens after expiry? Keep that horizon separate from the lifetime of the local operation record. A local retry arriving after the destination forgets its key can create an effect unless your recovery policy prevents it. AWS’s discussion of late requests makes the same retention problem explicit. [1][2]
- Record the endpoint and API version that recognizes the operation key.
- Confirm whether an error response is saved, retryable or uncertain after execution begins.
- Bind the outbound key to the persisted approved operation, and retain that binding through restarts.
- Keep the original identity and evidence during reconciliation; do not regenerate keys to escape an unresolved state.
How should the tool report uncertainty to the model?#
Return a distinct unresolved outcome with the operation identity and a safe next step. Avoid a generic error message that invites the agent to repeat the write. The application should enforce that rule regardless of the wording: a model instruction is useful context, but the ledger and authorization layer decide whether another execution is allowed.
{
"status": "unknown",
"operation": "approved-job-42",
"message": "The destination may have created the report job. Do not create another job.",
"next_action": "check_operation_status"
}Expose a read-only status tool only to callers allowed to see that operation. For confirmed success, return a stable receipt and enough business detail for the agent to continue the workflow. For unresolved work, route to reconciliation. For a rejected input, return the validation problem without implying that an outbound action occurred. Those outcomes let the agent plan while keeping execution authority in application code.
Track unresolved age, duplicate claims, payload mismatches, reconciliation decisions and destination receipts. Protect stored inputs and results according to their sensitivity, and define retention without silently freeing active identities. The tool-permissions guide explains the separate question of who may authorize an action. Idempotency controls repetition; it does not make an unauthorized action acceptable.
Test the recovery path by losing a response after the fake destination commits, then reopening the database. The retained operation must block another dispatch until trusted evidence resolves it. That is narrower than an exactly-once promise, but it is a rule an engineering team can demonstrate and maintain.
- Can the model generate the idempotency key?
- Keep the authoritative operation identity in application state. A model may propose arguments, but a fresh model call should not create a new business operation implicitly. Generate or assign the identity once for an approved intent, persist it, and attach it in trusted tool execution code.
- Is a hash of tool arguments enough?
- No. Identical arguments can represent separate intended actions, while a retry may serialize equivalent input differently. Use a durable business operation identity and bind a validated, normalized input fingerprint to it. Reject changed input under the same identity rather than guessing which intent the caller meant. [1]
- Does the SQLite ledger guarantee exactly-once execution?
- No. It atomically claims local ownership but does not atomically commit a remote action with the local result. A crash after remote commit leaves uncertainty. Use the destination’s verified idempotency contract or authoritative reconciliation, and keep unresolved actions blocked when neither is available. [6]
- Should an unknown operation expire automatically?
- Expiry alone does not prove that the destination did nothing. Keep stale pending and unknown operations blocked until evidence resolves them. Define retention and recovery explicitly, including the destination’s own key horizon, and require a trusted decision before any replacement execution.
- Can the ledger replace tool authorization?
- No. Authenticate the caller, authorize the requested operation and authorize access to replayed results. A tenant-scoped key prevents local identity collisions; it does not prove that someone may act for that tenant or inspect its receipts. Never accept that authority from a model-generated field.