02. A Minimal Agent Loop
Once you understand the tool protocol, the heart of an agent is the loop. It is not a while true that lets the model keep talking; it is a set of state transitions driven by the signals the model returns. The model says it needs a tool, so the runtime executes the tool; the model says it is done, so the runtime finishes; the model runs over length or gets aborted, so the runtime records the state and lets the layer above decide how to handle it.
Failure mode: treating tool calls as ordinary text
The most common mistake is handling only the assistant text and ignoring the tool calls. The model earnestly says "I need to read the file," and the system does nothing. The second mistake is executing one tool and then stopping, forgetting to send the result back to the model. The model then never gets a chance to reason further based on what it observed. The third mistake is throwing an exception and exiting when a tool fails, so the model can never correct itself.
The agent loop's job is to pin down these boundaries:
- A model response is one turn.
- A turn can contain zero or more tool calls.
- Every executed tool call must produce a tool result message.
- Tool results go into the next round of context.
- The task ends only when there are no more tool calls and no queued input.
Note the wording of that last point. Teaching implementations often write it as "end when the stop reason is not toolUse," but the basis a mature system uses is "does this assistant message contain a tool call." The two are equivalent most of the time, but the model will occasionally return stopReason: "stop" while still carrying a tool call. A loop that goes by content executes those tools as usual; a loop that goes by stop reason silently drops them and leaves behind an illegal context of "a tool call with no tool result," which the provider rejects outright on the next request. The defensive principle: use the stop reason to explain state, and let the presence or absence of tool calls decide control flow.
The minimal structure
First define the model boundary and the tool boundary:
type Message = UserMessage | AssistantMessage | ToolResultMessage;
type ModelClient = {
complete(input: { messages: Message[]; tools: ToolDefinition[] }): Promise<AssistantMessage>;
};
type ToolDefinition = {
name: string;
description: string;
parameters: unknown;
};
type ToolRuntime = {
definition: ToolDefinition;
execute(input: unknown, signal: AbortSignal): Promise<ToolResultMessage>;
};
Then implement the loop. The teaching version can execute tools serially for now; parallelism and the file-write queue come later:
async function runAgent(input: {
model: ModelClient;
tools: ToolRuntime[];
messages: Message[];
signal: AbortSignal;
maxTurns: number;
}): Promise<Message[]> {
const messages = [...input.messages];
const toolsByName = new Map(input.tools.map((tool) => [tool.definition.name, tool]));
for (let turn = 0; turn < input.maxTurns; turn += 1) {
const assistant = await input.model.complete({
messages,
tools: input.tools.map((tool) => tool.definition),
});
messages.push(assistant);
const toolCalls = assistant.content.filter(
(block): block is ToolCallBlock => block.type === "toolCall",
);
if (toolCalls.length === 0) {
return messages;
}
for (const block of toolCalls) {
const tool = toolsByName.get(block.name);
if (!tool) {
messages.push({
role: "toolResult",
toolCallId: block.id,
toolName: block.name,
isError: true,
content: [{ type: "text", text: `Unknown tool: ${block.name}` }],
});
continue;
}
const result = await tool.execute(block.input, input.signal);
messages.push({ ...result, toolCallId: block.id, toolName: block.name });
}
}
throw new Error(`Agent exceeded ${input.maxTurns} turns`);
}
The point of this code is not completeness but control flow: the assistant message enters history first, then the tools execute, then the tool results enter history, then control returns to the model. This order must not be shuffled. Otherwise the session log, UI events, restoration, and debugging all lose their single shared basis.
The rule is easier to see when you land it on the message array. Suppose an assistant turn contains two tool calls; the history array must look like this for the next request to be valid:
[
...,
assistant { content: [toolCall A, toolCall B] },
toolResult { toolCallId: A },
toolResult { toolCallId: B }
]
The two tool results come right after that assistant message, in the same order the tool calls were declared in its content. You cannot put B's result before A's, and you cannot insert a new assistant or user message between the two results. Chapter 6 shows a key distinction when it covers parallel tools: tools may execute concurrently and update the UI in completion order, but the order in which results are written back to the history stays locked to declaration order — the UI sees the real completion timing, while the model sees a reproducible, stable order.
Every failure drains into the same exit
Tool execution has five typical failure paths: the tool name does not exist, argument validation fails, an exception is thrown during execution, a permission hook blocks it, or an abort is received mid-execution. A mature system routes all five paths to the same exit — constructing a tool result with isError: true. This is not code fastidiousness; it is a protocol requirement. As the previous chapter said, every tool call must have a corresponding result message. Any failure path that "escapes" as an uncaught exception leaves behind a tool call with no result and poisons the entire session.
The test for whether an error should be fed back or should terminate is: can the model fix this error with more reasoning? A wrong path, a missing argument field, an oldText that does not match — the model can usually correct these once they are fed back. A permission denial, a user cancellation, a provider authentication failure — feeding these back only makes the model retry endlessly. The former go into the tool result; the latter go into the log and stop or wait for the user.
Feedback on a tool failure must be actionable. Suppose the model writes a wrong path when calling read_file; the runtime should return:
toolResult(read_file, isError=true):
File not found: src/maths.ts. Did you mean src/math.ts?
On the next turn, the model will most likely switch to the correct path. This closed loop is one of the differences between an agent and an ordinary script: a script exits on error, while an agent puts correctable information back into the context.
The right way to abort
When the user presses stop, the AbortSignal is passed into the provider request and into any tool currently executing. There is a detail here that is easy to get wrong: aborting does not mean returning immediately. A tool that has already started executing needs to run through its cleanup logic (kill child processes, close file handles), and each such tool should produce an "Operation aborted" error tool result; tool calls that have not yet started need not run, but if this assistant message is going to stay in the session for continued use, you must fill in placeholder results for them when projecting the context.
A better approach is to turn the abort into an assistant message with stopReason: "aborted" (or mark it on the existing partial message), write it to the log, and let the UI know that this turn was interrupted by the user rather than a model failure. If the exception runs bare all the way up to the top-level loop, there is no way to explain "where it left off last time" when the session is restored.
The boundary between a turn and a run
In the teaching implementation the loop is the whole thing, but it is worth establishing a pair of concepts up front: a turn is "one model response plus the batch of tool executions it triggers"; a run is "the sequence of turns from a user input until there are no more tool calls and no queued input." This boundary becomes important later — the UI refreshes at the end of each turn, interjections are injected at turn boundaries, compaction splits at turn boundaries, and statistics are aggregated by run. For now, just keep the turn boundary clear in the code; chapters 6 and 7 will use it.
The step limit and termination
The minimal loop must have maxTurns. An agent without an upper bound can run forever due to tool failures, model repetition, missing compaction, or a misleading prompt. It is worth noting that mature systems often do not use a hard-coded limit; instead they use a pluggable "should continue" hook (for example, asking the layer above whether to stop after each turn), plus thorough observability so the user can see what the agent is doing at any moment and abort at any moment. But that is a choice you make after the event stream, log, and UI are all in place. Until then, maxTurns is the cheapest safety valve, and the product layer can render "limit exceeded" as a state the user can understand: the current task did not converge, this was the last action taken, and here is how you might continue.
Checkpoint
After finishing this chapter, you should have an agent that can run through this script:
user: List the files in the workspace
assistant: toolCall list_files {}
toolResult: README.md, src/index.ts
assistant: The workspace contains README.md and src/index.ts.
Acceptance criteria:
- The assistant's tool calls are executed, even when the stop reason is not
toolUse. - Tool results are included in the next model request.
- Unknown tools, argument validation failures, and tool exceptions all produce error tool results, and the process does not crash.
- After an abort, every tool call that has started has a result message, and the session state is explainable.
- Exceeding the turn limit produces a clear error.
- All messages are preserved in the order they occurred.