Skip to content

Double while Loop

源码版本v0.73.1

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

  1. Event orchestration: each turn emits turn_start; the assistant message emits message_start/message_update/message_end; tool execution emits tool_execution_*; the end of a turn emits turn_end; the end of the loop emits agent_end. See packages/agent/src/agent-loop.ts:155-246.
  2. Streaming the assistant reply: streamAssistantResponse calls convertToLlm to convert to Message[], uses streamFn (default streamSimple) to send the request, and for await consumes events, rewriting the partial message at the tail of context.messages. See packages/agent/src/agent-loop.ts:252-345.
  3. Steering injection: the start of the inner loop checks pendingMessages, pushes steering messages into currentContext.messages before the next LLM call. See packages/agent/src/agent-loop.ts:180-188.
  4. followUp relay: after the inner loop exits, the outer loop calls getFollowUpMessages; if there are messages, it pushes them back into pendingMessages and re-enters the inner loop, otherwise it breaks. See packages/agent/src/agent-loop.ts:233-243.
  5. Early stop: shouldStopAfterTurn is called after turn_end; when it returns true, agent_end is emitted and the loop exits, bypassing queue polling. See packages/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

Skeleton of the outer loop draining followUp and the inner loop handling tool calls:

typescript
// 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:

typescript
// 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 stopReason is "error" or "aborted", turn_end + agent_end are emitted and the loop returns immediately — no tool execution, no steering/followUp polling. See packages/agent/src/agent-loop.ts:194-198.
  • transformContext contract: if transformContext throws, 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. See packages/agent/src/agent-loop.ts:260-263 and Type Contracts.
  • Steering skipped on first turn: runPromptMessages can pass skipInitialSteeringPoll to runAgentLoop so the prompt isn't preempted by steering right after it's sent. See packages/agent/src/agent.ts:374-388.
  • continue role precheck: both runAgentLoopContinue/agentLoopContinue check before calling runLoop that the last message is not assistant, otherwise they throw. See packages/agent/src/agent-loop.ts:127-133.
  • partials don't go into newMessages: the partial message is written into currentContext.messages for the next LLM turn's context, but newMessages (returned to the caller as this turn's additions) only pushes the complete finalMessage after message_end. See packages/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.