Your agent needs an unknown state
A timeout is not proof that nothing happened. When an agent turns an uncertain outcome into a fresh tool call, one authorized action can become two.
Suppose an agent is asked to open one support ticket. It calls the ticket API. The service creates the ticket, but the connection drops before the answer comes back. The agent sees a timeout, decides the attempt failed, and tries again.
Now there are two tickets. Both describe the same broken export. Every field is valid. The agent understood the assignment and selected the right tool. Its mistake was deciding that a missing answer meant nothing happened.
A timeout tells you that an answer did not arrive in time. It does not tell you whether the other machine changed the world. The request might never have arrived. It might still be running. It might have finished while the reply disappeared. Those possibilities can look identical from the caller's side.
That is a terrible place to ask a language model to improvise.
I want the agent to keep that outcome marked unknown. It should retain the identity of the action it attempted and have a defined way to resolve the uncertainty. Otherwise, the next round of reasoning can turn recovery into another action.
This is old distributed-systems territory. In End-to-End Arguments in System Design, Saltzer, Reed, and Clark explain why the application may still need duplicate suppression even when lower layers provide it. An application can originate a second attempt that looks like a perfectly new message to the communication system. The application knows the relationship between those messages. The network does not.
An agent can make that second attempt by thinking about the problem again.
HTTP's rules for retries already account for the distinction. An idempotent operation has the same intended effect when repeated. A client should not automatically repeat a non-idempotent request unless it knows the operation is safe to repeat or can establish that the original was never applied. This is why creating another ticket needs more care than fetching the ticket list.
The difficult part is preserving that distinction through the agent's tool interface. A wrapper that turns every timeout into success: false has thrown away information the rest of the system needs. Now the planner has a neat failure to repair, even though the business operation may already be complete.
The second call looks new
Idempotency keys help when the provider supports them. The client assigns an identifier to an operation and reuses it on retries. The provider recognizes the repeat and prevents an additional effect within its documented contract.
But a retry inside an HTTP client and a new call proposed by an agent are different things. The HTTP client might reuse the key correctly. The agent might respond to the same uncertainty by calling create_ticket again, producing a new tool-call identifier and a fresh provider key. Each layer can obey its local rules while the user gets two tickets.
A small runnable example makes the difference visible. The simulated ticket service creates the ticket and then drops its first response. The caller retries. Run it with Python 3.10 or newer and it prints:
No provider deduplication: 2 tickets
Same operation key: 1 ticket
New key after replanning: 2 tickets
This is a deterministic toy example with no LLM, network, or persistent database. Its service assumes an indivisible ticket write and deduplication record. It demonstrates what changing the key does under that assumption. It does not measure how often a model will choose to do it, or establish a production implementation's crash safety.
The third line is the one I care about. The provider honored its contract. The caller bypassed that protection by presenting one logical action as two different operations.
The identity has to belong to the operation the user authorized. A transcript message ID, a new worker, or a restarted run should not quietly create permission to perform it again. Create the operation record before dispatch, bind it to the account, target, and resolved arguments, and keep that record across recovery. If the model proposes another attempt at the same unfinished operation, route it through that record. Give each send a new attempt ID for tracing, while preserving the operation ID and its mapping to the provider key.
Matching the JSON is insufficient. Malcolm Featonby's account of idempotent APIs at Amazon points out that a caller may want two identical EC2 instances. Identical parameters do not necessarily mean a duplicate. AWS uses caller-provided request identifiers and checks for parameter mismatches. For its own database mutation, the server must record the identifier and the effect atomically. Writing the identifier and then hoping the effect follows leaves another failure window. That transaction does not automatically cover a downstream email or webhook consumer.
The integration has to decide what counts as one operation. One ticket for a particular approved support request is a useful identity. Every ticket with the same title forever is not. A hash can detect changed arguments; it cannot decide whether the user intended a second occurrence.
An August preprint on replay-resistant agent actions studies a related failure. In its synthetic agent-generation harness, an equivalent action can be proposed again after an uncertain outcome and receive a fresh single-use token for the same user authorization. The authors' CapLease design and a matched server-side ledger both track the authorization across token issuance. Both provide the tested replay protection under matched assumptions. The guarantees require trusted action identities and durable state. Preventing duplicate external effects also requires the destination to enforce idempotency. The study does not measure the rate of duplicate actions in deployed agents.
Persistence ends somewhere
Existing workflow systems already document this boundary.
Temporal's Activity documentation describes the case directly: a worker completes an Activity and crashes before reporting completion. The history lacks the successful result, so the Activity can run again. Completed Activities recorded in history do not rerun during normal replay. The vulnerable interval is between the external effect and the recorded result. Temporal recommends idempotent Activities and explains that downstream services enforce the keys.
The workflow engine can preserve its own history without participating in the ticket service's database transaction. I argued in the graph-engineering essay for explicit state and recovery between steps. A graph still needs to represent uncertainty inside a step that talks to another system.
There are also time limits. Stripe's idempotency contract returns the saved status and body for repeated requests with the same key, including saved 500 errors. Keys can be pruned after they are at least 24 hours old. Reusing a pruned key creates a new request. An agent that resumes days later cannot assume a stable string still carries the same duplicate-prevention guarantee. It needs to know the provider's scope and retention rules, and reconcile the existing operation when that guarantee no longer applies.
Writing an intent to a local database is useful. It preserves what the agent meant to do, but it does not make a remote write atomic with the local record. You still have to account for the moment when one system has committed and the other has not learned about it.
Make unknown change the next action
An unknown label earns its place only if it changes execution. It should stop the planner from treating the unresolved operation as a fresh opportunity to make progress.
The recovery path depends on the service. A provider-supported key may permit a bounded retry of the same operation. An operation ID may support a status query. If the caller received a resource ID before losing contact, it may identify the ticket to inspect. The missing reply may also have contained the only copy of that ID available to the caller. An asynchronous service may report 202 Accepted while processing remains incomplete. That response does not promise the work will eventually succeed. Accepted and completed deserve different states.
Readback needs its own care. A search returning no matching ticket may reflect a delayed index, another account, or an original request that has not finished. Even a current read can race with a write still in progress. Finding the particular completed operation can resolve uncertainty. Failing to find it does not automatically authorize another create request.
When the provider offers neither safe repetition nor a conclusive way to inspect the outcome, the uncertainty belongs in the product. Tell the user what was attempted and what remains unresolved. For an action where duplicates matter, waiting for investigation can be the correct result. A system that hides this choice in an optimistic retry policy has chosen for the user anyway.
Several effects require several records. Creating a ticket and sending its notification are separate operations. If the notification fails, creating another ticket is a bad recovery strategy. A compensating action may repair a partially completed workflow, but repair has limits. Garcia-Molina and Salem's sagas work treats compensation as an application-defined operation. A correction email cannot make the recipient unread the first one.
The test I want happens immediately after the external system commits. Drop the response. Restart the agent. Let it replan or delegate. Count the resulting objects and inspect the operation IDs, rather than judging only its final explanation. Repeat with a request that never reached the service, with delayed visibility, and with two workers trying to recover the same operation. A design that avoids duplicates by never completing anything has failed a different part of the test.
Measure successful completion, unwanted effects, unresolved outcomes, and time spent reconciling them. Keep the cases where the system admits it cannot determine the answer. That uncertainty belongs in the results, even if it makes the demo less satisfying.
Before I trust an agent to run unattended for hours, I want to see what it does after one missing response. It should be able to say which operation is unresolved and how it will find out what happened. "I'll try again" is only reassuring when it still means the same operation.
- Dr. J