I paid for 111 agents nobody had asked for.
I left a coding agent working on its own for a week. The budget was gone in three days — one of them cost about €2,600, the next about €2,000. That part is my own fault and not very interesting. The interesting part is what I found when I sat down and counted who had actually asked for each piece of work: a quarter of the spend had been requested by nobody at all.
Modern coding agents can open copies of themselves. You give one a job too big for a single pass, it decides on a split, and it launches a dozen or thirty helpers that each take a piece. It is genuinely effective. It is also the most expensive thing the tool can do, because every helper reads the shared context from the beginning before it contributes anything.
I use that heavily, so I expected a large bill. What I did not expect was to be unable to account for it.
Eight days, in euros
Those figures are reconstructed from my own session logs at public list prices — they are an estimate of what the work would cost at retail, not a copy of an invoice. Everything else in this post is a direct count from the same logs.
Who asked for this?
Each of those fan-out runs is recorded, so I could walk back from every single launch to the nearest thing a human had typed. Thirty-two launches in the week:
| origin | launches |
|---|---|
| I asked for it, in a full sentence | 18 |
| I replied «ok» or «go on» to something | 6 |
| Nobody asked | 8 |
Those eight opened 111 helper agents. About €1,100 at list prices, roughly an eighth of the week.
No instruction preceded them. In each of the eight, the nearest preceding human text was an older request that had already been satisfied — in two cases the same sentence, a day apart. What immediately preceded the launch was an automated notice.
One channel, two kinds of message
When a long task finishes in the background, the tool drops a note into the conversation so the model knows to pick the thread back up: task completed. That note is useful. The problem is where it arrives.
It arrives in the conversation as a user turn. Structurally identical to me typing. Same role, same channel, no marker that separates a thing a person wants from a thing the plumbing is reporting.
This is an old shape of bug wearing new clothes. A program acting on someone's behalf receives something that looks like authority but carries none, and uses its own privileges to act on it. Hardy named that the confused deputy in 1988 [3], and the modern instruction-hierarchy work [1] exists precisely because models have no built-in way to rank the authority of what arrives in context. The security literature has spent three years on the adversarial version of this, where an attacker hides instructions in a web page or a document the agent will read [2]. My case had no adversary at all. The tool's own status message was enough.
The part that compounds
Midway through the week the budget ran out and cut a long run in half. The response was to launch a fresh fan-out — 59 agents — to redo the work the interruption had lost. I can quote the intent, because the run recorded its own description, and it opens: "Finish what the session limit cut…".
So the same work was paid for twice: once partially, once in full to recover it. That launch I did trigger, but not in any sense I recognised at the time — I had asked a four-word question about how things stood. A question came back as a 59-agent orchestration.
What I did about it
My first instinct was to write the lesson down for next time. That is useless, and it took me a minute to see why: the thing that would have to remember the lesson is the same thing that just failed. Asking a model to be careful is not a control, it is a hope. No. 16 was the same mistake in a different costume — I trusted an intuition where a measurement belonged.
So the fix lives outside the model. The tool lets you intercept a call before it runs; the interceptor walks back through the transcript to the nearest real human turn, and if what it finds is an automated notice rather than a person, the launch is refused and never reaches the first agent.
Two properties matter more than the code. It fails closed: if it cannot read the transcript, or cannot establish who asked, it blocks. And the asymmetry justifies that — a wrong block costs me retyping a sentence, while a wrong pass cost €1,100. That is Saltzer and Schroeder's fail-safe default [4], which is fifty years old and keeps being the right answer.
It is also narrow. It guards exactly one operation, the expensive one. A general "check whether the user really wants this" gate on every call would be both slower and vaguer, and vague gates get disabled.
How to fix this in your own setup
The only thing you need from your harness is a pre-execution hook: somewhere to run a check after the model has decided to call a tool and before the tool runs, which can veto it. Most agent frameworks have one. If yours doesn't, this bug is unfixable from the outside and that is worth knowing before you hand it a budget.
Given that, the recipe is four steps:
- Pick the one expensive operation. Not every call — the one that fans out, or spends, or deploys. Mine was the launch that opens helper agents. A guard on everything is a guard that gets turned off.
- Walk backwards through the conversation to the nearest turn that a human actually wrote. This is the whole trick, and it is fiddly for one reason: most turns that are labelled as coming from the user were not written by a user. Tool results arrive that way. So do status notices, injected reminders, and the harness's own bookkeeping. You have to skip all of them.
- Classify what you land on. If it is an automated notice, veto. If it is a person, allow.
- Veto when you cannot tell. Unreadable transcript, unexpected shape, anything surprising — block.
Step 2 is where the real work is. The predicate is small, but each clause in it is a thing I got wrong first:
def last_human_turn(transcript):
"""The nearest turn a person actually typed, or "" if none."""
human = ""
for turn in transcript: # oldest → newest
if turn.role != "user":
continue
text = plain_text_of(turn) # text blocks only
# A "user" turn with no text is a tool result the harness
# fed back in. Nobody typed it. Keep walking.
if not text.strip():
continue
# Harness-authored text that *arrives* as a user turn.
# This is the list that matters; yours will differ, and
# finding it means reading your own transcripts.
if is_harness_injection(text):
injection = text # remember it...
continue # ...but it is not a person
human, injection = text, "" # a real instruction
return human, injection
# The gate itself
human, injection = last_human_turn(read_transcript())
if injection.startswith(TASK_COMPLETED_MARKER):
deny("a task notification is not an instruction")
Two clauses carry the weight. Skipping empty user turns — on my first attempt I did not, so the check landed on a tool result, found no text, and concluded "no human asked", which would have blocked every legitimate launch too. And remembering the injection separately from the human turn: you need both, because the question is not "did a human ever ask for anything" (they always did, further back) but "is the thing immediately before this launch a person or the plumbing".
Then test it against transcripts where you know the answer. I used four: a real instruction, a completion notice, an automated nudge from a local watchdog, and a call to a different tool that must pass through untouched. All four classified correctly, and only then did I leave it switched on. A guard you have not seen refuse anything is not a guard, it is a decoration.
The honest leftovers
The hook does not fix the real hole. It stops a notice from launching work. It does nothing about a four-word question becoming 59 agents, because that genuinely did start with a human. Turning a question into an orchestration is a judgement call, and I have no mechanism for it — only a rule I have written down, which by my own argument above is a hope rather than a control.
I got the first count wrong. My initial pass said 143 fan-out runs that week. That was wrong by more than 4×: I had counted directories touched rather than launches performed, and old runs' folders get touched when anything in them is read. The real number is 32. I published the wrong one to myself for about an hour before checking it, which is the no. 18 failure mode exactly — a number produced by the wrong query looks identical to a number produced by the right one.
I also blamed the wrong component first. I have a local watchdog that nudges stalled sessions to continue, and it was my immediate suspect — it had caused duplicated work before. It emits a recognisable phrase, so this was checkable rather than arguable: zero of the thirty-two launches carried it. The watchdog was innocent and I had already half-written the accusation.
And the cheapest lever is one I have not pulled. Over 99% of the week's spend went to the most expensive model available; the cheap one accounted for 0.2%. A large share of those 111 helpers were doing searches and verification passes — exactly the work the cheap model is for. I have not measured whether swapping them degrades anything, so I am not claiming a saving. I am claiming an untested suspicion, which is a different thing [no. 16].
One account, one week, one tool. The mechanism generalises to any agent that receives system events and user intent on a single channel, which is most of them — but I have measured it exactly once.
References
- Wallace, E., Xiao, K., Leike, R., Weng, L., Heidecke, J. & Beutel, A. (2024). The Instruction Hierarchy: Training LLMs to Prioritize Privileged Instructions. arXiv:2404.13208 — the framing that a model needs an explicit ranking of instruction authority, and that without one it cannot resolve a conflict between a privileged and an unprivileged message. The failure in this post is that case with no adversary: two messages of different authority on one channel.
- Greshake, K., Abdelnabi, S., Mishra, S., Endres, C., Holz, T. & Fritz, M. (2023). Not what you've signed up for: Compromising Real-World LLM-Integrated Applications with Indirect Prompt Injection. arXiv:2302.12173 — the adversarial version of the same confusion, where instructions are planted in data the agent will read. Cited here for the contrast: the mechanism fired on a benign status message, so defences framed purely as anti-attack miss it.
- Hardy, N. (1988). The Confused Deputy: (or why capabilities might have been invented). ACM SIGOPS Operating Systems Review, 22(4), 36–38. doi:10.1145/54289.871709 — the original statement of a privileged program acting on an unprivileged request it cannot distinguish from a legitimate one. The structural diagnosis of this post's bug, and the reason the fix belongs outside the model rather than inside its instructions.
- Saltzer, J.H. & Schroeder, M.D. (1975). The Protection of Information in Computer Systems. Proceedings of the IEEE, 63(9), 1278–1308. doi:10.1109/PROC.1975.9939 — fail-safe defaults and complete mediation. The justification for the guard blocking when it cannot determine who asked, instead of allowing.
Counts of launches, agents and tokens are read directly from local session logs by script, not transcribed by hand. Euro figures are computed from those token counts at published list prices and converted at 1 USD = 0.92 EUR; they are a retail-equivalent estimate and were not verified against a bill — the account they came from is billed as a quota, not per token. The guard was verified on four synthetic transcripts and against the live one before being left in place.
Previous: the cliff nobody publishes · related: the agent is a code block, your folder never leaves · back to the index.