The Scariest Line in an Agent Isn't a Hallucination, It's a Retry
A hallucination is text a human can catch. A retry is an action that already happened, and it can happen twice. Here is the one seam that makes every external write idempotent, and why I chose to refuse a repeat rather than risk a duplicate.
A hallucination is text. A retry is an action.
The line in an autonomous agent that should scare you isn’t the model inventing a number. It’s the plumbing deciding to run the same external write a second time. The model can be perfectly right and the system can still act twice.
A duplicate task in a CRM is annoying. A second outbound phone call to a real person is an incident. Same bug, very different blast radius.
Why a retry is scarier than a hallucination
A hallucination is contained. It is text, a human reads it, and a decent eval catches the pattern before it ships. You can grade it.
A retry is already gone. The second call does not come from the model. It comes from the machinery around it: an automatic retry on a transient network error, a workflow that resumes after a crash, an orchestrator re-running a node it is not sure finished. None of that is the model being wrong. All of it can repeat a side effect that already happened.
Before I fixed this, every external write the agent made had zero replay protection. Run the node twice, make the call twice. I had built careful prompts and careful evals, and left the actual dangerous part, the writes, completely unguarded.
One seam, every external write
The fix is one decorator, wrapped around every write that crosses the tool boundary. Not thirty checks scattered through the code. One.
It works off a key. The key is a stable hash over the run id, the tool name, and the semantic arguments of the call. I compute it in the framework-free core, not inside the vendor SDK, so the same logical action always hashes to the same key no matter which engine is driving it.
The store behind it exposes two operations. begin(key) is an atomic claim: a primary-key insert, where the first caller wins and a duplicate key physically cannot insert twice. complete(key, result) records what happened. Call the tool with a key that already has a recorded result, and the decorator hands that result back without touching the outside world. The write became idempotent without the tool author having to think about it.
The honest window
There is a gap I could not wish away, and pretending otherwise would be the real failure.
A write claims its key. It makes the external call. Then the process dies before it can record the result. Now the key is claimed and there is no result next to it. On resume, I genuinely cannot tell whether the call went out.
So the system refuses. It does not retry the call and hope. It raises replay in progress and stops, because the one thing worse than a missed action is a duplicated one. That is a deliberate choice of at-most-once over at-least-once.
The logic is asymmetric, and the asymmetry is the whole point. A missed outbound call I can recover from: I notice, I call back. A doubled call I cannot: it already rang on someone’s phone. When you cannot tell which happened, and doing it twice costs more than not doing it, you refuse.
Wrapping order is load-bearing
The decorators stack, and the order is not cosmetic. Idempotency goes on the outside, audit on the inside: Idempotent(Recording(adapter)).
That order means a deduplicated replay short-circuits at the very top, before the audit layer ever runs. So the ledger only records calls that actually went out.
Flip it, put recording on the outside, and a replay walks straight into the audit log and writes down a phone call that never happened. Now your record of “everything the system did” contains a thing it did not do. An audit log you cannot trust is worse than no audit log, because you will trust it anyway.
What’s still open
I will not claim the window is fully closed, because it is not.
To seal the last gap, the idempotency key has to travel all the way to the vendor, so their system dedups on it too. Most APIs that can do real damage support this, an idempotency key on the request, but wiring it is per-vendor work. One integration at a time.
For now the core is idempotent, the crash window is narrow, and it fails toward refusing rather than repeating. That is the honest state of it. I would rather ship a real boundary with one named gap than a smooth story with a silent one.
What it comes down to
The scary part of an autonomous agent was never that it might say something wrong. It is that it might do something twice.
You do not fix that with a smarter model. A better model still gets retried by the same plumbing. You fix it lower down: one seam, a stable key, and the discipline to stop and refuse when you cannot tell whether the side effect already happened.
This is the part of the work I get hired for. The place where an agent stops drafting text and starts touching a real account, a real ledger, a real person’s phone. That is where “probably did not do it twice” stops being good enough.