Part III Chapter 3: Handing the Wheel to the Loop — Agent Loop

Chapter guide: You will write
runAgent()insrc/agent.ts: it holds message state, collects text and tool calls, executes tools, and feeds the results back into the next round. The key insight: the model can only propose tool requests. The right to execute and the judgment about stopping both belong to the loop.
The model was wired up. Xiao A wrote it a test: ask the model to "read config.ts and tell me what's in it". The model returned a paragraph of text. Xiao A stared at the log for a long time, then asked Lao Z, "It... just said it? It didn't actually read anything?"
"That's right. The model can only propose tool requests," Lao Z said. "runAgent() in src/agent.ts holds the message state: it collects text and ToolCalls in order, records the assistant message, then executes the tools and records the tool results. In this chapter we hand the 'next step' over to the loop."
user → ModelClient.stream → assistant(text/toolCalls)
→ approval → tool lookup → tool result → next round"Compress that flow into one sentence: the model talks, the host acts. What the model says is a tool call. What the host does is look up the tool, ask for approval, execute, record the result. There is a clear boundary between the two: the model never touches the toolsByName Map, and the host never invents a tool call on its own."
Xiao A: "What exactly does runAgent take in and give back? I want to draw it."
Lao Z flipped agent.ts to the signature. "Look at the real signature first. The input mixes 'configuration' with 'runtime'; the output contains only messages and a stop reason."
// examples/mini-agent/src/agent.ts, excerpt
export async function runAgent(options: {
client: ModelClient;
system: string;
tools: Tool[];
user: string;
maxTurns: number;
signal?: AbortSignal;
beforeToolCall?: BeforeToolCall;
sessionPath?: string;
contextMaxChars?: number;
}): Promise<RunResult>;
export type RunResult = {
messages: Message[];
stopReason: "complete" | "max_turns" | "aborted";
};"Notice a few things. client, tools, and beforeToolCall are all injected; the loop is not responsible for creating them, which is exactly what makes them easy to swap out in tests. signal, sessionPath, and contextMaxChars are optional: if you don't pass them, the loop still runs — only without cancellation, without persistence, without trimming," Lao Z said. "The return has two things and nothing else: messages (the complete record of facts after this run) and stopReason (one of three stop reasons). There is no 'output text' field. The final answer you want, you have to find in messages yourself. That pairs with the design in the CLI chapter, 'print the last assistant content'."
Xiao A: "Why not just return finalText? That saves the caller from digging through messages."
"Because the loop doesn't know which message counts as 'final'," Lao Z said. "Your definition of final is 'the last assistant content'. But another caller might want 'the last two', or 'all the tool results', or 'assistant and tool messages paired up'. Handing over the whole messages array leaves the definition with the caller. That is one more example of a library function not deciding for the application. If runAgent returned finalText, it would assume on every caller's behalf that 'final answer = last assistant text', and that assumption breaks at any moment in multi-turn, multi-tool scenarios."
"But main and runCli both take it that way, don't they?" asked Xiao A. "One takes the last message, the other prints them all."
"True, but those are decisions made at the entry-point layer," Lao Z said. "main.ts writes result.messages.filter(m => m.role === "assistant").at(-1)?.content ?? "" — it decides 'I want the last one' inside its own file. The day main wants 'the last three', the change lands in main, not in agent.ts. runCli is even broader: it writes every non-empty assistant message to write. Same RunResult, two entry points, two ways of reading it, and neither touched the loop."
Lao Z split the input parameters into a table of required and optional:
| Parameter | Required | Consequence of not passing it |
|---|---|---|
client / system / tools / user / maxTurns | yes | the loop cannot start without any one of them |
signal | no | no cancellation; the aborted path never triggers |
beforeToolCall | no | every tool is approved by default (see ?? { approved: true }) |
sessionPath | no | no persistence; it's gone when the process exits |
contextMaxChars | no | no trimming; a long conversation feeds the full messages straight through |
"Every 'consequence' cell in that table is a capability falling back, not a crash," Lao Z said. "Making them optional isn't laziness. These capabilities are orthogonal to each other: you can have cancellation without persistence, persistence without cancellation, trimming without approval. The loop splits orthogonal capabilities apart; the caller composes them as needed."
"Then when contextMaxChars is present, what extra does the loop do?" asked Xiao A. "Doesn't it just hand the messages to stream as they are?"
"Look at the stream call site in agent.ts: messages: options.contextMaxChars ? trimContext(messages, options.contextMaxChars) : messages," Lao Z said. "The trimming happens after recording and before sending to the model. The record of facts in memory is always complete; it only gets trimmed at the instant it enters stream(). That is to say, trimming is visible to the model and invisible to the session: a restored session still has the full history, only this round's request saved tokens. Those two don't conflict — they apply to different objects. The cost is that every request runs trimContext once, and the throughput bottleneck of this artifact isn't there either. The other constraint on contextMaxChars is that maxChars < 1 throws immediately, and a budget too small to hold the smallest self-contained unit also throws. A budget that can't trim without breaking things should raise an error rather than quietly send an empty context."
"And maxTurns? What's its relationship to the history restored from the session?" pressed Xiao A.
"maxTurns counts only this run's loop iterations, not the turns in the restored history," Lao Z said. "The restored history joins the context as the starting point of messages, but the counter in for (let turn = 0; turn < options.maxTurns; turn++) starts from 0. However many old turns were restored has no bearing on how many remain in this run. The history decides the starting point; maxTurns decides this run's ceiling. Both main and runCli pass 8 — that's the policy the entry points set. The loop hard-codes no turn count."
Record the message first, then continue
Xiao A: "Where do the messages start? Does the session file get loaded at the very beginning?"
"The load has to come before the cancellation check," Lao Z showed him the top of agent.ts:
const messages: Message[] = options.sessionPath ? await loadSession(options.sessionPath) : [];
if (aborted(options.signal)) {
return { messages, stopReason: "aborted" };
}
const record = async (message: Message) => {
messages.push(message);
if (options.sessionPath) await appendSession(options.sessionPath, message);
};
await record({ role: "user", content: options.user });"The load happens before the cancellation check, so a pre-abort can still return the restored history. The check happens before the first record() of the new user message, so it never pollutes the session file," Lao Z said. "record updates memory first and appends to disk afterwards. That means when the append fails, memory and file may briefly disagree; the current implementation lets the exception propagate and offers no compensation or retry."
Xiao A: "Has a normal session round-trip been tested?"
"A normal session round-trip is covered by a regression test that calls runAgent({ sessionPath }) directly; neither main.ts nor runCli() currently wires up sessionPath."
"Why doesn't the entry point wire it up?" asked Xiao A. "Isn't persistence already there?"
"It is, but whether to wire it up is the entry point's call," Lao Z said. "main serves one-shot scripts: run, print, exit — a session file means nothing to it. runCli serves interactive use, where ideally it should connect to persistence, but right now it doesn't, and we're not going to pretend it does. That sessionPath shows up repeatedly in the tests is exactly the proof that this path through the loop works and is testable; the entry point not connecting is a matter of scope. The day some entry point really needs 'resume after interruption', adding one line with sessionPath at the wiring site is enough. That item in the Part III Chapter 12 checklist — 'main / runCli not yet wired to sessionPath' — is about precisely this."
"Then does the history loadSession restores get trimmed away by trimContext?" asked Xiao A again.
"It does, and deliberately," Lao Z said. "The restored history enters messages together with this run's messages, and before the request the whole thing goes through trimContext. The budget comes first; however old the history, the budget rules. Persistence governs the disk and guarantees the history isn't lost; trimming governs the window and guarantees the model isn't overloaded. The two sides are joined by one and the same messages array."
How one round collects events
Xiao A leaned in: "How exactly are the events collected?"
let text = "";
const calls: ToolCall[] = [];
for await (const event of options.client.stream(request)) {
if (event.type === "text") text += event.text;
if (event.type === "tool_call") calls.push(event.call);
}
if (text === "" && calls.length === 0) throw new Error("model completed without text or tool calls");"The string accumulates locally within the round, the tool requests keep their id and input, and the assistant message is committed only after the stream ends," Lao Z said. "The empty-response check runs before the commit, so the session never receives an assistant message that looks normal but is empty."
"What if stream() throws in the middle of the stream?" asked Xiao A. "Where do the text and calls that haven't been committed go?"
"They're discarded," Lao Z said. "The try/catch in agent.ts wraps the whole for-await, and the catch first checks whether the signal is already cancelled: if it is, we return through the aborted path; if not, we throw error right away and hand the original exception to the caller. The local variables text and calls vanish with their scope at that moment — not written to disk, not returned. They are this round's tentative results, not facts of the conversation; a fact counts only after it's committed. Dropping a half-finished stream on cancellation rests on the same rule: what isn't committed doesn't exist."
Xiao A: "What about the done event? ModelEvent has done, but this loop doesn't seem to handle it?"
"It doesn't; done is a no-op for the loop," Lao Z said. "What for await iterates over is an AsyncIterable<ModelEvent>, and done is just an ordinary element in the stream. The loop sees it and neither accumulates anything nor throws; what actually means 'the stream ended' is the for-await running to completion naturally. done is transparent to the loop and meaningful only to toModelEvents: it guarantees the protocol mapping finished with no half-open block earlier. That event exists for the Provider mapping layer to close out. The loop only cares about what it collected — each looks at its own side."
Tool dispatch and failure
Xiao A kept reading downward: "How do tools get executed? What happens when one fails?"
"The normal path: approval allows it → find the tool by name → execute → record the tool result → next round. The refusal path writes the reason; an unknown name writes unknown tool; execute itself returns an error result," Lao Z laid out a table:
| Stop / failure | Who decides | Result |
|---|---|---|
complete | no tool calls | return the messages |
max_turns | the outer for | no further request to the model |
aborted | external signal and checkpoints | the next call is not started; an already-started tool gets its result recorded first |
approval / execute throws (not cancelled) | the hook or the tool | close the current and unexecuted calls first, then rethrow the original error |
| empty response | the Agent's own validation | throw an exception |
| persistence failure | the filesystem | throw an exception |
Xiao A looked at the "aborted" row: "Are tools executed sequentially?"
"Sequentially, yes. But that only lowers the concurrency complexity inside this loop; it guarantees nothing about external processes or actions spanning files," Lao Z said. "All calls are awaited in array order for now: writes to the same file are not interleaved by this loop, and independent reads therefore can't run in parallel either."
"Why not in parallel? If the model asks for three files at once, isn't parallel reading faster?" asked Xiao A.
"Faster, but the ordering guarantee is gone," Lao Z said. "Switch to Promise.all and run tools in parallel, and you take on three new classes of trouble: races on writes to the same file, the uncertainty of 'who failed first' on a failure, and the exact bookkeeping at cancellation of 'which ones had started and which hadn't'. Sequential execution flattens all of these: call 1's result is recorded before call 2 begins, so at any instant there is only 'one tool running' and 'one unambiguous results ledger'. What this chapter buys with throughput is determinism: parallelism isn't impossible — it's that it would make the failure semantics untestable."
"And if the model makes up a tool name that doesn't exist?" pressed Xiao A. "What does the loop do with it?"
"It records unknown tool: ${call.name}, then continue — no throw, on to the next round," Lao Z said, pointing at that row in the table. "The model reads 'unknown tool: does_not_exist', and the normal reaction is to realize it used the wrong name and switch to a real one next round. agent-branches.test.ts has a dedicated case that feeds a nonexistent tool name and asserts the message carries the full call id. This is the same line of thinking as turning a tool failure into a message: hand the model's cognitive error back to the model to fix itself."
Four cancellation checkpoints
One thing was on Xiao A's mind: "How is cancellation handled? I don't want a run to be cut off halfway by the user and leave the state a mess."
"Cancellation is a cooperative state transition, not thread preemption," Lao Z counted out four checkpoints and marked where each occurs:
| Checkpoint | Where it happens | Committed state so far | Cancellation result |
|---|---|---|---|
| before the first record | after session load | only the restored history | no new user message; return aborted |
| before the assistant commit | after the stream ends | this round's events are still local variables | discard the partial round; return aborted |
| before the tool starts | after the approval await | assistant already contains toolCalls | fill in cancellation results for the current and remaining calls |
| after the tool returns | the real result already recorded | started side effects and results are kept | fill in cancellation results for the remaining calls; return aborted |
"Those four points respectively prevent 'cancelling the task but writing a new user message', 'passing a partial stream off as a complete answer', 'starting the tool anyway after cancellation during approval', and 'reporting max turns when the tool actually finished'," Lao Z said. "The regression tests manufacture the races with deterministic fakes; no real network is needed."
"Can you be more concrete about the specific mistake each checkpoint guards against?" asked Xiao A.
"Take the second one," Lao Z said. "The case in cancellation.test.ts named 'abort after a non-cooperative stream completes prevents assistant commit': the fake emits one sentence, 'partial', then aborts immediately, and the stream finishes normally. If the cancellation check sat only at the top of the loop, the loop would think this round succeeded and commit 'partial' as a complete answer. The second checkpoint blocks exactly that: the moment the stream ends it looks at the signal, and if it's aborted it returns — that 'partial' never enters the session. One checkpoint blocks one specific bad outcome. Only all four together cover all four arrangements of 'which instant cancellation lands in'."
Why cancelled tool results must be filled in
Xiao A frowned: "The tool never even ran. Why write anything into the session at all?"
"Because once the assistant message is committed, every ToolCall.id in it is a fact of the conversation, and returning directly would leave calls with no result," Lao Z typed out recordAbortedCalls():
// examples/mini-agent/src/agent.ts, excerpt
for (const call of calls.slice(startIndex)) {
await record({
role: "tool",
toolCallId: call.id,
content: "aborted before execution",
isError: true,
});
}"If the first tool already finished, startIndex begins at the next one: the real result is kept, and only the remaining entries are marked cancelled. When there is no cancellation but approval or execute throws an exception, the same closing procedure runs: the current call records a generic failure without exception details, and the following calls are recorded as 'not executed after an earlier failure', while the original exception keeps propagating to the caller. That way, when you restore the session, every declared call has a diagnosable closed state."
Closing on failure: recordFailedCallAndSkippedCalls
Xiao A set cancellation aside: "What if the tool throws an exception during execution? That one is a real failure."
"Then it goes through the other closing function," Lao Z opened agent.ts:
// examples/mini-agent/src/agent.ts, excerpt
const recordFailedCallAndSkippedCalls = async (
calls: ToolCall[],
failedCallIndex: number,
failureContent: string,
): Promise<void> => {
const failedCall = calls[failedCallIndex];
if (!failedCall) return;
await record({
role: "tool",
toolCallId: failedCall.id,
content: failureContent,
isError: true,
});
for (const call of calls.slice(failedCallIndex + 1)) {
await record({
role: "tool",
toolCallId: call.id,
content: "not executed after an earlier failure",
isError: true,
});
}
};"Look at the wording: the failed one records failureContent (for instance “tool execution failed”), and the rest record “not executed after an earlier failure” — not aborted. Cancellation and failure are two different things: cancellation is you deliberately calling a halt; failure is something going wrong without any cancellation. When you restore the session, you have to be able to tell from the record whether this call didn't work out or never got its turn."
"Where does the original exception go?" asked Xiao A.
"recordFailedCallAndSkippedCalls writes only the generic failure text “tool execution failed” into the session, with no stack trace, and then throw error hands the original exception back to the caller," Lao Z said. "The session keeps a diagnosable closed state; the caller holds the complete original error. Neither side is missing anything, and the two don't contaminate each other."
A started tool cannot be rolled back
Xiao A thought for a moment: "What if cancellation arrives while a tool is already running?"
Lao Z didn't answer directly. He had him read the code first:
// examples/mini-agent/src/agent.ts, excerpt
result = await tool.execute(call.input, options.signal);
await record({
role: "tool",
toolCallId: call.id,
content: result.content,
isError: result.isError ?? false,
});
if (aborted(options.signal)) {
await recordAbortedCalls(calls, callIndex + 1);
return { messages, stopReason: "aborted" };
}"The signal is passed to the tool, but the tool may ignore it. Once a tool has started, the loop can only wait for the Promise to resolve or reject. File writes, network requests, or process side effects that already happened — AbortSignal cannot roll them back. If the tool returns, record the real result first, then check for cancellation."
Xiao A: "Why not just return immediately when the signal fires?"
"Because that would disguise side effects still running in the background as 'already stopped'," Lao Z said. "Waiting for the tool to hand control back may be slower, but the message record stays consistent with the real world. If a custom tool throws and the signal is already cancelled, the loop follows the existing aborted semantics: record a diagnostic result for the cancellation, fill in the remaining calls, and return aborted. If it isn't cancelled, first close the current and remaining calls with a generic isError: true result, then rethrow the original exception. That way the session never stores potentially sensitive exception text, and the caller can still diagnose the original error."
"Doesn't 'a started tool cannot be rolled back' make users feel it's unsafe?" asked Xiao A.
"It doesn't, provided you say so plainly," Lao Z said. "The case in the tests named 'abort during a started tool preserves its real result' is the evidence for exactly this semantics: the tool is aborting mid-execution, the runner still returns 'real result', the loop records it, and stopReason is aborted. Note that there is no rollback here — a file that got written got written; the result stays. Cancellation is not time travel. What the user wants is: after cancellation, which side effects already happened and which didn't, written out clearly in the messages."
"The three stop reasons — how do they finally become exit codes?" asked Xiao A.
"In cli.ts, exitCodeForStopReason is a plain switch: complete is 0, max_turns is 2, aborted is 130," Lao Z said. "cli-exit.test.ts pins the mapping with one assertion per line, and both main and runCli finish through it. The exit code is the only interface between the loop and the operating system: it carries no error text, only a state category. That is how the independence stated as 'the loop doesn't know what the terminal looks like' is preserved."
Summary
The loop's most important design decision is to split "the model decides" from "the host executes" into two halves. The model produces only the intent of a tool call — a request with a name, arguments, and a call ID. Whether that request executes, how the result gets written back, and when one turn counts as finished are all decided by the host program. The cancellation signal is checked at four checkpoints: before the stream starts, while the stream is in progress, before a tool executes, and after a tool executes. There are three stop reasons: complete means the model stopped calling tools and ended naturally, max_turns means the turn budget ran out, and aborted means an external signal stopped it; each maps to a different exit code. Cancelled and failed calls are not silently dropped — recordAbortedCalls and recordFailedCallAndSkippedCalls write them into the messages explicitly, so on session reload every call has a matching result.
The trap easiest to fall into here is treating "cancellation" as "undo". AbortSignal is cooperative: it requests a stop, but it cannot force-terminate a tool that ignores it, and it certainly cannot roll back side effects that already happened. A file already written and a network request already sent do not disappear because of a signal. The loop also has no concurrency control, no retry mechanism, and no side-effect deduplication — all of these are deliberately left out.
The other invisible design is "what isn't committed doesn't exist". Before the stream ends, text and calls are only local variables; on cancellation or a throw they vanish with their scope, and only a commit into an assistant message makes them facts of the conversation. This principle runs through the entire loop: there is a clear commit line between tentative results and the record of facts, and every cancellation check sits right up against one side or the other of it, so a half-finished product is never written into the session as a complete answer. Its companion is "a started tool cannot be rolled back": once a tool is running, the loop is responsible for waiting and recording, not for taking things back. Together those two are the loop's posture toward the real world: partial never gets written out as complete, and a tool that ran never gets pretended away.
The loop compresses stop reasons into three states. complete, max_turns, and aborted each map to a different exit code, and the caller decides what to do based on that: a script reads the exit code, an interactive entry point reads the message content, a test asserts on stopReason. The loop makes no judgment about whether the result is good; it only guarantees the result is classifiable and explainable. What earlier chapters kept repeating — "the host owns the stopping power" — lands in code as these three states and that one for turn ceiling: the model may keep proposing requests forever, but when it stops is always decided by the loop's return.
Stage Acceptance
npm test includes independent regressions: pre-abort does not modify the session, a race at stream completion does not commit the assistant, an approval race does not execute the tool, a race at tool return preserves the real result, and multiple calls get associated cancellation results filled in. It also covers: after a tool or approval hook throws, every call in the session reload has a result; later calls are correctly associated; sensitive exception text is not written into the session; and the caller still receives the original error.
