An agent loop is a while loop with good manners
One agent turn, dissected: exits, breakers, and recovery paths are the actual engineering — in a loop whose own codebase bans the while statement.
On this page
“Agentic loop” is doing a lot of mystical work in 2026. Say it in a talk and people picture planners, graph executors, some emergent decision engine humming inside the framework. Here is what it actually is: assemble a prompt, call the model once, run whatever tools it asked for, append everything to a list, and go again — until the model stops asking. A while loop with good manners.
That’s not a dismissal. It’s the load-bearing observation of this post, because once the loop itself stops being magic, you can see where the engineering really lives: in what gets assembled each lap, in who decides when to stop, in what the loop tells the outside world, and in what happens when the model emits garbage. The edges, not the center.
Everything below is lifted from efferent’s agent kernel, where the entire loop is one file — packages/engine/src/loop/loop.ts, ~540 lines. And that file demands a confession before the dissection starts: it contains no while. It can’t. The repo’s own quality gates ban loop statements outright (effect/no-loop-statements, enforced at a zero baseline alongside no-try-catch and no-let — one violation anywhere fails the build). The loop is written as a fold: Effect.iterate over a single immutable state value, stepping once per turn until the state says stop. The shape of a while loop survived. The statement didn’t. Keep that in mind; the post ends up arguing it’s not a paradox at all.
One turn, in ten naive lines
First, vocabulary. A turn (the code also says step) is one full lap: build the prompt, make one model call, execute any tools, append the results. A tool call is the model replying not with prose but with a structured request — “run read_file with these arguments” — and a finish reason is the provider’s label for why generation stopped: it ran out of things to say ('stop'), or it stopped on purpose because it wants tool results before continuing ('tool-calls').
With those three terms, the whole architecture fits in a sketch:
let messages = [...history, { role: 'user', content: prompt }]
while (true) {
const res = await llm.generate({ system, messages, tools })
messages.push(...res.newMessages) // assistant text + tool calls + tool results
if (res.finishReason !== 'tool-calls') break // the model stopped asking
}
return res.text
That is, genuinely, the shape of every coding agent you’ve used. The model can’t run grep; it can only ask. So the loop’s job is to keep answering — execute the request, append the result, re-send the whole conversation — until a reply arrives that asks for nothing.
But every line of the sketch hides a decision. Who executes the tools, and how many at once? while (true) trusts the model to terminate — would you? push appends, but appends what exactly, and who persists it? There’s exactly one exit, and it belongs to the model, not to you. And nothing outside this function can see a turn happen, which means no UI, no metrics, no logs. The rest of this post is those decisions, one at a time.
Buy the step, own the iteration
The first decision is where to stop writing code. efferent sits on @effect/ai, whose LanguageModel.generateText does something stronger than a raw provider call: hand it a toolkit — schema-typed tool definitions whose handlers are Effects — and it resolves one entire step. It decodes the model’s tool calls against the schemas, runs your handlers with bounded concurrency (default 4 — enough for real fan-out, bounded so a twenty-call turn doesn’t stampede a rate limit), and returns a response that already contains the assistant’s parts and the tool results.
What it deliberately does not do is iterate. That part is yours, and in the new line it looks like this:
type Phase = "continue" | "completed" | "step-cap" | "degenerate-loop"
interface LoopState {
readonly messages: ReadonlyArray<AgentMessage>
readonly newTail: ReadonlyArray<AgentMessage> // everything the loop appended
readonly finalText: string
readonly turnIndex: number
readonly malformedStreak: number
readonly seen: ReadonlySet<string> // progress signatures seen this run
readonly staleTurns: number
readonly usage: TokenUsage
readonly phase: Phase
// trajectory vitals — annotated on the run span at the end
readonly toolCalls: number
readonly toolFailures: number
readonly corrections: number
}
const final = yield* Effect.iterate(initialState, {
while: (state) => state.phase === "continue",
body: step, // one turn: prompt → model call → tool fan-out → the next state
})
step is a function from state to state: take the world as it stands, make one model call, produce the next world. Every variable the naive sketch would mutate — the buffer, the counter, the break flags — is a field on one immutable value, and every exit is a value of phase, decided in exactly one place per turn. The payoff is the same one “errors as values” buys: state can’t be half-updated, a transition is a plain value you can log and test, and reading the LoopState interface is reading the loop’s complete anatomy — including three counters that exist purely so the run can testify about itself later.
The division of labor is worth defending in one paragraph. The per-step machinery — argument decoding, handler dispatch, running four handlers concurrently without leaking on interruption — is fiddly and completely generic; let a library own it. The iteration is the opposite: the buffer is your domain entity, the exits are product policy, the events are your observability. Whoever owns the loop owns your product’s shape, which is why efferent buys the step and keeps the loop — and why the engine package imports nothing but effect and @effect/ai, a boundary the gate suite fails the build over rather than trusting to convention.
One assembly note before the exits, because the “conversation” framing hides it: the model remembers nothing between calls. Every turn re-sends the system prompt plus the entire mapped history, rebuilt from the buffer. The mapping (packages/engine/src/loop/mapping.ts) exists mostly for one subtle obligation: providerOptions, an opaque blob carried verbatim in both directions, which is how Gemini’s thought_signature — a signed receipt the provider demands back on the next call — survives across turns. The framework’s own response-to-prompt helper drops it; the engine maps by hand to keep it alive.
Exits are policy, not accident
The sketch had one exit and gave it to the model. The real loop decides phase in one expression at the bottom of every step:
const wantsMore = outcome.finishReason === "tool-calls" && toolCalls.length > 0
const broke = sig !== "" && stale >= REPEAT_BREAK_AT
const phase: Phase = broke
? "degenerate-loop"
: !wantsMore
? "completed"
: turnIndex >= maxSteps
? "step-cap"
: "continue"
completed — the model is done. Both conjuncts of wantsMore are deliberate: a provider can report 'tool-calls' while delivering a response with no decodable calls in it, and trusting the label alone would loop forever on a prompt the model has no further moves for.
step-cap — the ceiling. DEFAULT_MAX_STEPS is 100, and the doc comment calls it what it is: “a ceiling, not a target.” The interesting part is the bookkeeping: a capped run is not a finished run — its last assistant text is mid-thought narration, and any caller that surfaces it as the answer is presenting an abandoned thought as a deliverable. So the run’s verdict says which ending happened: agent_end carries outcome: "ok" | "partial" plus a reason (completed / step-cap / degenerate-loop), and a capped run is partial by construction. (smith, efferent’s coding agent, runs its attempts at 100 steps and treats the cap as pacing: the real runaway bounds are the breaker below and a wall-clock budget, and the cap just sets how often the quality gates get to weigh in.)
The give-up bound. Three consecutive unparseable responses fail the run outright (MAX_MALFORMED = 3) — the recovery section covers the two chances before that.
degenerate-loop — the no-progress breaker. The newest exit, and the only one aimed at a model that’s working but going nowhere. It gets its own section.
Four exits, four owners: the model’s choice, your cap, your patience, your progress.
The breaker: progress is a hash of results
How do you tell a methodical model from one going in circles? Not by watching its arguments — by watching what the world says back. Each turn’s tool results are folded into a progress signature:
const progressSignature = (content, pollable) => {
const calls = responseToolCalls(content)
if (calls.length === 0) return ""
if (calls.every((c) => pollable.has(c.toolName))) return ""
return responseToolResults(content)
.filter((r) => !pollable.has(r.toolName))
.map((r) => `${r.toolName}:${r.ok ? "ok" : "err"}:${clip(JSON.stringify(r.result ?? null), 300)}`)
.sort() // a multiset: same results in a different order is still no progress
.join("|")
}
The signature is keyed on results, not arguments — the design decision the whole feature hangs on. A stuck model churns its arguments endlessly: grep foo, then grep "foo", then grep foo\. — three distinct calls, one identical empty result, zero progress. Hash the arguments and that looks like exploration; hash the results and it looks like what it is. A turn whose signature was already seen this run counts as stale.
Two escalation stops: at three consecutive stale turns, a corrective nudge enters the conversation — “STOP repeating that call. Take a DIFFERENT concrete action toward the task…” — which costs a step and counts as a correction, like every synthetic turn. At five, the loop force-stops: phase degenerate-loop, outcome partial, and if the model never produced prose, the final text is an explicit [stopped: repeated the same tool call with no progress — the run was looping] — so the caller reads a loop-stop, never an answer.
And one exemption keeps it honest: pollable tools never count. A tool designed to be polled returns unchanged results correctly — that’s not degeneracy, that’s polling. Smith exempts todo_write, its plan-status tool, because a status update legitimately repeats with a constant result; a scripted forge first exposed the breaker firing on that legitimate repetition.
Recovery: when the model emits garbage
A production loop talks to a probabilistic text generator. Ordinary tool failures — file not found, ambiguous edit — are already data: efferent’s tools declare failureMode: "return", so the failure comes back as a tool result the model reads and corrects in the same run. But there’s a failure zone before any handler: a hallucinated tool name or a wrong-shaped argument payload fails response decoding inside generateText itself. The loop catches that and converts it into text the model reads:
if (outcome._tag === "malformed") {
const streak = state.malformedStreak + 1
if (streak >= MAX_MALFORMED) return yield* Effect.fail(outcome.err)
const corrective: AgentMessage = {
role: "user",
content:
`Your previous reply could not be parsed: ${desc}\n\n` +
`This usually means you called a tool that doesn't exist or used the ` +
`wrong argument shape. The only tools available are: ${toolNames.join(", ")}. ` +
`Reply again using one of those tools, or plain text if you're done.`,
}
yield* options.onTail?.([corrective]) ?? Effect.void // synthetic turns persist too
return {
...state,
messages: [...state.messages, corrective],
newTail: [...state.newTail, corrective],
turnIndex: state.turnIndex + 1, // recovery can't dodge the step cap
malformedStreak: streak,
corrections: state.corrections + 1,
}
}
Three details earn their lines. The bound is consecutive: the success path resets malformedStreak to zero, so a model that occasionally stumbles isn’t punished like one that’s broken. The corrective costs a step, so recovery spends from the same allowance as work. And the corrective goes through onTail into the persisted record — the synthetic message is part of what really happened, which is why the loop reports its own appends instead of letting callers do index arithmetic on a buffer that folds can reshape mid-run.
One event sink, three seams
The engine exposes observation as one union and control through three narrow seams.
Observation is a single optional onEvent receiving LoopEvent:
export type LoopEvent =
| { readonly type: "turn_start"; readonly turnIndex: number }
| { readonly type: "assistant_delta" /* streamed text — transient by design */ }
| { readonly type: "assistant_message" /* text, reasoning, toolCalls, usage, position */ }
| { readonly type: "tool_start" /* toolCallId, toolName, args */ }
| { readonly type: "tool_end" /* …plus ok + result */ }
| { readonly type: "compaction"; readonly tokens: number; readonly kept: number }
| { readonly type: "agent_end"; readonly outcome: "ok" | "partial"; readonly reason: string }
It’s discriminated on type rather than _tag so a product extends the vocabulary with its own events (type SmithEvent = LoopEvent | { type: "context_folded"; … }), and every driver — TUI, headless printer, scripted scenario — consumes the same stream. Events observe; they cannot alter the run.
The three seams that can:
onTail — incremental persistence. Called with each turn’s appended messages the moment they land, so a crashed session is restorable to its last completed turn — and it returns the absolute store positions it assigned. The ordering is deliberate: the loop persists before emitting the assistant event, so the event carries the message’s durable position and a UI can key its blocks on it — a live-streamed block and a later re-projection of the same message reconcile instead of duplicating. Same motive behind a small pre-step: some providers (Gemini) return tool calls with no id at all, so the loop mints deterministic ones — <turnIndex>:<toolName>:<ordinal> — once, before the content fans out into events and persisted messages.
compact — within-run folding. Consulted at turn boundaries with the buffer the next turn would send plus the finished turn’s usage; returning a plan rewrites the buffer to summary-plus-kept-tail. The loop stays pure — thresholds, summarization, and store reconciliation all live in the callback (that machinery is the next post in the compaction story) — and a plan that would split a tool call from its results violates the contract and is ignored, never applied.
pendingInput — mid-turn steering. Consulted at turn boundaries while the run continues: a human note typed mid-run becomes a user message the next model call sees, instead of feedback that arrives after the whole run. Two guarantees ride it: the injection happens after any fold, so a steering note can never be compacted away before the model reads it, and it persists through onTail like every other message.
What the run reports
The counters the state has been dragging through the fold pay off at the end:
yield* Effect.annotateCurrentSpan({
"engine.outcome": outcome, // ok | partial
"engine.reason": reason, // completed | step-cap | degenerate-loop
"engine.turns": final.turnIndex,
"engine.tool_calls": final.toolCalls,
"engine.tool_calls.failed": final.toolFailures,
"engine.corrections": final.corrections,
})
A “completed” run with 25 turns, five failed calls, and three corrections is a low-quality success — and it’s visible at a glance on the trace, without reading a transcript. The same vitals feed Prometheus counters (runs by outcome, tool calls by ok/failed, corrections by kind), so trajectory quality is a dashboard, not an anecdote.
What owning the loop costs
The honest ledger, because “just write the loop yourself” glosses over what you’re signing up for.
The invariants are yours now. Providers require every assistant tool-call to be followed by its matching result — break the pairing and the API answers with a 400, not a type error. Every path that touches the buffer had to be designed around that: correctives land only at turn boundaries, minted ids keep call↔result pairing intact, and the fold contract exists so compaction can never cut between a call and its results. Nothing in the type system holds that line for you.
maxSteps is a blunt instrument. It counts laps, not progress — a methodical 25-step refactor and a spiral look identical to it. The breaker is the smarter signal layered on top, and the cap stays as the dumb, reliable backstop. I’d rather have a crude bound that always fires than a clever one I have to trust.
Per-step concurrency is a constant, not a solution. Four is a guess balancing fan-out against rate limits, caller-overridable — which is another way of saying the hard problem is deferred to whoever can see the whole system.
Streaming has a sharp edge. Streamed turns fold back into the same settled shape the rest of the step handles (fanning assistant_delta events while tokens flow), but only a failure before the first part falls back to generateText — after the first part, tool handlers may already have run, and a transparent replay would run them twice. So the fallback is for the rest of the run, never a per-turn retry.
Against all that: the alternative is a framework’s loop, where the same invariants still exist but live in someone else’s code, behind someone else’s extension points, on someone else’s release schedule.
The loop is the easy part — that’s the point
There’s a whole product category built on the premise that the agent loop is hard: graph DSLs, orchestration frameworks, platforms whose pitch is we’ll run the loop for you. After writing one, I think the premise is backwards. The loop was an afternoon. It’s ~540 lines, and the decisions are the product: where the exits live, what the events expose, what persists, what the model reads when it fumbles.
And here’s the twist paying off. The gates that govern this codebase ban try, let, and every loop statement — and the agent loop, the thing with “loop” in its name, didn’t get an exemption. It didn’t need one. Rewritten as a fold, it kept every exit, every breaker, every recovery path, and lost the mutable state that made the old version easy to get subtly wrong. A loop was never its keyword. It’s its exits, its breakers, and its manners.
So my advice runs opposite the category: buy the step, own the loop. Let a library do the genuinely fiddly per-step work, and write the iteration yourself, with the exits your product needs — even if your own linter won’t let you write while (true). Especially then.