Double while Loop
runLoop is the engine of pi-agent-core. The outer while (true) drains the followUp queue; the inner while (hasMoreToolCalls || pendingMessages.length > 0) handles the tool-call loop and steering interjections. Each turn calls streamAssistantResponse to fetch an assistant message; if it has toolCalls, it runs executeToolCalls and loops back into the inner loop, otherwise it exits the inner loop and asks the followUp queue. The whole loop holds no state — all state is passed in by the caller (Agent), and events are emitted through AgentEventSink.
Responsibilities
- Event orchestration: each turn emits
turn_start; the assistant message emitsmessage_start/message_update/message_end; tool execution emitstool_execution_*; the end of a turn emitsturn_end; the end of the loop emitsagent_end. Seepackages/agent/src/agent-loop.ts:155-246. - Streaming the assistant reply:
streamAssistantResponsecallsconvertToLlmto convert toMessage[], usesstreamFn(defaultstreamSimple) to send the request, andfor awaitconsumes events, rewriting the partial message at the tail ofcontext.messages. Seepackages/agent/src/agent-loop.ts:252-345. - Steering injection: the start of the inner loop checks
pendingMessages, pushes steering messages intocurrentContext.messagesbefore the next LLM call. Seepackages/agent/src/agent-loop.ts:180-188. - followUp relay: after the inner loop exits, the outer loop calls
getFollowUpMessages; if there are messages, it pushes them back intopendingMessagesand re-enters the inner loop, otherwise it breaks. Seepackages/agent/src/agent-loop.ts:233-243. - Early stop:
shouldStopAfterTurnis called afterturn_end; when it returns true,agent_endis emitted and the loop exits, bypassing queue polling. Seepackages/agent/src/agent-loop.ts:218-228.
Design motivation
Why two while loops instead of one? Because there are two "continue" semantics. Tool calls are a "within-turn" continuation — the assistant emits toolCalls, and after execution the results are fed back to the LLM to keep going; that's the inner loop. followUp is a continuation "after the turn should have ended" — the user queued something for the agent to handle once it's done; that's the outer loop. Mixing them would blur the injection timing of steering (within-turn interjection) and followUp (cross-turn relay).
Why is steering in the inner loop and followUp in the outer? Steering means "interject before the next LLM call", so it must be consumed at the top of the inner while, before streamAssistantResponse. followUp means "the agent was about to stop, hand it another task", so it must wait until the inner loop exits naturally (hasMoreToolCalls=false and steering is empty) before being checked.
Why does streamAssistantResponse mutate context.messages directly? Because the partial message needs to be visible to the UI during streaming, and the LLM call needs a context where "the previous assistant message is already written". Pushing the partial into context is the simplest approach; the done event then replaces the partial with the finalMessage. The cost is that context is in a "half-finished" state during streaming, but the loop only reads and never writes, so there's no race.
Key files
packages/agent/src/agent-loop.ts:25-26—AgentEventSinktype, the synchronous entry point for all events.packages/agent/src/agent-loop.ts:31-54—agentLoopsync entry; internallyvoid runAgentLoop(...).then(stream.end), returnsEventStream.packages/agent/src/agent-loop.ts:64-93—agentLoopContinueadds no new prompt; resumes from the existing context, used for retries.packages/agent/src/agent-loop.ts:95-118—runAgentLoopemitsagent_start/turn_start, fires each prompt message viamessage_start/message_end, then callsrunLoop.packages/agent/src/agent-loop.ts:120-143—runAgentLoopContinueemits no prompt message events; goes straight intorunLoop.packages/agent/src/agent-loop.ts:155-246—runLoopbody: outer while, inner while, error short-circuit,shouldStopAfterTurn, followUp relay.packages/agent/src/agent-loop.ts:252-345—streamAssistantResponse:transformContext→convertToLlm→streamFunction→for awaitevent loop.packages/agent/src/agent-loop.ts:281-285— the actual stream call;apiKeyis re-resolved viagetApiKeybefore the call, to avoid OAuth token expiry during long tasks.
Skeleton of the outer loop draining followUp and the inner loop handling tool calls:
// packages/agent/src/agent-loop.ts:168-243
while (true) {
let hasMoreToolCalls = true;
while (hasMoreToolCalls || pendingMessages.length > 0) {
if (!firstTurn) await emit({ type: "turn_start" }); else firstTurn = false;
// push steering into context ...
const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFn);
if (message.stopReason === "error" || message.stopReason === "aborted") { /* emit turn_end/agent_end and return */ }
const toolCalls = message.content.filter((c) => c.type === "toolCall");
hasMoreToolCalls = false;
if (toolCalls.length > 0) {
const executedToolBatch = await executeToolCalls(currentContext, message, config, signal, emit);
toolResults.push(...executedToolBatch.messages);
hasMoreToolCalls = !executedToolBatch.terminate;
// push toolResults into context ...
}
await emit({ type: "turn_end", message, toolResults });
if (await config.shouldStopAfterTurn?.(...)) { await emit({ type: "agent_end", ... }); return; }
pendingMessages = (await config.getSteeringMessages?.()) || [];
}
const followUpMessages = (await config.getFollowUpMessages?.()) || [];
if (followUpMessages.length > 0) { pendingMessages = followUpMessages; continue; }
break;
}streamAssistantResponse rewrites the partial message at the tail of context.messages directly inside stream events:
// packages/agent/src/agent-loop.ts:290-316
for await (const event of response) {
switch (event.type) {
case "start":
partialMessage = event.partial;
context.messages.push(partialMessage);
addedPartial = true;
await emit({ type: "message_start", message: { ...partialMessage } });
break;
case "text_start": case "text_delta": case "text_end":
case "thinking_start": case "thinking_delta": case "thinking_end":
case "toolcall_start": case "toolcall_delta": case "toolcall_end":
if (partialMessage) {
partialMessage = event.partial;
context.messages[context.messages.length - 1] = partialMessage;
await emit({ type: "message_update", assistantMessageEvent: event, message: { ...partialMessage } });
}
break;
case "done": case "error": { /* replace with finalMessage, emit message_end, return */ }
}
}Data flow
Full path after agent.prompt(text) comes in:
Boundaries and failure
- Error short-circuit: when
stopReasonis"error"or"aborted",turn_end+agent_endare emitted and the loop returns immediately — no tool execution, no steering/followUp polling. Seepackages/agent/src/agent-loop.ts:194-198. - transformContext contract: if
transformContextthrows, it breaks the loop without producing a normal event sequence, so the type contract requires it not to throw and to return a fallback instead. Seepackages/agent/src/agent-loop.ts:260-263and Type Contracts. - Steering skipped on first turn:
runPromptMessagescan passskipInitialSteeringPolltorunAgentLoopso the prompt isn't preempted by steering right after it's sent. Seepackages/agent/src/agent.ts:374-388. - continue role precheck: both
runAgentLoopContinue/agentLoopContinuecheck before callingrunLoopthat the last message is not assistant, otherwise they throw. Seepackages/agent/src/agent-loop.ts:127-133. - partials don't go into newMessages: the partial message is written into
currentContext.messagesfor the next LLM turn's context, butnewMessages(returned to the caller as this turn's additions) only pushes the complete finalMessage aftermessage_end. Seepackages/agent/src/agent-loop.ts:191-192.
Summary
runLoop uses a double while to separate "within-turn tool-call loop" from "cross-turn followUp relay"; steering is injected at the top of the inner loop, followUp relays at the end of the outer loop, and errors and aborts short-circuit out. The loop itself is stateless; all cross-turn information flows through the context passed in by Agent and the two callbacks getSteeringMessages/getFollowUpMessages. For how tool calls actually run, see Tool Execution sequential/parallel; for the loop's entry wrappers and state shell, see Agent Class and Lifecycle.