Anti-Pattern: The Unbounded Agent
6 min read · updated August 3, 2026
Every agent framework’s quickstart is an unbounded agent. It has a loop, a tool list and a while-true, and it works, because the task in the quickstart takes four steps. The bound is the part you are expected to add, and it is the part that gets added after the first invoice.
Why nobody writes the bound first
The unbounded agent is not an oversight born of carelessness. It is the direct consequence of what makes agents impressive in the first place: an agent is valuable precisely because you do not have to specify how many steps the task takes. That is the entire pitch. A workflow with a fixed step count is a workflow, and you could have written it yourself.
So the design instinct — leave the loop open, the model will know when it is done — is not stupid. It is the feature. The mistake is believing that “the model decides when to stop” and “there is no other stopping condition” are the same statement. The model deciding is a heuristic; a bound is a guarantee. You want both, and only one of them can be relied on when the heuristic is the thing that has gone wrong.
There is a second reason, and it is about how agents are tested. An agent is developed against tasks the developer already knows are achievable. A runaway happens on a task that is not achievable — the file does not exist, the API returns a shape the agent cannot parse, the goal was underspecified — and those tasks are not in anybody’s manual test set, because they were not interesting to try.
Three dimensions it runs away along
“Infinite loop” is too coarse a description. There are three separate runaway modes and they need three separate bounds, because each one is invisible to the other two’s detector.
| Runaway | Description |
|---|---|
| Step count | The classic. The agent retries the same failing tool, or oscillates between two states, or plans a plan to plan. Caught by a step counter, which is the easy one and the one everybody has. |
| Spend | Steps are bounded but each step grows. The transcript accumulates, so step twenty re-sends everything from steps one to nineteen, and cost per step rises roughly with the square of the run length. A twenty-step limit does not bound spend. |
| Fan-out | The agent spawns sub-agents or parallel tool calls, each of which has its own step budget. Bounds that are per-agent rather than per-task multiply. This is the one that turns a rounding error into a real number. |
The spend mode deserves a moment because it surprises people who correctly added a step limit. Each turn re-sends the conversation so far, so if turns add roughly constant length, total input tokens across an n-step run grow with n squared rather than n. Doubling the step limit from ten to twenty does not double the worst-case bill; it roughly quadruples the input half of it. That is why a step limit chosen to feel generous is a much larger financial commitment than it looks, and why the budget has to be denominated in money rather than in iterations.
What it looks like while it is happening
A runaway does not announce itself as an error, and this is the part that makes it expensive: the agent is working. Logs are being written, tools are being called, nothing is throwing. The named signals worth alerting on are these, and they are cheap to compute from a trace you are already collecting.
- Step-count distribution with a fat right tail. Most runs finish in a few steps; the mean is not the story. Watch the p99 and the share of runs that hit the ceiling. A rising ceiling-hit rate means the ceiling is now doing load-bearing work, which means something upstream broke.
- Repeated tool-call fingerprints within a run. Hash the tool name plus its arguments. The same hash appearing three times in one run is an oscillation, and it is detectable in the loop rather than in a dashboard afterwards.
- Cost per completed task, not cost per call. The per-call figure looks fine throughout a runaway. The per-task figure is the one that moves, and it is the only one that maps onto unit economics.
- Runs with no state change. If an agent is supposed to produce an artefact and a run ends without one, that run consumed budget and produced nothing. Track the rate; it is the agent equivalent of a request that returns 200 with an empty body.
The bounds, written out
Three counters and a progress check. The progress check is the one that is usually missing, because it is the only one that catches an agent which is making measurable progress toward nothing.
type Budget = {
maxSteps: number;
maxCostCents: number;
maxWallMs: number;
maxRepeats: number; // identical tool-call fingerprints tolerated
};
class RunBudget {
private steps = 0;
private cents = 0;
private seen = new Map<string, number>();
private readonly startedAt = Date.now();
constructor(private readonly limits: Budget) {}
/** Called BEFORE each step, so a step that cannot be afforded never starts. */
admit(estimateCents: number) {
if (this.steps >= this.limits.maxSteps) throw new Halt("steps");
if (this.cents + estimateCents > this.limits.maxCostCents) throw new Halt("budget");
if (Date.now() - this.startedAt > this.limits.maxWallMs) throw new Halt("time");
this.steps++;
}
/** Called AFTER, with the real charge and what the step actually did. */
settle(actualCents: number, fingerprint: string) {
this.cents += actualCents;
const n = (this.seen.get(fingerprint) ?? 0) + 1;
this.seen.set(fingerprint, n);
if (n > this.limits.maxRepeats) throw new Halt("no-progress");
}
spent() { return this.cents; }
}Two details carry the weight. admit runs before the call and takes an estimate, so a step that cannot be paid for is never started — the same reason a deadline refuses to begin work it cannot finish. And the budget is a property of the task, not of the agent: if a sub-agent is spawned it receives the same object, not a fresh one. A per-agent budget under fan-out is how a fifty-cent cap becomes a fifty-dollar run.
The halt has to be a distinct outcome, not an error. A task stopped by its budget is not the same as a task that failed, and conflating them means you cannot tell an infrastructure problem from a task that was too hard. Record which limit fired; the distribution across steps, budget, time and no-progress tells you something different in each case. Stopping conditions in general — including the progress heuristics a well-behaved agent uses to decide it is finished — are their own subject; what is above is the floor beneath them.
Designing a task that can be bounded
The bounds above are a safety net, and a safety net that fires often is a design problem rather than a solved one. Three properties make a task boundable in the first place.
- A checkable definition of done. Not “the model says it is finished” but a predicate you can evaluate: the tests pass, the schema validates, the file exists, the record is written. A task with no checkable completion has no natural stopping point and will always be stopped by a counter.
- Progress that is externally visible. If each step is meant to change something outside the transcript, a step that changes nothing is detectable without semantics. Agents whose only product is text in their own context window cannot be checked this way, and that is a reason to make them write to somewhere real.
- Idempotent, reversible tools. A halt lands mid-run by definition, so every tool must leave the world in a state you can resume from or roll back. This is where the claim-before-call pattern stops being an infrastructure nicety and becomes the thing that lets you cancel at all.
A useful design exercise before you build: write down what the agent should do when it has used eighty per cent of its budget and is not finished. If the honest answer is “keep going”, the task has no bound and you should not be running it unattended. If the answer is “hand what it has to a person”, you have just specified a review queue, and the agent is a first draft generator rather than an autonomous worker — which is usually the more valuable of the two anyway.