Loop Engineering: Designing the Cycle an Agent Runs In
An agent is a while loop. Everything hard is in the exit condition.
Strip an agent down and you get about fifteen lines:
let messages = [systemPrompt, userRequest];
while (true) {
const response = await model(messages, tools);
messages.push(response);
if (!response.toolCalls) break;
for (const call of response.toolCalls) {
messages.push(await execute(call));
}
}
That's it. That's an agent. You can write it in an afternoon and it will work on your first ten test cases.
Then you put it in front of real inputs and it runs for forty steps re-reading the same file, or it declares success without doing anything, or it burns your monthly token budget on a task it could never complete. The loop was never the hard part. The hard part is everything that decides when the loop should stop, whether it's getting anywhere, and what it learns when a step fails.
That's loop engineering, and it's a distinct skill from prompt work.
Failure mode one: the loop that doesn't know it's stuck
The most expensive agent failure isn't a crash. It's an agent making calls that look productive and aren't.
The classic version: the agent reads a file, doesn't find what it needs, reads the same file again with a slightly different justification, and repeats. Every step is a valid tool call. Every step returns successfully. Nothing advances. The model has no built-in sense of "I already tried this" beyond what's in context, and by step 15 the early attempts are compacted or crowded out.
The fix isn't a better prompt. It's a progress signal the loop owns:
const seen = new Map<string, number>();
function checkProgress(call: ToolCall): 'ok' | 'repeat' | 'stuck' {
const key = hash(call.name, call.args);
const count = (seen.get(key) ?? 0) + 1;
seen.set(key, count);
if (count >= 3) return 'stuck';
if (count === 2) return 'repeat';
return 'ok';
}
Cheap and effective. On repeat, inject an observation into context — you have already made this exact call and received this result; a different approach is needed. On stuck, break the loop and escalate.
The deeper version tracks state change rather than call identity: did anything about the world or the agent's knowledge actually differ after that step? Harder to implement, catches more. Start with call hashing; you'll get most of the value.
Failure mode two: no exit condition you actually control
while (true) with break on "model stopped calling tools" delegates termination to the model. That's fine when the task is well-formed and the model is confident. It fails in both directions: agents that stop early because they've convinced themselves they're done, and agents that never stop because each step reveals more work.
A production loop has at least four exit conditions, and only one of them belongs to the model:
Success. The model signals completion — and a verification step confirms it. "I've fixed the bug" is a claim. Running the test is the exit condition. If your task has any checkable definition of done, check it; don't take the model's word.
Budget exhaustion. Steps, tokens, wall-clock time, dollars. All four, with separate limits. Hitting one of these is not an error — it's a designed outcome, and the loop should return partial results plus a clear statement of what remains.
No progress. The stuck detector above. Three identical calls, or N steps with no state change.
Hard failure. An unrecoverable error: authentication gone, required resource missing, permission permanently denied. These should exit immediately rather than being retried into the budget cap.
Write these as explicit branches. When your loop exits, you should be able to name which one fired, and that name should be in the log and, when appropriate, in what the user sees.
Failure mode three: errors that teach nothing
When a tool call fails, the error goes back into context and becomes the model's entire understanding of what happened. This is a prompt you're writing at runtime, and most people write it badly:
Error: ENOENT
The model now knows something went wrong. It doesn't know what, or what to do instead, so it guesses. Usually it retries the identical call.
Compare:
File not found: config/settings.json
The directory 'config/' exists and contains: app.json, database.json
Did you mean 'config/app.json'?
Same failure. Completely different next step. The second version costs more tokens and saves several iterations — a trade that's almost always worth it.
The rule: error messages are prompts. Every one should answer three questions — what failed, what the actual state is, and what a reasonable next action would be. If your error handler can compute a suggestion cheaply, compute it.
There's a second rule that matters as much: distinguish transient from permanent. A rate limit is transient; the loop should back off and retry without the model ever seeing it. A malformed argument is permanent for that exact call; the model must see it and change. A revoked credential is permanent for the session; the loop should exit. Conflating these produces either wasted iterations on unretryable errors or spurious failures on retryable ones.
type Failure =
| { kind: 'transient'; retryAfterMs: number } // loop retries, model unaware
| { kind: 'invalid'; message: string } // model sees, corrects
| { kind: 'fatal'; message: string }; // loop exits
Failure mode four: context that grows until the loop dies
Every iteration appends. Model output, tool results, error messages — all of it accumulates in messages. Around step 20 on a real task you're re-sending a context that's mostly archaeology, paying for it every turn, and watching quality degrade as the signal gets diluted.
Compaction has to be part of the loop, not an afterthought. The structure that works:
- Recent turns, verbatim. The last three to five steps stay in full. This is the agent's working memory and it's where most of the useful detail lives.
- Older turns, summarized. Steps 1 through N-5 collapse into a running findings block: what was learned, what was tried, what was ruled out. Regenerate it periodically rather than appending to it.
- Pinned facts, always. The original request, hard constraints, and any discovered invariant ("the API returns snake_case") stay in full regardless of age. Losing the original request at step 30 is a real and embarrassing failure.
- Tool results, tiered. Large results get stored externally and referenced by handle. The agent gets a summary plus the ability to re-fetch. A 200KB API response should never sit raw in context.
Trigger compaction on a threshold — say, 60% of your context budget — not on a step count. Tasks vary wildly in how much they produce per step.
One caution: compaction is lossy by definition, and the thing you drop is sometimes the thing that mattered. Log what was compacted away. When an agent makes an inexplicable decision at step 25, the explanation is frequently in what the summarizer discarded at step 18.
Failure mode five: one loop doing too much
A single loop holding a fifty-step task in one context is fighting itself. The context is enormous, the early steps are compacted into vagueness, and every iteration re-reasons about the whole problem.
Decomposition fixes this, and the useful primitive is a sub-loop with its own fresh context and its own budget:
Parent loop: owns the plan, holds ~5 high-level steps
└─ Sub-loop: owns one step, fresh context, own budget,
returns a compact result to the parent
The parent never sees the sub-loop's forty intermediate messages — only its conclusion. This is context isolation, and it's the single highest-leverage move for long tasks.
It's not free. You lose information at the boundary, and if the sub-loop's result summary is thin the parent makes decisions blind. Design the return contract deliberately: what the sub-loop found, what it did, what it couldn't do, and what the parent needs to know that it didn't ask for.
Decompose when a task has genuinely separable phases. Don't decompose a tightly coupled task into pieces that each need the others' full context — you'll spend more on coordination than you save.
The loop is a state machine wearing a while loop
The thread through all five failure modes: while (true) is not a design, it's a placeholder. What you actually want is explicit state — current phase, budget consumed, progress signal, failure history — evaluated at the top of each iteration, with transitions you wrote rather than transitions the model implied.
That's a smaller change than it sounds. Keep the loop; add the bookkeeping:
while (state.phase !== 'done') {
if (overBudget(state)) return partial(state, 'budget');
if (isStuck(state)) return partial(state, 'no-progress');
const step = await runStep(state);
state = reduce(state, step);
}
Every production agent I'd trust converges on something close to this. The model is one call inside it.
What to build first
In order, because each one makes the next easier to see:
- A step counter and a hard cap. Not because the cap is a good exit condition — it isn't — but because it forces you to handle the partial-result path, which you need anyway.
- Repeat detection by call hash. Twenty lines. Catches the most common and most expensive failure.
- A three-way error taxonomy — transient, invalid, fatal — with different loop behavior for each.
- Compaction with a pinned-facts block. Only once you're routinely running past ten steps.
- Sub-loops, last. Don't reach for decomposition before you've made a single loop reliable; you'll just have several unreliable loops.
The agent that finishes isn't the one with the best prompt. It's the one whose loop knows when to stop.
"An agent is a while loop. Everything hard is in the exit condition."
Notes from the build
More from the AI engineering trenches
Evals, observability, prompt design, and real lessons from shipping AI products. No fluff.
By subscribing, you agree to receive Orlando's emails. No spam. Unsubscribe anytime.