← All posts

Idempotency for Agents: Safe Reruns by Design


Agents retry, resume, and get interrupted. Idempotency makes those reruns safe: same intent, one durable outcome, and an audit trail.

An agent that cannot safely run twice is an incident waiting for a timeout. Models retry. Workers restart. Humans press “run again” after an ambiguous result. If a second run sends a second invoice, creates a second ticket, or deploys a different build, the agent did not merely fail to be elegant — it failed to be operational.

The practical rule is simple: give each real-world intent a durable identity, record its outcome, and make every tool call resolve that identity before it makes a new change. That is idempotency: repeated requests with the same intent produce one durable effect, not a growing pile of duplicates.

Retries are normal; duplicate effects are optional

The agentic loop is built around uncertainty. A tool call can time out after the remote system has already completed it. A process can crash between a successful write and the log line that confirms it. A verifier can fail after an action that was actually correct.

From the agent’s point of view, each of these looks like a reason to retry. From the external system’s point of view, it may be a second instruction to do the same thing.

Consider a simple sequence:

1. Agent requests: create invoice for order 8472
2. Billing API creates invoice inv_91
3. Network response is lost
4. Agent sees a timeout and retries
5. Billing API creates invoice inv_92

Nothing in the model’s reasoning needs to be wrong for this to happen. The failure lives at the boundary between an uncertain agent loop and a stateful external system. Prompting the model to “avoid duplicates” is not a control; it is a hope. The tool contract must make the duplicate impossible or detectable.

Define the unit of intent

An idempotency key represents a business operation, not an API attempt. create_invoice:order_8472 is a plausible key. A random UUID generated anew for every retry is not — it makes every retry look like fresh work.

The key must be stable across the entire retry chain and any later resume. It should also be scoped tightly enough that two legitimate operations do not collide. “Send customer email” is too broad; “send shipping-confirmation email for order 8472” is meaningful.

Before calling a side-effecting tool, the harness creates or retrieves an operation record:

operation = ledger.get_or_create(
    key=f"invoice:create:order:{order_id}",
    payload_hash=hash_payload(order_id, amount, currency),
)

if operation.status == "succeeded":
    return operation.result

if operation.payload_hash != hash_payload(order_id, amount, currency):
    raise IntentConflict("idempotency key reused with different payload")

The payload hash matters. A reused key must not silently turn “invoice order 8472 for $10” into “invoice order 8472 for $100.” That is not an idempotent retry; it is conflicting intent, and it should stop for review.

Make the tool boundary do the work

The safest place to enforce idempotency is the system that owns the side effect: the billing API, deployment service, ticketing system, or database. The agent harness should pass a stable key; the receiving service should atomically store the first result and return it for duplicates.

POST /invoices
Idempotency-Key: invoice:create:order:8472

first request      → 201 Created, {"invoice_id": "inv_91"}
same request again → 200 OK,     {"invoice_id": "inv_91"}
same key, new body → 409 Conflict

This contract is stronger than checking first and writing second in the agent. Two workers can both pass a pre-check before either writes. Atomic deduplication at the owner closes that race.

For tools you control, expose the key explicitly in the schema. Do not ask the model to hide it in a free-form note or reconstruct it from conversation history. The tool design layer should make the safe path the easy path: a typed idempotency_key, a response that includes the prior result, and a conflict error that names the mismatch.

Separate planning from committing

Not every action needs the same control. Reading a document is naturally repeatable. Drafting a reply is usually harmless. Sending the reply, issuing a refund, or changing production configuration is different.

A useful pattern is two phases:

  1. Plan: gather inputs, validate constraints, produce the proposed action.
  2. Commit: execute one named operation with an idempotency key and durable record.

The commit phase is where you apply the permission boundary and the verifier level appropriate to the blast radius. A deployment can use a build artifact digest as part of its intent. A CRM update can use the record ID, target field, and desired value. An email can use the template version plus recipient and business event.

This also makes human escalation cleaner. A reviewer approves an explicit operation — “publish build SHA abc123 to production” — rather than an open-ended continuation of an agent conversation.

Reruns need a ledger, not a memory

An agent’s context window is not a source of truth. It can be truncated, summarized incorrectly, or unavailable when a worker restarts. Store operation state outside the model:

Field Why it exists
Intent key Links retries and resumes to one business operation
Payload digest Detects a changed request under the same key
Status Distinguishes pending, succeeded, failed, and unknown
External reference Points to the invoice, deploy, ticket, or message created
Request and response metadata Supports debugging without relying on model narration
Timestamps and actor Makes recovery and audit possible

The awkward status is unknown. It means the caller did not receive a definitive answer and cannot prove whether the external effect happened. Do not automatically repeat an unknown operation without querying the owner by idempotency key or external reference. If the owner cannot answer, route the operation to a reconciliation workflow. This is slower than blind retrying and vastly cheaper than repairing a duplicate payment or deletion.

Idempotency changes retry policy

The retry policy should branch on whether the operation is read-only, safely idempotent, or non-idempotent with an unresolved outcome.

def retry(operation, error):
    if operation.is_read_only:
        return retry_now()
    if operation.has_idempotency_key:
        return retry_with_same_key()
    if error.outcome_is_unknown:
        return reconcile_before_retry()
    return escalate_to_human()

This is the missing counterpart to diversified retries. When a response fails correctness verification, the next model attempt should change its reasoning context. When a side-effecting call times out, the transport retry should preserve its identity. These are separate retries with separate invariants. Mixing them is how a harmless reasoning retry becomes a second external action.

Common mistakes

Generating the key inside the tool wrapper on every call. This creates a new operation per retry. Generate it at the orchestration boundary and persist it before the first call.

Using a conversation ID as the key. One conversation can legitimately create several tickets or send several messages. The key must identify a single business intent.

Treating “success” as the only terminal state. Timeouts and worker crashes create uncertainty. Model unknown explicitly and reconcile it.

Deduplicating only in the agent. Multiple workers, manual reruns, and other clients can bypass that check. Put enforcement at the owner of the side effect whenever possible.

Making every operation idempotent with the same key. Broad keys suppress legitimate work. A key should be stable for retries and unique for distinct intents.

The recovery drill

Test the property deliberately. Pick one side-effecting tool and simulate each interruption point: before the request, after the remote system commits, after the response returns, and during local persistence. For each case, restart the agent and confirm that it either returns the original result, reconciles the uncertain outcome, or escalates safely.

Log the key, status transition, external reference, and retry decision in the observability record. A trace that says merely “retrying request” cannot prove safety. A trace that says “replayed invoice:create:order:8472; returned inv_91” can.

Takeaway

Agents will be retried by their harnesses, their operators, and the unreliable systems around them. Design for that reality. Give each side effect a durable intent key, enforce it where the side effect is owned, preserve it across retries, and reconcile uncertainty before repeating work. Safe reruns are not a model capability. They are a contract your harness makes with the world.