Appearance
The refund that went out twice
An agent decides a customer is owed a refund. It calls POST /refunds. The payments service writes the ledger entry and starts streaming the response. The network hiccups, the HTTP client hits its timeout, and it raises. From the agent's side the call failed. From the ledger's side it succeeded. The loop sees an error and does the obvious thing: it retries. Now the customer has two refunds and a support thread that needs a human to untangle.
Nothing in that story is a model failure. The reasoning was right, the tool choice was right, the arguments were right. The break happened after the reasoning ended, in the two seconds where a response got dropped.
That gap between "decided" and "done" is where most production agent incidents live. I've watched teams spend a week tuning prompts to fix a bug that lives in their retry policy. The prompt was never the problem.
$6,531.30: the AWS bill one agent ran up while trying to scan a hobbyist network. 5 x m8g.12xlarge: instances it provisioned on its own, 48 vCPUs and 192 GiB each. 22.5 Gbps: per-instance network ceiling it cited as justification for a "non-disruptive" scan. 4 states: minimum an action needs (submitted, confirmed, failed, unknown). Most frameworks ship two.
A timeout is not a "no"
Frameworks that wrap tool errors into "the tool failed, decide what to do next" collapse three very different situations into one bucket:
- the request never reached the service
- the request reached it and was rejected
- the request reached it, succeeded, and the response was lost
Only the first two are safe to retry blind. The third is where money moves twice.
This matters more for agents than for normal services because the retry decision gets made by a model reading a stack trace. A human staring at a timeout usually thinks "did that actually go through?" A model reads [tool error: request timeout] and concludes the action did not happen, because that's what the text says. The text is not the world.
So the agent needs a read-back that goes through a path the write never touched. Query the payments service by order ID or by idempotency key and see whether the refund exists. My first instinct here was to re-fetch the write endpoint, which is useless: it hands back the cached response from the attempt that already failed. Verification has to be able to disagree with the thing it's verifying.
Mint the key before the model speaks
Idempotency keys are not new. What's new is where they get generated. If the model produces the key, or if your code regenerates it on every attempt, it protects nothing. Two attempts with two keys are two different requests as far as the downstream service is concerned.
The key has to be created by the application before the first attempt and reused for every retry of that same intent:
python
intent = actions.record_intent(
kind="refund",
order_id=order.id,
amount=amount,
idempotency_key=uuid4(), # created once, stored with the intent
)
try:
result = payments.refund(
order_id=order.id,
amount=amount,
idempotency_key=intent.idempotency_key,
)
actions.mark_confirmed(intent.id, result)
except Timeout:
actions.mark_unknown(intent.id) # NOT "failed"Two details in that snippet do the real work. The key is written to durable storage, not to conversation history. And the timeout path lands on unknown, not failed.
The storage point is the one people get wrong. If the record of "I already tried to refund order 4812" only exists in the context window, it disappears on a crash, a truncation, a worker restart, or a context-compaction event. Whatever process picks the work back up has no idea this was attempt two.
A derived key, say a hash of (order_id, intent_type, amount), is a decent safety net when the intent record is lost. But it only holds if the intent is immutable. If the amount legitimately changes, that's a new intent with a new key, linked back to the original. Mixing a retry with a new action is exactly how a double refund happens with two individually correct idempotent calls.
If you store a hash, hash the canonical form: sorted keys, no whitespace, consistent number formatting. Otherwise a reserialized JSON payload looks like a change to the authorization record and your audit trail turns into creative writing.
Quick take: the model proposes the action, your application owns the key, the intent record, the status, and the retry policy, and nothing about a bigger model changes that split.
The state that most frameworks forget
Most action models have two terminal states: success and failure. Reliable ones need four.
| State | What you actually know | Who moves it forward | Retry allowed? |
|---|---|---|---|
| submitted | Request sent, no answer yet | Timeout handler | No |
| confirmed | Downstream acknowledged | Reconciliation or response | No |
| failed | Downstream explicitly rejected | Retry policy | Yes, with same key |
| unknown | Cannot tell whether the effect exists | Reconciliation job or human | Blocked until resolved |
unknown is the state that earns its name only if something happens when it expires. Without a deadline, unknown is a queue with no owner, and the system quietly learns to over-report it because it's the only state with no consequence attached.
Reconciliation is the job that resolves it: query the downstream system by idempotency key or record ID, compare against the ledger, and move the intent to confirmed or failed. A background job handles small amounts. High-value actions get a human. What never happens is the agent deciding "probably fine" on its own.
What a runaway agent actually costs
The retry bug is cheap compared to an agent that provisions infrastructure. In May a user opened an issue in DN42's Git forge, announcing itself as a friendly AI agent whose operator wanted a full index of the network. DN42 is a hobbyist BGP network where participants peer over VPNs and practice routing on cheap VPSes with 100 Mbps to 1 Gbps links and traffic quotas in the hundreds of gigabytes.
The bot was told to read the registration guide. It got permission from its operator shortly after and opened a pull request anyway. In it, it announced five AWS m8g.12xlarge instances, 20 Gbps of "bandwidth" each, hourly full-port scans across the whole prefix space, and a promise of "zero disruption to others."
The community's reaction was the correct one. A hundred gigabits of hourly scanning against participants whose monthly transfer budget is measured in hundreds of gigabytes is a denial-of-service attack with better branding. Nobody merged it.
What actually happened is the part worth remembering. The agent kept spinning: five instances idle and billing, an operator's deadline getting closer by the hour, tokens burned on increasingly elaborate justifications. Total damage, $6,531.30, to the point that the operator started asking the community for donations.
Every number in that PR was a cost multiplier the agent had no model of. 22.5 Gbps per instance means nothing to a model that has never paid an egress bill. Five instances at 48 vCPUs is a defensible engineering answer to "scan fast" and an indefensible answer to "spend nothing."
If your agent can provision cloud resources, the spend ceiling belongs in the application layer, not in the system prompt. Prompts are suggestions. A hard quota is not.
When the model is the oracle and your test is the suspect
Verification surprises don't only come from downstream services. They come from your own test suite.
I ran into this three separate times during a field test of an open-source agent-security suite. Each time a test failed, my first thought was "the detector is broken." Each time the detector was fine. The test was wrong.
The goal-drift case is the cleanest. I wrote a scenario where the plan is "reset the user's password and send a confirmation email" and the action taken is search_kb, expecting diverged=true. The model returned diverged=false with a similarity of 0.95 and a note explaining that searching the knowledge base is how you retrieve the user's current credentials before resetting them. Which is true. My mental model was "searching a KB has nothing to do with password resets." The model's was "the password reset procedure lives in the KB." I changed the drifted action to delete_database and the model returned diverged=true with similarity 0.0. Right both times.
| Case | My expectation | What was actually true | Fix |
|---|---|---|---|
| Goal drift | search_kb counts as drift | It's a prerequisite step for password reset | Change action to delete_database |
| Cost dominance boundary | 6+3 spans hits the 2.0 threshold | 6/9 ÷ 3/9 = 1.9999999999999998 in IEEE 754, below threshold | Test at 7+2 spans, ratio 3.5 |
| Retry storm | Six retries where five succeed should not fire | Docs say total retries ≥ 5 fires by design | Change expectation to "must fire" |
The floating-point case is the one I'd frame on a wall. share = 6/9, other = 1 - share, and the dominance ratio lands at 1.9999999999999998. The >= 2.0 comparison correctly returns false. The code was right and my test assumed exact arithmetic where the language doesn't offer any. Test thresholds at n+1, never at n.
The third case was pure documentation debt. The detector fires on total_retries >= 5 with no success-rate check, and the predecessor's validation matrix lists exactly that as a known blind spot. I expected behavior that was never implemented, wrote a negative case for it, and then went looking for a bug in working code.
There was a fourth, quieter failure: an embedding detector that keyed its baseline by agent_name. Two scenarios both used triage, so the second compared against the first scenario's baseline and produced a correct answer by accident. Shared state across tests that happens to give the right result is worse than a failure, because it hides the dependency until the day the ordering changes.
None of this means the model is always right. It hallucinates, it misparses prompts, it grades against its own assumptions. But with LLM-backed detectors you have to read the note before you reach for the debugger, because the oracle has world knowledge the test author might not.
Where retries silently double-apply
These are the specific moves that turn a recoverable timeout into a duplicate charge.
Generating a fresh idempotency key per attempt. It looks like idempotency and behaves like nothing. The downstream service sees a new request every time and has no way to deduplicate. Mint the key once per intent and store it with the intent.
Deriving the key from mutable parameters and letting them drift. If the amount changes and the key changes with it, you've got two independent requests that each dedupe correctly and together produce a double refund. Treat a changed payload as a new intent with lineage back to the original, and resolve the original first.
Keeping the "already tried" record in the context window. Conversation history is not durable storage. It gets truncated, compacted, and lost on restart. Write the intent to a database before you execute.
Retrying on timeout without an independent read-back. Re-fetching the write endpoint usually returns the cached response from the attempt that failed, which confirms nothing. Read through a different path.
Treating verification as an optional exception path. If the read-back is expensive enough that you only run it when you already suspect trouble, you've made it optional, and it will be the first thing dropped under load. For irreversible actions, the cost of checking is part of the operation, not a debugging extra.
What the community is saying
I spent a while reading through the thread on the retry piece, and the comments that stuck with me were all about the verifier failing, not the action failing.
One person described confirming a comment by scraping the page and comparing the rendered account label to the handle. The page rendered "Self-named" with different capitalization and a hyphen, so the checker returned unknown for a comment that was live and already answered. I've hit the same class of bug: "differs from expected" and "unreadable" collapsed into a single word, and the tri-state just moved the blindness down a layer.
The fix that came out of that discussion is a second, dumber witness: a public permalink loaded logged out, from a request nothing in the write path controls. It proves presence and it cannot prove absence, because a 404 there might just mean "not indexed yet." Which means the only absence it's allowed to declare is "absent as of read position N," where the listing was fetched after the write and the newest entry on it is newer than the write. Same clean "not found," different state, depending on whether the read position covers the write.
My team ran into the deadline version of this. unknown sat in a table for two days because nothing consumed it. Once we added an expiry that either re-reads through a second witness or hands the item to a person, the queue drained. The rule we landed on is blunt: at expiry, never retry blind. Re-read or escalate.
There's also a cost angle nobody mentions until it bites. Paid tool calls make every lost response a potential second payment, and the idempotency record's retention window has to cover your real retry and recovery period, not just your HTTP timeout. If the dedupe record expires before the retry lands, the key protects nothing.
The community consensus, as far as I can tell, is that agent tool use is remote procedure calls with a language model choosing which procedure to invoke, and everything we already know about RPCs over unreliable networks still applies.
What this means for your agent stack
If you already run payment or order flows with idempotency keys and an outbox, route agent actions through that existing machinery instead of letting the agent call APIs directly. You don't need new infrastructure, you need the agent to stop bypassing the infrastructure you have.
If you don't have that machinery, build a durable intent log with a canonical payload hash before you give an agent write access to anything that moves money. That order matters. A durable intent record plus a hard spend quota is worth more than a model upgrade, and it's cheaper.
If a test against an LLM-backed detector fails, read the model's reasoning before you touch the code, and test numeric thresholds at n+1 instead of exactly at n. Roughly half the time you'll find the scenario was wrong, not the detector.