A blocked duplicate is still a billed turn
An agent regenerates each retry, so idempotency keys never match it — and refusing the duplicate action does not refund the model turn that produced it.
A guard that refuses a duplicated action saves the action, not the money. Every attempt it refuses was produced by a model turn that read the whole transcript and wrote a tool call, and that turn is billed whether the call is executed or thrown away. The controls most teams already have — idempotency keys, dedupe caches, repeat-hash counters — are keyed on exact equality, and an agent does not replay a request, it regenerates one. The quantity worth watching is billed model turns per client-visible action, not refused calls.
This is the hard half of a case that a looping agent looks healthy to your monitoring left open. That post lists byte-identical requests as the cheapest runaway to detect, because a hash per request is enough to count them. The repeats below are not byte-identical, and that is the entire problem.
An idempotency key assumes a replay
A Show HN on 2026-11-09 for an open-source send guard names the failure it was built for:
The failure it's for: an agent's send tool times out, the model retries, and each retry changes a word, so idempotency keys never match.
An idempotency key asserts this is the same operation I already asked for. It works in conventional code because the retry is a replay: the request object is built once and the same bytes go out again, so the key travels with them unchanged. An agent's retry is not that. It is a second forward pass over a transcript that now contains the timeout, and the model re-derives the arguments from scratch. One rephrased sentence, one reordered field, one freshly generated key, and every equality test in the path reports a new operation — the HTTP idempotency header, a dedupe cache keyed on an argument hash, and a repeat counter keyed on a request hash alike.
The general form outlives this particular tool: a control that recognises repetition by equality cannot recognise repetition produced by a non-deterministic generator. That covers most of what is already deployed, because all of it was designed against retry loops written in application code.
A tool can return 200 and make no progress
The retry needs a trigger, and the trigger is often not an error. A comment on a Launch HN thread on 2026-11-11 for a site-to-MCP service draws the distinction:
One distinction worth measuring is transport success versus actual operation success. A tool can return HTTP 200 while making no useful progress. The agent may then repeat the same call with identical arguments.
A timeout on a write that actually landed, a 200 carrying a body that says nothing happened, a tool that returns a job identifier and no result — each reads as try again to the model and as fine to every layer underneath it, so the error-rate panel stays flat while the loop runs.
These two reports describe opposite shapes: one has the arguments changing on every attempt, the other has them identical. Both occur, and the identical case is the catchable one. The same comment is explicit about what its own approach does not reach:
It can flag repeated tool calls using privacy-preserving fingerprints, but it doesn't block live retries or determine whether caching is safe.
Flagging after the fact and blocking in the path are different products, and the gap between them is paid in tokens.
The refusals are the cheap half
The send guard's write-up gives a ratio, from its own demo rather than from a production incident — the author states that the demo raises the threshold so the behaviour is visible, and that the default refuses the fourth attempt (2026-11-09):
In the demo, 400 attempts to send one customer the same receipt: 5 go out, 395 are refused.
Take the shape rather than the number. Four hundred attempts is four hundred model turns, because the guard sits below the model: it inspects a tool call that already exists, which means the tokens that produced it are already bought. The author's own framing is the correct one — "It's blast-radius control, not a filter." It bounds what reaches the customer. It does not bound what reaches the invoice.
Three things follow from where the guard sits.
The attempts get more expensive as they go. Each refusal returns a tool result, and that result joins the transcript. Attempt k is therefore billed on a prompt containing attempts 1 through k−1 and every refusal they drew, so input per attempt rises monotonically and the total over N attempts grows faster than N times the first one. This is tool-result bloat with a generator attached.
Prompt caching dampens the input half and hides the climb. The growth is appended at the end of the transcript, so the prefix stays stable and most of each prompt can be served as a cache read. Cache reads are cheaper than base input, not free, and output tokens are never cached at all. The per-call figure stays flat or falls while the run total climbs — which is the exact shape a cost review reads as healthy.
The refusal counter looks like a saving and is not one. "395 refused" is a safety number. It describes side effects prevented, and side effects were not the expensive part. Reported next to a dollar figure it will be read as the reason that figure is low.
Pre-side-effect and pre-token are not the same control
Both a send guard and a budget check block something before it happens, and on a diagram they look alike. They sit on opposite sides of the model call. A guard at the tool boundary runs after the turn that produced the call, so it can refuse the email and not the tokens. A check at the provider boundary runs before the request leaves the process, which is the only position from which spend can be refused rather than reported — pre-call enforcement. A system can need both; having one is not having the other.
What to record
- Billed model turns per client-visible action. Near one on a healthy run. When a loop is stuck this is the whole signal, and no other number you hold expresses it.
- Attempts per tool call site, counted by intent rather than by equality. A counter keyed on an argument hash reads four hundred distinct calls. A counter keyed on the call site, with no advance in task state between them, reads one stuck step. Only your application can define the second one.
- Input tokens against turn index inside one run. A retry storm's signature is a monotonic input curve underneath an artefact that is not changing, and an average over the run deletes exactly that shape.
- Refusals as their own line, separate from failures. A guard's refusal is a success for the guard and a cost for the run; one counter cannot carry both meanings.
- Tool results as a share of input tokens. Every refusal is re-sent on every remaining turn, so a short message paid once is charged for the rest of the run.
How this works in Capsera, and what it does not do
capsera.init() patches the provider clients — Anthropic messages, OpenAI chat
completions, OpenAI embeddings and Google Gemini, sync and async — so every
attempt is captured at the provider client, whichever tool wrapper or framework
produced it. That is the half a send guard cannot see: it is holding the
outbound action, and the spend happened one layer above it.
@capsera.agent(name="sender") says whose loop it is, and because the agent
stack is a contextvars ContextVar rather than thread-local state, a nested call
inherits that identity without anything being threaded through. Budgets are
checked before the provider call and are on by default, scoped per agent, per
team or globally; a blocking budget raises BudgetExceededError and no tokens
are spent. That is a ceiling on the agent, not on the run — there is no per-run
cost ceiling — and enforcement fails open, so a backend or network error lets the
call through.
The limitation here is the one this post is about, and it applies to us. Capsera stores hashes and token counts, never prompt or completion content, and the dashboard's prompt analysis surfaces repeated prompts by comparing hashes. A retry that changes a word is a different hash, so it will not be reported as a repeat — the blind spot described above is in our detection too. What survives the content boundary is the aggregate shape: billed calls climbing inside one agent, input tokens per call rising within a short window, a burn rate that steps. Enough to catch the storm, not enough to prove two attempts were one intent.
If proving that is what you need — reading the arguments the model actually produced on two far-apart attempts to show they were the same request — Langfuse is open source, free, and built to store and inspect exactly that content.
Give every agent an identity, a budget, and hard limits.
One line of code. Anthropic, OpenAI, and Google Gemini.
See pricing