The Agent Loop
The three tools from the last chapter can run a command, read a file, change a file — but nothing calls them. They sit there, a set of capabilities with no one to use them. What turns a text model into something that acts is a loop, and it is a simple one: show the model the conversation and the tools it may use, let it reply, and if its reply asks to call a tool, run that tool and hand the result back — then ask again. Repeat until the model answers with no tool call, which is how it says the task is done.
That cycle — propose, run, feed back, repeat — is the whole engine of an agent. Everything later in this book makes it durable, safe, and cheaper to run, but none of it changes this shape. The model never touches the filesystem itself; it can only say which tool it wants, and the loop is what actually runs it and reports back. This chapter builds that loop and proves the one property that keeps it from spinning: it stops when the model stops asking.
2.1 One turn
Start with a single pass — one message to the model and everything that follows from its reply.
async function bufferedTurn(
provider: Provider,
messages: Message[],
options: LoopOptions,
maxTokens: number,
context: ToolContext,
tools: ToolDef[],
emit: (event: LoopEvent) => void,
): Promise<boolean> {
const session = options.session ?? null;
const { message, usage } = await provider.chat(messages, tools, maxTokens);
messages.push(message);
recordAssistant(session, message);
session?.append("usage", usage);
emit({ kind: "usage", usage });
if (message.content) emit({ kind: "assistant", content: message.content });
if (!message.toolCalls?.length) return true;
for (const call of message.toolCalls) {
emit({ kind: "tool-call", name: call.name, arguments: call.arguments });
const result = await gatedDispatch(call, context, options, session, tools);
emit({ kind: "tool-result", name: call.name, result });
const suspicion = detectInjection(result.output);
if (suspicion.suspicious) {
emit({ kind: "injection-flagged", name: call.name, markers: suspicion.markers });
}
messages.push({
role: "tool",
toolCallId: call.id,
content: frameToolResult(call.name, result.output, suspicion),
});
session?.append("tool/result", {
callId: call.id,
name: call.name,
output: result.output,
error: result.error,
});
}
return false;
}Listing 2.1 · one turn: ask the model, and if it called no tools, stop; otherwise run each call and append its result.
provider.chat sends the running list of messages and the available tools to the model and returns its reply — a message that carries the model’s text and, optionally, a list of toolCalls. The turn appends that reply to the conversation, then makes the one decision the loop turns on: if (!message.toolCalls?.length) return true. A reply with no tool calls is the model saying it is finished, and returning true reports the turn as done. Anything else means the model wants to act.
When it does, the turn runs each call and feeds the result back. gatedDispatch executes the tool — the policy check it passes through, and the frameToolResult that wraps the output as untrusted text, are the subjects of later chapters; here they run and return a ToolResult. The result becomes a new message with role: "tool", tagged with the call’s id so the model can match it to what it asked for, and pushed onto the conversation. Then the turn returns false: not done, because the model has seen a tool result and will want to respond to it. The loop that calls this turn again is next.
2.2 The loop, and its two limits
The loop wraps that turn in a counter and a stop.
const provider = options.provider ?? new OpenAIProvider(options.baseUrl, options.model);
const streaming = options.stream ?? true;
for (let turn = 0; turn < maxTurns; turn += 1) {
if (options.compactBudget !== undefined && needsCompaction(messages, options.compactBudget)) {
messages = await compact(messages, provider, session, options, maxTokens, emit);
}
if (session !== null) assertReconstruction(session.events, messages);
const finished = streaming
? await streamedTurn(provider, messages, options, maxTokens, context, tools, emit)
: await bufferedTurn(provider, messages, options, maxTokens, context, tools, emit);
if (finished) {
await session?.flush();
for (const connection of mcp) connection.close();
return messages;
}
}
await session?.flush();
for (const connection of mcp) connection.close();
return messages;
}Listing 2.2 · repeat the turn until one reports done, or until maxTurns turns have run.
The provider is the model behind the loop, and it is a seam: the loop takes whatever options.provider supplies and only otherwise builds the default one, which is what lets a test drive the loop with a scripted model instead of a live one. Then the for loop runs turns. Each iteration calls a turn — bufferedTurn above, or the streaming variant that Chapter 4 builds — and the instant one returns true, the loop returns the finished conversation. The two lines above the turn, the compaction check and assertReconstruction, belong to the session and history chapters; skip them on this read.
Two things can end the loop, and the difference matters. The first is the model deciding it is done — a turn returns true, and the loop returns mid-way through its budget. The second is maxTurns: if the model never stops calling tools, the for loop still ends after a fixed number of iterations. That bound is not a nicety; it is the only thing standing between a confused model and an agent that calls tools forever. A correct loop almost always exits by the first path; the second is the backstop.
2.3 Correct: it acts, then it stops
The loop is right when it does two things: it actually runs the tool the model asks for and feeds the result back, and it stops the moment the model stops asking. Neither needs a live model to check — a real model would make the test slow, non-deterministic, and dependent on a key. Instead the driver supplies a scripted model: on its first turn it returns a call to bash, and on its second, having been handed the result, it returns a plain reply with no tool call. The driver runs the real loop against that script in a throwaway workspace and checks what came out:
$ bun agentloop_check.ts
agent loop 4/4 turns 2 PARITY OK
Four checks pass in two turns. The loop proposed the model’s bash call, ran it and fed the output back into the conversation as a tool message, stopped on the second turn the moment the scripted model returned no call — not by running out its budget — and ended on a clean assistant reply. The turns 2 line is the one to watch: it is the count of times the model was asked, and a correct loop asks exactly as many times as the model keeps working, then stops.
The loop runs, but it runs blind: nothing shows what the model spent, what it did, or lets you stop it before it burns a budget you did not mean to give it. Before the agent grows a durable session and a trust boundary, it needs a way to watch and bound a single run from the command line. That is the next chapter.