Part II Chapter 4: The Double Loop — pi-agent-core

Chapter guide: Stop ③ on the source route (core layer). We switch from
pi-aito thepi-agent-corepackage and readagent.tsandagent-loop.ts. What this stop has to show is the loop in its true form: how model output and tool results push and pull each other to move the state forward one round at a time, and what the easily misread "inner and outer loops" actually are.
Having finished the stream adapter layer, Xiao A told Lao Z, full of confidence, "I get it. The model streams in, and the loop just turns there: the outer loop manages model turns, the inner loop runs tools in parallel. Right?"
Lao Z did not answer directly. "How many unexamined assumptions are in that sentence of yours?"
Xiao A froze.
"The stream adapter layer only produces events," Lao Z said. "What turns an event into an assistant message in the context, turns a tool call into a tool result, and then decides whether to send another request, is the loop in pi-agent-core. Your guess — 'outer loop manages model turns, inner loop runs tools in parallel' — is exactly the misreading this stop takes apart. The answer is no. You have to look at it through the state variables and the branches."
This chapter is based on the upstream pi tag v1.0.0 (commit a13d35a742c6ef8462812a28fbe1d8c8b7431c32) and @earendil-works/pi-agent-core 1.0.0. It covers runAgentLoop, runLoop, streamAssistantResponse, executeToolCalls, and runToolCall. It does not discuss session persistence, compaction, or Skills — as of v1.0.0, those no longer belong to this package.
Agent is a host wrapper with queues, not the loop itself
Xiao A was not ready to give up. "Then the Agent class must be the loop."
"It isn't," Lao Z said. "Agent in packages/agent/src/agent.ts holds the transcript state, the listener set, the steering queue, the follow-up queue, and the current AbortController. Its prompt() eventually calls runAgentLoop. continue() calls runAgentLoopContinue when the last message is not an assistant message; when it is an assistant message, continue() first takes one steering message, falls back to follow-up if there is none, and starts a new run with whatever it got. It throws only when both queues are empty."
// packages/agent/src/agent.ts, Agent's queue interface (v1.0.0, excerpt)
/** Queue a message to be injected after the current assistant turn finishes. */
steer(message: AgentMessage): void {
this.steeringQueue.enqueue(message);
}
/** Queue a message to run only after the agent would otherwise stop. */
followUp(message: AgentMessage): void {
this.followUpQueue.enqueue(message);
}"Note that these two queues fire at different moments. Don't merge them just because both hold 'user messages'," Lao Z said. "PendingMessageQueue.drain() is also governed by QueueMode: all takes everything, otherwise it takes one message; the default is one-at-a-time. drain() first uses peek() to choose which messages to take and only then removes them, so a host can also preview what the next round will inject via peekQueuedMessages() without consuming the queue. If you call prompt() while a run is already active, the source throws outright and tells you to use steer() or followUp() — that keeps two loops from mutating the same state concurrently."
| API / state | Confirmed role | What you must not infer from it |
|---|---|---|
prompt | Adds a user message and starts a run | That you can start several runs in parallel |
continue | Continues from a user / tool-result ending; on an assistant ending it only consumes queued steering / follow-up | That an assistant ending can also continue unconditionally |
steer | Injects after the current assistant turn, before the next request | That it interrupts the tokens already coming in |
followUp | Injects only when the Agent would otherwise stop | That it gets inserted on every turn |
abort | Aborts the current controller | That external side effects already executed are undone automatically |
subscribe | Awaits listeners in order, passing the signal | That listeners are a persistence mechanism |
"The comment on subscribe adds that agent_end is the last event, but the Agent only truly goes idle after every listener for that event has settled. That waiting relationship is part of observing consistency; it is not the same as the session being written to disk. pi-agent-core in v1.0.0 does not handle writing to disk at all — that is the host's job, for instance the coding-agent's AgentSession."
One more change to keep in mind first: in v1.0.0, AgentContext has only two fields left, messages and tools. There is no systemPrompt. The system prompt and the tool declarations visible to the model both live in a role: "system" message inside the transcript. When constructing Agent, pi-ai's createInitialSystemMessage turns the initial systemPrompt and tools into the first system message; state.systemPrompt is only a getter replayed out of the transcript.
runAgentLoop first establishes this run's increment
Xiao A wanted to go straight to the loop. "What's the first thing the loop function does on entry?"
"It establishes the increment for this run," Lao Z said, opening agent-loop.ts:
The low-level runAgentLoop takes prompts, the existing context, config, an event sink, a signal, and a stream function. It first processes the prompts with declareToolChanges, builds this run's newMessages from the result, constructs currentContext from the historical messages plus this batch, emits agent_start, the first turn_start, and a start/end event for each message, and then enters runLoop.
// packages/agent/src/agent-loop.ts, runAgentLoop (v1.0.0, excerpt)
const initialMessages = declareToolChanges(context, prompts);
const newMessages: AgentMessage[] = [...initialMessages];
const currentContext: AgentContext = {
...context,
messages: [...context.messages, ...initialMessages],
};
await emit({ type: "agent_start" });
await emit({ type: "turn_start" });
for (const message of initialMessages) {
await emit({ type: "message_start", message });
await emit({ type: "message_end", message });
}
await runLoop(currentContext, newMessages, config, signal, emit, streamFn ?? getDefaultStreamFn());"There are two lists here. Don't mix them up," Lao Z stressed. "currentContext.messages is the complete working context visible to the next model call. newMessages is the increment added by this run, returned at the end alongside agent_end. Confusing the two is why people explain badly why the prompts are both emitted and fed into the model context."
Xiao A pointed at the first line. "What does declareToolChanges do?"
"It compares two tool lists," Lao Z said. "context.tools are the tools the runtime can actually execute. The system message replayed out of the transcript says which tools the model believes it can call. When the two disagree, it inserts a system message before the messages waiting to be injected, declaring the difference with toolsAdded / toolsRemoved; if the waiting messages already contain a system message, it rewrites that one's tool fields instead. This way, replaying the transcript always yields a tool set equal to context.tools. The cost is that tool changes stay in the history as messages, rather than silently changing a field."
"runAgentLoopContinue's boundaries are equally explicit: an empty context throws; a last message that is an assistant message also throws directly. The queue-draining logic lives in the previous section's Agent.continue(), not in the low-level function. That keeps the loop from appending an assistant answer straight onto the next provider request; only the real convertToLlm, called on every turn, can verify the message form the provider accepts."
What the outer and inner loops each solve
Xiao A circled the two while statements. "What does each one control?"
"In the fixed snapshot, runLoop reads getSteeringMessages once before entering the outer loop. The comment on the outer while (true) is 'queued follow-up messages arrive after agent would stop'; the comment on the inner while (hasMoreToolCalls || pendingMessages.length > 0) is 'process tool calls and steering messages'," Lao Z said. "So the inner loop is not the parallel-tool loop you had in mind. Parallelism happens only later, inside the tool execution helpers."
// packages/agent/src/agent-loop.ts, runLoop (v1.0.0, excerpt)
while (true) {
let hasMoreToolCalls = true;
while (hasMoreToolCalls || pendingMessages.length > 0) {
// Not the first round: prepareNextTurn, then take steering once more, emit turn_start
// Inject the prepared and pending messages, then prepareRequest
const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFunction);
// Tool results are added back to currentContext.messages and newMessages
const decision = await config.finishTurn?.(lastCompletedTurn, signal);
await emit({ type: "turn_end", message, toolResults });
if (decision?.action === "end") { await emit({ type: "agent_end", messages: newMessages }); return; }
pendingMessages = (await config.getSteeringMessages?.()) || [];
}
const followUpMessages = (await config.getFollowUpMessages?.()) || [];
if (followUpMessages.length > 0) { pendingMessages = followUpMessages; continue; }
if (explicitContinuation) { explicitContinuation = false; continue; }
break;
}The state sequence for one ordinary tool round is as follows:
initial prompts → emit start/end → prepareRequest → assistant stream
→ start/delta update the partial assistant → done yields the final assistant
→ find toolCall inside content
→ execute / reject each tool call, producing ToolResultMessage
→ append the tool results to currentContext and newMessages
→ finishTurn gives its decision (end / continue / no opinion)
→ emit turn_end; if the decision is end, emit agent_end and return
→ take steering; with tool results or steering present the inner loop runs another round:
prepareNextTurn may replace context, model, thinking, or append messages
→ emit turn_start → inject messages → prepareRequest → request the model
→ otherwise take follow-up; if any, go back into the inner loop
→ if neither exists but finishTurn asks to continue, request once more with the current context only
→ otherwise emit agent_end"When the assistant has no tool call, hasMoreToolCalls becomes false, and only steering can keep the inner loop going. A follow-up arriving after the assistant is read only when the inner loop is about to exit. The semantic difference between the two queues lands exactly on these two read positions. They are not one message-priority table."
"This version also adds three host hooks, each at a different position," Lao Z said. "finishTurn is called after the tool results are appended and before turn_end. Returning { action: "end" } ends the run without reading the queues; returning { action: "continue" } guarantees at least one more model request. prepareNextTurn is deferred until the next round begins; its comment says it may be slow — compaction, for example — so steering is taken once more after it returns. prepareRequest is called before every model request, including the first round, by which point the messages waiting to be injected have already been appended."
| Hook | When it is called | What it can change | What it does not do |
|---|---|---|---|
finishTurn | After tool results are appended, before turn_end | End the run, or demand one more request | Cannot rescue an error / aborted |
prepareNextTurn | Before the next round's turn_start | context, model, thinking, appended messages | Not called on the first round |
prepareRequest | Before every model request | context, model, thinking | Does not read queues, does not append messages |
Following streamAssistantResponse section by section
Xiao A kept pressing. "How does the context actually get into the model's hands?"
"Look at streamAssistantResponse. It is the boundary between AgentMessage[] and AI Message[]," Lao Z said. "Optionally transformContext first, then convertToLlm, and then only the converted messages go to normalizeContext to build the request context. The system prompt and the tool declarations are already inside the system message, so they are no longer passed separately. Then it refreshes a possibly expired API key and calls the injected streamFunction."
// packages/agent/src/agent-loop.ts, streamAssistantResponse (v1.0.0, excerpt)
let messages = context.messages;
if (config.transformContext) messages = await config.transformContext(messages, signal);
const llmMessages = await config.convertToLlm(messages);
const llmContext = normalizeContext({ messages: llmMessages });
const resolvedApiKey =
(config.getApiKey ? await config.getApiKey(config.model.provider) : undefined) || config.apiKey;
const response = await streamFunction(config.model, llmContext, { ...config, apiKey: resolvedApiKey, signal });
const result = async () => Object.assign(await response.result(), { thinkingLevel: config.reasoning ?? "off" });"normalizeContext returns the type TranscriptContext. pi-ai's comment says only this function can produce one, so a non-normalized Context cannot accidentally flow to a provider. The last line records this request's thinking level onto the final assistant message, no matter which stream function produced the answer."
Xiao A remembered a trap. "What if convertToLlm filters out the system message?"
"Then the model sees no system prompt and no tool declarations," Lao Z said. "Agent's default convertToLlm keeps four roles: system, user, assistant, toolResult. A host that writes its own conversion function has to remember to keep system."
"The payoff of this section is that the loop is not bound directly to ModelsImpl: a test or a host can inject the stream function, and context conversion can be recomputed on every turn. The cost is that the transform, the conversion, and the key resolver can all fail; this function does not catch them itself — the run and the Agent host that called it handle the errors."
"Now the core branch of event consumption. The excerpt keeps the state changes and drops the enumeration of all delta types."
// packages/agent/src/agent-loop.ts, streamAssistantResponse (v1.0.0, excerpt)
case "start":
partialMessage = event.partial;
context.messages.push(partialMessage);
addedPartial = true;
await emit({ type: "message_start", message: { ...partialMessage } });
break;
// text/thinking/toolcall delta: replace the last entry in context and emit message_update
case "done":
case "error": {
const finalMessage = await result();
if (addedPartial) context.messages[context.messages.length - 1] = finalMessage;
else context.messages.push(finalMessage);
await emit({ type: "message_end", message: finalMessage });
return finalMessage;
}"It puts the partial in first, then replaces the last entry with the final message, which guarantees that subsequent tool execution reads completed assistant content. Even if start never arrives, the done / error branch still appends the final message and emits a late message_start; if the iteration ends naturally, it still calls response.result(). That covers the different event shapes across adapters — you must not conclude from this that every adapter necessarily sends start first."
Stop reasons come before tool execution
Xiao A assumed again. "If the model says it wants to call a tool, it definitely gets executed?"
"Not necessarily." Lao Z went back to runLoop. "If the assistant's stopReason is error or aborted, the loop still calls finishTurn as usual but ignores the decision: it emits turn_end and agent_end and returns, without executing any tool. If the content has tool calls and the stop reason is length, none of them executes either — failToolCallsFromTruncatedMessage generates an error result for each call. Its comment says the parser may try its best to repair truncated JSON, but those calls still will not be executed."
| assistant state | Action when tool calls exist | Does the inner loop continue |
|---|---|---|
| normal, not length | executeToolCalls | Depends on whether every result is terminate: true, and on whether finishTurn ends the run |
length | An error result per call, "arguments may be truncated" | Yes, so the model re-issues a complete call |
error | No tools executed | No, agent_end immediately |
aborted | No tools executed | No, agent_end immediately |
| no tool call | No tool results | Only steering can continue |
"That gives at least two interruption / boundary paths: a model abort stops the loop; output-length truncation keeps the tool result evidence on record but refuses the side effect. Treating length as 'safe to continue with whatever arguments parsed' would contradict the source comment."
executeToolCalls decides sequential or parallel
Xiao A came back to his opening question. "So where exactly is the parallelism? At the start I thought the inner loop was the parallel-tool loop."
"Back to the code," Lao Z said, opening executeToolCalls:
// packages/agent/src/agent-loop.ts, executeToolCalls (v1.0.0, excerpt)
const toolCalls = assistantMessage.content.filter((c) => c.type === "toolCall");
const hasSequentialToolCall = toolCalls.some(
(tc) => currentContext.tools?.find((t) => t.name === tc.name)?.executionMode === "sequential",
);
if (config.toolExecution === "sequential" || hasSequentialToolCall) {
return executeToolCallsSequential(currentContext, assistantMessage, toolCalls, config, signal, emit);
}
return executeToolCallsParallel(currentContext, assistantMessage, toolCalls, config, signal, emit);"It first collects every toolCall in the assistant content, and the split between parallel and sequential is right here: if the global toolExecution is sequential, or any one of the found tools declares sequential, the whole batch takes the sequential path; otherwise it takes the parallel path."
"Both paths first emit tool_execution_start for each call and go through prepareToolCall. Preparation checks that the tool exists, preprocesses the arguments, runs validateToolArguments, invokes the optional beforeToolCall hook, and checks abort. Any exception, a missing tool, being blocked by the hook, or an abort becomes an immediate error result — it does not take down the whole batch."
"The parallel path first collects immediate results or async thunks in the order they appear in the assistant message, then runs the prepared thunks concurrently with Promise.all; before each thunk starts, it checks the signal again, and if already aborted it produces an Operation aborted error result without calling the tool. The tool-result messages are nonetheless regenerated in the original order of orderedFinalizedCalls. The payoff of this choice is that independent tools can run at the same time while the context order stays stable; the cost is that tools touching shared resources must be explicitly marked executionMode: "sequential" or serialize themselves."
"executePreparedToolCall turns a tool's progress callback into tool_execution_update through the injected update sink, and waits until the update events it emitted have been delivered before returning. When a tool returns normally but its result carries isError: true, that flag is preserved. Its catch wraps an exception thrown by the tool into an error result. A beforeToolCall block may also carry terminate: true. The afterToolCall hook can still modify the result, and if the hook itself throws, that also produces an error result. Finally, shouldTerminateToolBatch stops the next model request only when the batch is non-empty and every result has terminate === true."
| Tool boundary | Where it comes from | Result visible to the loop | Downstream effect |
|---|---|---|---|
| Tool name does not exist | prepareToolCall | error tool result | Still handed to the model to decide the next step |
| Invalid arguments / before hook blocks | Preparation phase | error tool result | The tool is not executed |
| Signal aborted | Preparation, the sequential loop, or before a parallel thunk starts | error result, or later sequential items stop | No promise to undo tools already started |
tool.execute throws | Catch in the execution phase | error tool result | Still forms evidence in the context |
| Every result terminates | shouldTerminateToolBatch | terminate: true | The inner loop does not continue because of tools |
Xiao A asked, "What if one tool wants to call another tool? Say a script tool that batch-calls read."
"v1.0.0 exports runToolCall for exactly that," Lao Z said. "It goes through the same steps as a model-initiated call: argument preprocessing, schema validation, beforeToolCall, execution, afterToolCall. It does not emit events and does not add messages to the transcript; tool failures all come back with isError: true rather than rejecting. That way hooks such as permission checks apply to nested calls as well."
Payoffs, costs, and what is not covered
Inference from the source: making the low-level loop's stream function, message conversion, hooks, turn hooks, and queues all injectable lets one loop serve different hosts, and the events let the UI / persistence layer observe every state change. The cost is that state is spread across the context, newMessages, the queues, and the listeners, so anyone extending the implementation has to deal seriously with cancellation, write ordering, and listener failure.
This chapter did not cover how a host persists message_end, and it proved nothing about any tool's idempotence or cancellability. The actual side effects of a tool call depend on the specific tool; parallel execution is not a safety guarantee. Part II Chapter 5: The Dispatch Desk — pi-coding-agent follows these events into the product layer, to see how AgentSession wires them to the session file, compaction, and Skills.
Summary
After the model streams in, here is how the loop turns. runAgentLoop establishes this run's increment and the working context. runLoop's inner loop handles tool results and steering; only the outer loop reads follow-up, and only after the agent would otherwise stop. streamAssistantResponse consumes the stream by placing a partial and replacing it with the final message, which guarantees that while the model is still running the context never contains a half-finished assistant message. executeToolCalls picks sequential or parallel from the configuration and the tool declarations, turning multiple failures into visible tool results so that exceptions are never silently swallowed.
On the boundaries, length, aborted, and error are none of them ordinary successful endings, and each maps to a different recovery path. Tool calls inside a truncated message never enter executeToolCalls — that is the hard boundary preventing a half-formed call from executing.
Key point
The outer turn loop and the inner tool loop are not the same thing: the inner loop handles "all the tool results this one model response triggered", while only the outer loop handles "should there be another round at all". Collapsing the two into one loop is the most common misreading of pi-mono's loop design.
Source Walkthrough
- In
packages/agent/src/agent.ts, readPendingMessageQueue.drain,steer, andfollowUp. Picture the mode asalland as the default value, and write down how many messages each drain returns. - Read from
runAgentLooptorunLoop. Trace how the same prompt enters bothcurrentContext.messagesandnewMessages, and readdeclareToolChangesto state under what conditions it inserts a new system message. - In
runLoop, annotate the two inner and outerwhileloops with their respective continuation conditions. Verify that follow-up is read only after the inner loop exits and steering is read at the tail of each turn. Then mark the three call sites offinishTurn,prepareNextTurn, andprepareRequest. - Read
streamAssistantResponse's start branch, its done / error branch, and the result branch after the loop ends, line by line. Confirm that even without a start event, the final message still gets written into the context. - Search for
failToolCallsFromTruncatedMessage. Verify that tool calls from alengthstop reason never enterexecuteToolCalls. - Compare
executeToolCallsSequentialandexecuteToolCallsParallel. Confirm that the parallel path runs prepared thunks withPromise.allyet appends the tool-result messages in the original call order. - Read
runToolCall. AgainstexecuteToolCalls, list the two things it skips (events and transcript messages).
