Loop Engineering: ReAct, Plan-Execute, and the Stop Conditions That Save the Bill
A runaway loop is the same bug I paid $5,350 for on Lambda, in a runtime that can now spend money on purpose. Budget guards, circuit breakers, and goal-drift detection.

- A runaway loop is the oldest bug in operations, and agents reintroduce it in a runtime that holds a credit card and can always produce a plausible reason to take one more step. - ReAct versus plan-then-execute is not a quality decision. It is a governance decision: one of them gives you a gate before side effects happen, and the other does not. - Every stop condition must emit a structured termination reason. A loop that just halts is a loop your on-call cannot resume at 3am.
I Have Paid This Bill Before, In a Runtime That Could Only Waste Money By Accident
In 2022, mid-transition into a tech lead role, a Lambda function I deployed entered an infinite invocation loop. No billing alarm. No concurrency ceiling. Nobody watching the AWS bill closely during a handover. It ran for weeks and burned $5,350 in a single month. The money stung less than explaining it, freshly into a seat where I was supposed to be the person who caught this.
Say the honest part first: that was not an agent. I have no logged incident of debugging a runaway agent loop, and I am not going to dress an infrastructure scar up as one. What I have is the bill from the structurally identical failure, one runtime earlier — and the analogy is not flattering to agents.
My Lambda could only waste money by accident. It repeated one wasteful action, identically, until someone noticed. An agent loop wastes money on purpose: at every step it has a reason to continue, the reason is written in fluent English, and the reason is frequently wrong. Serverless removed the ceiling a provisioned box gives you. Agents remove a second ceiling — determinism. You can no longer bound cost by reasoning about what the code does, because what it does next is a distribution.
That is what loop engineering is for. Not making the agent smarter. Making it stop.
Loop Engineering Is Not Prompt Engineering
Loop engineering is the design of how an agent runs, checks its own work, and decides to stop — not what it is asked. It covers the control flow around the model: step ceilings, cost and wall-clock budgets, retry policy, circuit breakers on failing tools, progress detection, and the structured termination reason emitted when any of those fire. Prompt engineering shapes one call. Loop engineering shapes the bill.
The distinction is load-bearing because the failure modes do not overlap. A perfect prompt inside an unbounded loop still produces a $5,000 invoice. A mediocre prompt inside a bounded loop produces a bad answer, cheaply, with a reason attached — and only the second one is recoverable at 3am. Everything below layers onto the minimal harness from agent harness design; none of it replaces the harness.
The Three Loop Shapes, and What Each One Costs
ReAct: reason, act, observe
The model thinks, calls one tool, reads the result, thinks again. Cheap to build, cheap per step, genuinely good when the next move depends on what the last move returned.
What it costs you is a plan. No artifact says what the agent intends to do, so there is nothing to review and nothing to gate. ReAct is myopic by construction: it optimizes the next step, not the trajectory. That is how you get an agent that reads the same file five times, because each individual read looked locally reasonable. On BENCH-1 — migrate seven db.queryRaw() call sites to db.query() across 40 files — it will discover call sites one at a time, re-paying for context on every hop.
Plan-then-execute: commit first, then act
The model produces a plan, the plan is a reviewable artifact, and then execution proceeds against it. This is the only shape that gives you a gate before any side effect. For anything with blast radius — writes, migrations, pull requests — that gate is the whole point. It is also the shape that makes cost predictable: you can price the plan before you fund it.
What it costs you is adaptability. The plan is a snapshot of a world that keeps moving. A test that was green when the plan was written goes red at step four, and now the agent is executing instructions that no longer describe reality. Plan-then-execute fails brittle, not gradually, and it fails hardest exactly when the environment is least stable.
Reflection and critic loops: grade your own work
Add a step where the model (or a second model) critiques the output and the loop iterates until the critic is satisfied. This is real quality lift on well-specified tasks. It is also the single most reliable way to build an infinite loop, because the critic has no obligation to ever be satisfied. Two competent components, each behaving correctly, can hold each other in a cycle forever. Nothing errors. The tokens just leave.
Never ship a reflection loop without a hard iteration cap and a progress check. The cap is not a safety net you hope never fires — on unbounded critics it is the normal exit path.
The decision rule
Three questions, in order:
- Is the task decomposable up front? If you can enumerate the steps before starting — as you can with BENCH-1, where the call sites are discoverable by grep — plan-then-execute. If the next step genuinely depends on the last result, ReAct.
- Does a human need to approve before side effects? If yes, plan-then-execute, because there is no other shape with a reviewable pre-commit artifact.
- How expensive is a wrong step? Cheap and reversible favours ReAct's speed. Expensive or irreversible favours a plan plus a gate, even when the task is adaptive enough to want ReAct.
Most production loops are hybrids: plan the decomposable outer layer, run ReAct inside each bounded step. That is fine. What is not fine is choosing the shape by which tutorial you read last.
The Failure Taxonomy, and the Detector for Each
This is the part almost nobody writes down. Five ways agentic loops fail, and what you instrument to catch each one.
| Failure | What it looks like | Detector |
|---|---|---|
| Infinite reflection | Tokens climb, iteration count climbs, output barely changes | Step ceiling + output-similarity check across the last n iterations |
| Goal drift | Agent pursues a related but different objective, competently | Periodic judge comparing recent actions to the stated goal |
| Hallucinated tool calls | Calls a tool that does not exist, or a real tool with invented arguments | Strict schema validation at the tool boundary; count rejections per step |
| Context overflow | Output quality degrades silently as the window fills | Token accounting per step with a degradation threshold, not a hard limit |
| Silent failure | Confident summary, zero observable change to the world | State fingerprint before and after; compare |
Two of these deserve more than a row.
Context overflow degrades silently. The loop does not crash when the window fills. It gets worse at the task while sounding exactly as confident, so your only signal is measured quality, not an error. That is why context is its own engineering surface — budget, compaction, and handoff are covered in context engineering for long-running agents.
Silent failure is the worst one, because nothing alerts. The agent finishes. The termination reason says goal_met. The summary is articulate. And the seven queryRaw call sites are all still there. Every other failure in this table eventually shows up as a cost spike, an error rate, or a stuck run. This one shows up as a green dashboard. The only defence is to define done as observable state, and check it — never as the model's own report that it is done.
The Controls, In Code
Six controls. Runnable versions and the BENCH-1 fixture live in github-repo/agent-engineering/03-loop-controls/.
Start by making termination a value, not a break:
// 03-loop-controls/stop.ts
export type Termination =
| { kind: 'goal_met'; evidence: string }
| { kind: 'cost_budget'; spentUsd: number; capUsd: number }
| { kind: 'deadline'; elapsedMs: number; capMs: number }
| { kind: 'step_ceiling'; steps: number; cap: number }
| { kind: 'tool_circuit_open'; tool: string; consecutiveFailures: number }
| { kind: 'no_progress'; sinceStep: number; fingerprint: string }
| { kind: 'goal_drift'; why: string; step: number };
export interface Limits {
maxUsd: number;
maxSteps: number;
wallClockMs: number;
toolFailureThreshold: number;
progressWindow: number;
}Cost, wall clock, and step ceiling are one object, because they are one question — has this run spent more than it is worth?
// 03-loop-controls/budget.ts
import type { Limits, Termination } from './stop';
// Per-MTok rates for the model you are actually running. Read them from
// config. Never hard-code a price you have not checked this quarter.
interface Rates { inputPerMTok: number; outputPerMTok: number; cacheReadPerMTok: number }
export class Budget {
private usd = 0;
private steps = 0;
private readonly startedAt = Date.now();
constructor(private readonly limits: Limits, private readonly rates: Rates) {}
record(u: { input_tokens: number; output_tokens: number; cache_read_input_tokens?: number }): void {
this.usd +=
(u.input_tokens / 1e6) * this.rates.inputPerMTok +
(u.output_tokens / 1e6) * this.rates.outputPerMTok +
((u.cache_read_input_tokens ?? 0) / 1e6) * this.rates.cacheReadPerMTok;
this.steps += 1;
}
breach(): Termination | null {
const elapsedMs = Date.now() - this.startedAt;
if (this.usd >= this.limits.maxUsd)
return { kind: 'cost_budget', spentUsd: this.usd, capUsd: this.limits.maxUsd };
if (elapsedMs >= this.limits.wallClockMs)
return { kind: 'deadline', elapsedMs, capMs: this.limits.wallClockMs };
if (this.steps >= this.limits.maxSteps)
return { kind: 'step_ceiling', steps: this.steps, cap: this.limits.maxSteps };
return null;
}
}Note the cache-read term. Cached input is billed at a fraction of the base rate, so a ledger that sums input_tokens alone overstates spend on cache-heavy loops and gets throttled early. Read the fields the API actually returns.
The circuit breaker is the control most teams skip, and the one that matters when a dependency browns out. A tool that has failed three times consecutively is not going to succeed on attempt four just because the model asked more politely:
// 03-loop-controls/circuit.ts
export class ToolCircuit {
private consecutive = new Map<string, number>();
constructor(private readonly threshold: number) {}
record(tool: string, ok: boolean): void {
this.consecutive.set(tool, ok ? 0 : (this.consecutive.get(tool) ?? 0) + 1);
}
open(): { tool: string; consecutiveFailures: number } | null {
for (const [tool, n] of this.consecutive) {
if (n >= this.threshold) return { tool, consecutiveFailures: n };
}
return null;
}
}The progress check is the answer to silent failure. Fingerprint the world, not the transcript:
// 03-loop-controls/progress.ts
import { createHash } from 'node:crypto';
// For BENCH-1, observable state is the set of surviving queryRaw call sites
// plus the test result — the two things the task is defined over.
export interface WorldState { callSites: string[]; testsPass: boolean }
export function fingerprint(s: WorldState): string {
return createHash('sha256')
.update(JSON.stringify({ c: [...s.callSites].sort(), t: s.testsPass }))
.digest('hex');
}
export class ProgressCheck {
private last = '';
private stale = 0;
constructor(private readonly window: number) {}
// Returns false when the world has not changed for `window` steps.
advancing(s: WorldState): boolean {
const fp = fingerprint(s);
this.stale = fp === this.last ? this.stale + 1 : 0;
this.last = fp;
return this.stale < this.window;
}
}Goal drift needs a judge, because it is a semantic failure. Run it every n steps, not every step — a drift check that costs as much as the work it guards is not a control, it is a second agent:
// 03-loop-controls/drift.ts
import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic();
export async function driftVerdict(goal: string, recentActions: string[]) {
const res = await client.messages.create({
model: 'claude-opus-5',
max_tokens: 512,
system:
'You audit an agent trajectory. Decide only whether the recent actions ' +
'serve the stated goal. Related-but-different counts as off-task.',
output_config: {
format: {
type: 'json_schema',
schema: {
type: 'object',
properties: { onTask: { type: 'boolean' }, why: { type: 'string' } },
required: ['onTask', 'why'],
additionalProperties: false,
},
},
},
messages: [{ role: 'user', content: `GOAL: ${goal}\n\nRECENT ACTIONS:\n${recentActions.join('\n')}` }],
});
const block = res.content.find((b) => b.type === 'text');
return JSON.parse(block?.type === 'text' ? block.text : '{"onTask":true,"why":"unparsed"}') as {
onTask: boolean;
why: string;
};
}The agent loop itself runs on claude-sonnet-5. The judge runs on claude-opus-5, because a judge that is weaker than the worker is a rubber stamp.
Every Stop Must Be Explained
Here is the rule that outranks all six controls: a loop your on-call can stop, inspect, and resume at 3am beats a loop that is cleverer. Every architecture decision I have made that looked elegant on a diagram and came back as a late-night "the site is down" call taught me the same lesson. The person recovering the system is tired, is not you, and has no access to your intent.
Applied to loops: every termination path returns a Termination value and writes it to the run record. Never break. Never return null. A loop that stops without saying why forces the responder to reconstruct the last ten minutes from a token graph, and they will guess wrong — cost_budget and no_progress look identical from outside and demand opposite responses. One means raise the cap. The other means the agent is stuck, and raising the cap sets money on fire.
Design reviews optimize for elegance. 3am optimizes for recoverability. The structured stop reason costs one type definition and buys you the difference.
What the Loop Costs
The metric to hold this against is cost per verified unit of work, from AI cost accountability in engineering leadership. It is the right denominator here for one reason: loop controls change the numerator and the denominator in opposite directions. Tighter budgets cut spend. Tighter budgets also abandon runs that would have succeeded on step twelve. Cost-per-run cannot see that trade. Cost per verified unit can.
Illustrative arithmetic, with the inputs stated so you can redo it with yours. Say an uncontrolled configuration passes 12 of 20 runs at an average $0.40 per run: $8.00 total, 12 verified units, roughly $0.67 per verified unit. Now say controls cut average spend to $0.22 but also kill three runs that would have passed: $4.40 total, 9 verified units, roughly $0.49 per verified unit. Better — but tighten the budget one notch further and you can invert it. These numbers are illustrative, not measured. The point is the shape: there is an optimum, it is not at either extreme, and you cannot find it without the denominator.
BENCH-1: Uncontrolled Loop vs Controlled Loop
Same task, same model, same tools. One configuration runs to natural termination. The other runs behind the six controls above.
| Configuration | Pass rate (20 runs) | Median tokens | p95 wall clock |
|---|---|---|---|
| Uncontrolled ReAct loop | — | — | — |
| Controlled loop (budget, breaker, progress, drift) | — | — | — |
The interesting number is not the median. Medians will land close, because the median run does not misbehave — it finds the call sites, edits them, and exits. The controls earn their keep in the p95 tail, where the uncontrolled configuration contains the runs that reflected in circles, drifted into refactoring an unrelated module, or reported success on an unchanged repository. A median-only comparison makes loop controls look like overhead. The tail is where the $5,350 lives.
FAQ
How do you stop an AI agent from looping forever?
Layer four independent limits: a step ceiling, a wall-clock deadline, a cost budget, and a progress check that fires when observable state has not changed for n steps. Any one alone is insufficient — a step ceiling does not catch slow expensive steps, and a budget does not catch a cheap loop making no progress. Emit a structured reason whenever one fires.
What is loop engineering?
Loop engineering is designing how an agent runs, verifies its own work, and decides to stop, as opposed to prompt engineering, which shapes what the agent is asked. It covers loop shape, retry budgets, circuit breakers, cost and time limits, progress detection, and termination reporting. It is the layer that determines your bill, not your demo.
ReAct vs plan-and-execute — which should you use?
Plan-then-execute when the task decomposes up front, or when a human must approve before side effects: only a plan gives you a reviewable pre-commit artifact. ReAct when each step genuinely depends on the last result and wrong steps are cheap and reversible. In production, most loops plan the outer layer and run ReAct inside bounded steps.
What is goal drift in AI agents?
Goal drift is an agent pursuing a related but different objective from the one it was given — competently, and usually without any error. Asked to migrate seven call sites, it starts refactoring the data-access layer. Detect it with a periodic judge comparing recent actions against the stated goal, run every few steps rather than every step.
How do you cap agent token spend?
Meter usage per step from the API's own token fields, including cached-input reads, which are billed at a different rate. Convert to currency using rates read from config, accumulate across the run, and terminate on breach with the spend and cap recorded. Pair the cap with a progress check so a stuck loop stops before it exhausts the budget rather than after.
Take This Further
Pick your worst-behaved loop and give it one thing this week: a Termination type with a real reason on every exit path. You will learn more from the first week of stop reasons than from any amount of prompt tuning — including which of your loops has been silently failing all along. Then read graph engineering for what happens when one loop becomes several, and agent evals and observability for how to prove any of it worked at the trajectory level.