Picture a support agent that refunds a customer's order. Forty minutes and a lot of tokens later, it refunds the same order again. Nothing crashed and nothing timed out. The agent didn't know it had already done it.
Repeated actions have more than one cause, and the fix for each is different. Most of them are yours to fix whatever you run the agent on. This post goes through all of them, then the one underneath most of them, then the small part an engine can do.
Every cause, and what fixes it
1. Nothing tells the loop to stop. An agent loop runs think, act, observe, and goes round again. If the only way out is the model deciding it is finished, an ambiguous goal leaves it assuming one more step is needed. A practitioner write-up, this one on loops that never end, calls the stop condition implicit and recommends a hard step cap that ends the loop whatever the model decides. It is a blog post, not a study, but the fix costs almost nothing. Add an explicit completion check as well. The guide on an agent that calls the same tool twice covers it.
2. The tool's answer was unclear. A partial result, an odd schema or a silent error leaves the model unable to tell whether the call worked. So it calls again. Return a result the model can read as done or failed, in plain words, and say why when it failed.
3. A retry after a timeout. The first call succeeded and its answer never came back, so the caller sent it again. This one is not about the model at all. The guide on a tool that runs twice after a resume covers it, and Stripe's page on idempotent requests describes the standard fix: a key the server uses to recognize a retry.
4. The same request arrives twice. A webhook can deliver the same event twice: Stripe's webhook documentation says an endpoint might occasionally receive the same event more than once. The agent does the work twice because it was asked twice. Deduplicate at the door, before the request reaches the agent. The guide on stopping an agent from running twice covers the case where two workers pick up one run.
5. The record was thrown away. When old messages and tool results are dropped or summarized, the evidence that an action ran can go with them. Tian Pan's post on compaction traps describes the pattern: the summary keeps the goal and loses the completion records, so the agent re-reads files it already processed and re-applies changes it already made. The same post lists double-written records and duplicate notifications among the results.
6. The record is there and the model doesn't use it. In a long context the model may not use the record even when it is there. Lost in the Middle found that accuracy drops when the relevant information sits in the middle of a long input. Chroma's context rot report tested 18 models and found that performance degraded as input length increased across its experiments.
You can tell the last two apart by checking what the model was shown at the moment it repeated. If the evidence that the action ran was in its prompt and it did it anyway, you have a reasoning problem, and the fix is a check outside the model. If the evidence never reached the model, no change to the prompt wording will fix it.
What stops it, whatever you run on
Put the check in the tool. Give each call that changes something a business key, such as refund:O1039, record the key when the call is claimed, and refuse a second call with the same key. Return a refusal the model can read ("refund already issued for this order") instead of throwing an error, so it can tell the customer instead of retrying.
One gap remains. If the process dies between the claim and the refund, the key says done when it may not be. Record that case as outcome unknown and check it against the payment provider, because the tool can't know. The guide on making agent tools safe to retry explains idempotency keys, with code, including how to pass one to a provider that accepts it.
A guard like this is easy to write yourself, and you should. It stops a repeat from being applied. It does nothing about the model trying again and again, which still costs tokens and can still reach a side effect you forgot to guard.
The cause underneath
Causes 1 to 4 are about the loop and the plumbing. Causes 5 and 6 share a root with a lot of other long-run failures: the only record of what the agent did lives in the prompt, and the prompt is something a model reads, rewrites and loses. The model is asked to remember what it did and to decide what to do next from that memory. In a plain loop, nothing outside the model checks the record before an action runs.
A longer window may delay the failure, and a better summary prompt may shrink it. Neither removes it while the record stays in the model's hands. The record has to be kept somewhere the model can't edit, and the guard has to read from there.
What an engine can fix
That is the part we build. On the hosted engine a tool can declare once_per, a template such as refund:{order_id}, and the engine refuses a second call whose filled-in key matches. Separately, the engine can list in the prompt what has already been done. It does not decide when your agent is done or fix an unclear tool answer. A retry you send is still a retry unless you send an Idempotency-Key, which the hosted engine uses to return the first answer to a repeated wake.
What we measured
In October, a 12-hour shop run on an older build, with no key on the refund tool, paid 7 orders more than once. The known issues page lists each order.
We reran the shift in November, twice, on Claude Haiku 5.5 (engine release 0.51.0, hosted). The refund tool declared once_per: "refund:{order_id}" in both runs. One run had the engine keeping state and one had the older setup.
- Refunds applied twice: 0 in both runs.
- Refunds that reached the tool: 17 with the state on, 16 with it off, each for a different order, and none for an order that was already paid.
The key was on in both runs, so this test can't separate what the key did from what the engine-kept state did. A call the engine refuses is never handed back to the harness, so it doesn't appear in this log either. The refund log and the graded rows are downloadable.
What we haven't shown
- One run per setup, on a scenario we generated, with one model.
- One guarded tool. The mechanism takes a key on any tool, but we have measured it on refunds only.
- An action running exactly once. We don't claim that. A key on a business identifier only helps when the thing being repeated has one, and many agent actions don't.
- Anything about another SDK. We haven't published a run of an agent built on another SDK through this scenario.
Sources and method: 11 hours 59 minutes on shift: what the agent remembered.