Pify

Chapter 3: The agent loop

How Pi turns a prompt into model turns, tool batches, queued interventions, and a settled run.

Chapter 2 separated model transport, the Agent runtime, and the coding product. The Agent Loop is the moving part inside that architecture. This chapter starts with why a loop exists, then follows one message through Pi 0.85.0: context preparation, streaming, Tool execution, queued instructions, termination, events, and final settlement.

1. Prelude: three ways to use an LLM

The amount of control delegated to the model separates a direct call, a Workflow, and an Agent Loop. All three can use the same provider and model; their control flow differs.

Mode 1: direct call, “model, answer me”

A direct call sends one prepared context and consumes one response:

user input -> build Context -> models.streamSimple() -> final AssistantMessage

The current Pi AI entry point is a Models collection. This complete example registers one provider and makes one call:

import { createModels, type Context } from "@earendil-works/pi-ai";
import { anthropicProvider } from "@earendil-works/pi-ai/providers/anthropic";

const models = createModels();
models.setProvider(anthropicProvider());

const model = models.getModel("anthropic", "claude-sonnet-4-6");
if (!model) throw new Error("Model not found");

const context: Context = {
  systemPrompt: "You are a translation assistant.",
  messages: [
    {
      role: "user",
      content: "Translate this TypeScript function into Python.",
      timestamp: Date.now(),
    },
  ],
};

const stream = models.streamSimple(model, context);
const response = await stream.result();
console.log(response.content);

Prompt construction and response handling are the application’s main work. Translation, extraction, classification, and a bounded question often fit this mode.

Mode 2: Workflow, “follow these application-defined steps”

A Workflow calls a model several times, but application code fixes the order and decides whether a step passed:

input
  -> model: extract requirements
  -> code: validate required fields
  -> model: draft change
  -> code: run tests
  -> model: explain failures or write summary

The model supplies judgment inside each stage. The application owns the state machine. Document pipelines, RAG, review gates, and repeatable approval flows benefit from that predictability.

Mode 3: Agent Loop, “choose the next operation”

An Agent gives the model a set of Tools and lets each assistant response select the next operation:

user: "Explain why this test fails"
  -> model requests read(test file)
  -> application executes read and appends ToolResultMessage
  -> model requests grep(symbol)
  -> application executes grep and appends ToolResultMessage
  -> model returns an explanation with no ToolCall
  -> run reaches a stable boundary

The application still controls the available Tools, permissions, validation, stop hooks, queues, and error policy. The model chooses among those permitted operations. Pi repeats model and Tool turns until the runtime reaches an explicit exit boundary.

That division of authority is practical. The model cannot execute an arbitrary function merely by naming it: Agent Core resolves the name against AgentContext.tools, normalizes and validates the arguments, and can block the call before product code runs. Likewise, the model cannot keep the process alive by emitting prose that says “continue.” Another Turn needs a Tool batch, an injected steering message, or a follow-up message accepted by runtime state.

DimensionDirect callWorkflowAgent Loop
Next-step decisionCallerApplication state machineModel output within runtime rules
Model callsUsually oneKnown or bounded by codeDepends on Tool requests and queues
Main design workContext and output handlingStages, transitions, and validationTools, loop policy, events, and termination
Model roleAnswer generatorSpecialist inside a stageChooses among permitted operations
Good fitTranslation or extractionRAG or review pipelineCoding assistant or open-ended automation

2. Two concepts first: Trace and Turn

Pi’s public event names make the distinction concrete. A run is bounded by agent_start and agent_end. A Turn is bounded by turn_start and turn_end.

Trace: one complete run

This chapter uses Trace for the whole run initiated by one Agent.prompt() or Agent.continue() call. “Trace” is a teaching term here, not an exported Pi type.

Trace
├─ agent_start
├─ Turn 1: assistant requests read + grep; both Tools settle
├─ Turn 2: assistant requests edit; the Tool settles
├─ Turn 3: assistant answers without a ToolCall
└─ agent_end

A Trace can also end after one Turn, on a hard provider failure, after shouldStopAfterTurn, or at a deferred-response boundary. Awaited Agent subscribers remain part of settlement even after the agent_end event has been emitted.

Turn: one assistant response plus its Tool batch

A Turn contains exactly one assistant model response and every Tool call that Pi accepts from that response. Three parallel Tool calls still belong to one Turn:

turn_start
  -> one streamFn(model, context, options) call
  -> one completed AssistantMessage
  -> zero or more Tool executions from that message
  -> zero or more ToolResultMessage objects
turn_end

The next model call starts another Turn. The initial user prompt is emitted inside the first Turn, before assistant streaming begins. Steering and follow-up messages are likewise emitted when the loop injects them before a later assistant response.

turn_end.toolResults contains the finalized Tool result messages for that assistant response. A Turn with no calls carries an empty array. A response with several calls still produces one turn_end after the batch settles; it does not close and reopen the Turn around each individual Tool. This boundary gives session persistence and telemetry a stable unit without hiding fine-grained Tool progress events.

Relationship between Trace and Turn

one Trace

├─ Turn 1
│  ├─ AssistantMessage: ToolCall(read), ToolCall(grep)
│  └─ ToolResultMessage(read), ToolResultMessage(grep)

├─ Turn 2
│  ├─ AssistantMessage: ToolCall(edit)
│  └─ ToolResultMessage(edit)

└─ Turn 3
   └─ AssistantMessage: text, no ToolCall

The distinction prevents two common counting errors: treating each Tool in a parallel batch as a Turn, or treating an entire multi-Turn prompt as one Turn.

3. Big picture: the message journey and the loop

Full journey

The complete path for agent.prompt("Read src/main.ts and explain it") is:

string input
  -> normalized UserMessage
  -> agent_start, turn_start, user message_start/message_end
  -> transformContext(AgentMessage[])
  -> convertToLlm(AgentMessage[]) -> Message[]
  -> streamFn(model, Context, options)
  -> assistant message_start/message_update*/message_end
  -> ToolCall blocks selected from AssistantMessage.content
  -> Tool preflight and execution
  -> ToolResultMessage events and transcript append
  -> turn_end
  -> shouldStopAfterTurn(completed Turn)
  -> steering queue drain
  -> if another inner Turn is required: prepareNextTurn
  -> if the earlier poll was empty: steering queue refresh after preparation
  -> another Turn, follow-up outer loop, or agent_end

During a normal Tool turn, the transcript grows in conversation order. This artifact shows selected fields rather than complete protocol objects:

[
  { "role": "user", "content": "Read src/main.ts" },
  {
    "role": "assistant",
    "content": [{ "type": "toolCall", "id": "call_1", "name": "read" }]
  },
  {
    "role": "toolResult",
    "toolCallId": "call_1",
    "toolName": "read",
    "isError": false
  }
]

AgentState.streamingMessage exposes the partial assistant message while it is being built. AgentState.pendingToolCalls tracks Tool call IDs between tool_execution_start and tool_execution_end. Completed messages enter AgentState.messages on message_end.

The loop also maintains newMessages, a run-local collector returned by the low-level stream and attached to agent_end. For a prompt run it begins with the input messages; for a continuation run it begins empty. It then collects assistant output, Tool results, and injected queue messages. Existing context is therefore distinguishable from artifacts created during this invocation, which is useful when an outer session decides what to persist or display.

What keeps the loop moving, and what ends it

The historical implementation could be summarized too easily as “inspect stopReason.” Pi 0.85.0 uses several pieces of state:

assistant response
  ├─ error / aborted ------------------------------> hard exit
  ├─ ToolCall blocks ------------------------------> Tool batch
  │    ├─ non-terminating batch -------------------> automatic next Turn
  │    └─ every finalized result terminate=true ---> no automatic Tool continuation
  ├─ shouldStopAfterTurn=true ---------------------> graceful exit before queues
  ├─ steering messages ----------------------------> next inner-loop Turn
  ├─ follow-up messages at stable boundary --------> reopen inner loop
  └─ none of the above ----------------------------> agent_end

StopReason still records why provider streaming ended:

Final reasonWhat Agent Core does
toolUseExecutes actual ToolCall content blocks; the label alone does not continue the loop
stopWith no Tool calls, reaches the stop hook and steering/follow-up queue checks
lengthNever executes Tool calls from the truncated response; emits an error result for each and lets the model reissue them
deferredTakes the ordinary no-Tool post-Turn path; the loop does not poll the DeferredHandle
error / abortedEmits turn_end and agent_end immediately, skipping turn hooks and both queues

pending is the initial/partial value while some provider streams are in flight. It is not a successful terminal done reason. A final deferred message carries a DeferredHandle; fetching or cancelling it belongs to the host through Models.fetchDeferred() or Models.cancelDeferred(), outside this Agent Loop.

This explains why a termination report must name both the provider result and the runtime state. “The model returned stop” is incomplete if a steering instruction is already queued. “A Tool returned terminate: true” is incomplete if another result in the same batch did not. “The stream ended” is incomplete when the final message is deferred and host-level handling remains. The observable finish condition belongs to the whole run, not to one field on one message.

One rule drives ordinary continuation

The core decision is based on content and finalized Tool state, not one string:

// Abridged from packages/agent/src/agent-loop.ts at 107d79f1.
const toolCalls = message.content.filter((part) => part.type === "toolCall");
hasMoreToolCalls = false;

if (toolCalls.length > 0) {
  const batch =
    message.stopReason === "length"
      ? await failToolCallsFromTruncatedMessage(toolCalls, emit)
      : await executeToolCalls(currentContext, message, config, signal, emit);

  hasMoreToolCalls = !batch.terminate;
}

A toolUse reason with no ToolCall block does not force another Turn. Conversely, a valid Tool block controls execution even though the runtime must separately handle length, abort, hooks, and queues. After Tool execution, batch termination only disables automatic Tool continuation; steering or follow-up messages can still extend the run.

Minimal loop: the common denominator

The smallest useful Agent loop can be taught without Pi-specific hooks. This is pseudocode, not a copyable API:

// Pseudocode
while (true) {
  const assistant = await callModel(messages, tools);
  messages.push(assistant);

  const calls = getCompleteToolCalls(assistant);
  if (calls.length === 0) break;

  const results = await executeAllowedTools(calls);
  messages.push(...results);
}

That loop is the ReAct rhythm: the model reasons into an action, the application observes the action by running a Tool, and the observation returns as a Tool result. Production code needs cancellation, validation, event ordering, queue policy, custom messages, and settlement guarantees around it.

All exit paths

Exit pathTriggerQueue behavior
Normal stable boundaryNo Tool continuation and no queued messageRuns the stop hook, polls steering and follow-up, then emits agent_end if both are empty
Batch termination hintEvery finalized Tool result has terminate: trueSkips automatic Tool continuation, then still checks the stop hook, steering, and follow-up
Graceful hook stopshouldStopAfterTurn returns trueExits before steering and follow-up polling
Provider hard stopFinal reason is error or abortedSkips prepareNextTurn, stop hook, and queues
Deferred boundaryFinal reason is deferred and there are no Tool callsRuns the stop hook and steering poll, then follow-up at a stable boundary if steering is empty; host handles the handle
Callback/runtime throwA “must not throw” transform, conversion, or hook rejectsRaw low-level normal sequence is not guaranteed; Agent catches run failure and emits a synthetic failure turn

For a no-Tool deferred message, the loop emits turn_end and runs shouldStopAfterTurn on the completed-Turn snapshot. If that hook is truthy, the loop emits agent_end immediately. Otherwise it polls steering. A returned steering message warrants another inner Turn, whose preparation runs before the message is injected; if steering is empty, the loop checks follow-up at the stable boundary. Neither poll fetches or cancels the DeferredHandle; that remains the host's responsibility.

Agent.abort() signals the active provider request and Tool callbacks. Provider-side cancellation normally becomes an aborted assistant message. If the signal arrives during Tool processing, started Tools receive the signal; sequential preparation stops after the observed abort, and the next provider boundary receives the already-aborted signal. A Tool must honor its signal for cancellation to be prompt.

4. Source walkthrough: base loop and coding-agent layering

The reusable loop now lives in @earendil-works/pi-agent-core. The coding product does not maintain a separate private loop. It constructs Agent, supplies a StreamFn wrapper, converts coding-specific messages, refreshes model/system prompt/Tools between Turns, maps terminal input to steering or follow-up, and persists the emitted state.

The conceptual kernel remains short:

// Pseudocode: conceptual kernel only.
for (;;) {
  const assistant = await streamAssistant(context);
  const batch = await executeCompleteToolCalls(assistant);
  if (!batch.needsAnotherModelTurn) break;
}

What the coding agent layers on top

Product needReusable Agent Core mechanismCoding Agent policy
Terminal input arrives during a runsteer() and steering queue modesInteractive/RPC input selects steering
A task should wait until the current task settlesfollowUp() and the outer queue checkUI and RPC expose follow-up commands
Extensions alter model-visible contexttransformContext and convertToLlmExtension context event, custom message conversion, image blocking
Settings or extensions change between TurnsprepareNextTurnWithContextRefresh system prompt, Tools, model, and thinking level
Provider retries, headers, and timeoutsInjected StreamFnModelRuntime.streamSimple() wrapper and Extension provider hooks
Session/UI updatesTyped AgentEvent streamAgentSession persistence, rendering, compaction, and queue display

This is a correction to older source tours: steering, follow-up, turn hooks, and parallel Tool scheduling are Agent Core features at the pinned revision. Coding Agent supplies product policy through those extension points.

4.1 Entry: Agent, agentLoop(), and agentLoopContinue()

Most applications should enter through Agent. The current model stream function is bound to its Models instance:

import { Agent } from "@earendil-works/pi-agent-core";
import { createModels } from "@earendil-works/pi-ai";
import { anthropicProvider } from "@earendil-works/pi-ai/providers/anthropic";

const models = createModels();
models.setProvider(anthropicProvider());
const model = models.getModel("anthropic", "claude-sonnet-4-6");
if (!model) throw new Error("Model not found");

const agent = new Agent({
  initialState: {
    systemPrompt: "Inspect code before making a claim.",
    model,
    tools: [],
  },
  streamFn: models.streamSimple.bind(models),
});

await agent.prompt("Explain the build scripts in package.json.");

The two exported low-level functions return EventStream<AgentEvent, AgentMessage[]>:

function agentLoop(
  prompts: AgentMessage[],
  context: AgentContext,
  config: AgentLoopConfig,
  signal: AbortSignal | undefined,
  streamFn: StreamFn,
): EventStream<AgentEvent, AgentMessage[]>;
function agentLoopContinue(
  context: AgentContext,
  config: AgentLoopConfig,
  signal: AbortSignal | undefined,
  streamFn: StreamFn,
): EventStream<AgentEvent, AgentMessage[]>;
EntryAdds prompt messagesOwns durable state/queuesEvent-consumer barrier
Agent.prompt()YesYesAwaits subscribers in registration order
agentLoop()YesCaller owns returned messagesRaw stream is observational
agentLoopContinue()NoCaller supplies existing contextRaw stream is observational

agentLoopContinue() requires non-empty context whose last message is not an assistant message. After convertToLlm, the provider-facing tail must be user or toolResult. This path is for an already prepared continuation; it does not invent a retry prompt.

Low-level callers must also own transcript persistence. agentLoop() creates a working message array and returns the messages created by that invocation through stream.result(); the supplied AgentContext is not a stateful substitute for Agent. A caller that wants another independent low-level run must merge the returned artifacts into its own context deliberately. This ownership rule is one reason the Agent wrapper is the safer default.

A low-level prompt input has normal AgentMessage shape:

const prompts: AgentMessage[] = [
  {
    role: "user",
    content: "Inspect package.json.",
    timestamp: Date.now(),
  },
];

The loop receives a context snapshot:

const context: AgentContext = {
  systemPrompt: "Be precise.",
  messages: [],
  tools: [],
};

And its behavior comes from callbacks and stream options:

const config: AgentLoopConfig = {
  model,
  convertToLlm: (messages) =>
    messages.filter(
      (message) =>
        message.role === "user" ||
        message.role === "assistant" ||
        message.role === "toolResult",
    ),
  toolExecution: "parallel",
};

The public Agent creates snapshots of its system prompt, messages, and Tools, runs runAgentLoop or runAgentLoopContinue, then reduces events back into live AgentState.

4.2 Skeleton of runLoop(): core first, shells second

Core: the inner loop

The pinned inner condition includes both automatic Tool continuation and injected messages:

// Faithfully abridged from packages/agent/src/agent-loop.ts.
let lastCompletedTurn: PrepareNextTurnContext | undefined;

while (hasMoreToolCalls || pendingMessages.length > 0) {
  if (lastCompletedTurn) {
    const nextTurnSnapshot = await config.prepareNextTurn?.(lastCompletedTurn);
    if (nextTurnSnapshot) {
      currentContext = nextTurnSnapshot.context ?? currentContext;
      config = {
        ...config,
        model: nextTurnSnapshot.model ?? config.model,
        reasoning:
          nextTurnSnapshot.thinkingLevel === undefined
            ? config.reasoning
            : nextTurnSnapshot.thinkingLevel === "off"
              ? undefined
              : nextTurnSnapshot.thinkingLevel,
      };
    }
    if (pendingMessages.length === 0) {
      pendingMessages = (await config.getSteeringMessages?.()) || [];
    }
    await emit({ type: "turn_start" });
  }

  if (pendingMessages.length > 0) {
    for (const pendingMessage of pendingMessages) {
      await emit({ type: "message_start", message: pendingMessage });
      await emit({ type: "message_end", message: pendingMessage });
      currentContext.messages.push(pendingMessage);
      newMessages.push(pendingMessage);
    }
    pendingMessages = [];
  }

  const message = await streamAssistantResponse(
    currentContext,
    config,
    signal,
    emit,
    streamFunction,
  );
  newMessages.push(message);

  if (message.stopReason === "error" || message.stopReason === "aborted") {
    await emit({ type: "turn_end", message, toolResults: [] });
    await emit({ type: "agent_end", messages: newMessages });
    return;
  }

  const toolCalls = message.content.filter((part) => part.type === "toolCall");
  const toolResults: ToolResultMessage[] = [];
  hasMoreToolCalls = false;
  if (toolCalls.length > 0) {
    const executedToolBatch =
      message.stopReason === "length"
        ? await failToolCallsFromTruncatedMessage(toolCalls, emit)
        : await executeToolCalls(currentContext, message, config, signal, emit);
    toolResults.push(...executedToolBatch.messages);
    hasMoreToolCalls = !executedToolBatch.terminate;
    for (const result of toolResults) {
      currentContext.messages.push(result);
      newMessages.push(result);
    }
  }

  await emit({ type: "turn_end", message, toolResults });
  lastCompletedTurn = {
    message,
    toolResults,
    context: currentContext,
    newMessages,
  };

  if (await config.shouldStopAfterTurn?.(lastCompletedTurn)) {
    await emit({ type: "agent_end", messages: newMessages });
    return;
  }

  pendingMessages = (await config.getSteeringMessages?.()) || [];
}

hasMoreToolCalls begins true so the first assistant response runs even with no pending messages. Each later iteration corresponds to a new Turn. The abridgement removes detailed helper bodies but preserves the pinned control-flow order.

Layering: the outer queue shell and stateful wrapper

There are two shells around that kernel:

Agent wrapper
  ├─ mutable public state
  ├─ awaited subscribers
  ├─ AbortController and settlement promise
  └─ steering/follow-up queue objects
       |
       └─ runLoop outer while(true)
            ├─ inner while(tool continuation || pending messages)
            └─ when inner stops: drain follow-up queue or break

The outer while (true) is not another model algorithm. It reopens the inner loop only when follow-up messages exist at the point the Agent would otherwise stop.

4.3 Steering injection

Applications enqueue a complete AgentMessage:

agent.steer({
  role: "user",
  content: "Read the test fixture instead of production data.",
  timestamp: Date.now(),
});

The queue does not interrupt the active provider stream or a running Tool. Agent Core polls steering before the first inner-loop iteration and after every completed Turn whose stop hook is falsy. If Tool continuation or that post-Turn poll warrants another iteration, it runs prepareNextTurn; when the earlier poll was empty, it polls once more after preparation so steering queued during a long-running hook can join the next Turn:

if (pendingMessages.length > 0) {
  for (const message of pendingMessages) {
    await emit({ type: "message_start", message });
    await emit({ type: "message_end", message });
    currentContext.messages.push(message);
    newMessages.push(message);
  }
  pendingMessages = [];
}

one-at-a-time drains the oldest queued message per poll. all drains the whole queue. Because polling occurs at Turn boundaries, “steering” means next-Turn priority, not mid-Tool preemption.

4.4 streamAssistantResponse(): the model boundary

Phase A: transform Agent context

The first hook works entirely in the richer application message domain:

let messages = context.messages;
if (config.transformContext) {
  messages = await config.transformContext(messages, signal);
}

Coding Agent uses this stage to let Extensions transform context. Compaction or retrieval can also return a different AgentMessage[]. The contract says the hook must not throw; on failure it should return the original messages or another safe fallback.

durable AgentMessage[]
  -> transformContext()
  -> turn-specific AgentMessage[]

The returned array prepares one request. It does not replace the durable transcript unless application policy explicitly performs a separate state update.

Phase B: convert AgentMessage to Message

convertToLlm is required at the low-level boundary:

const llmMessages = await config.convertToLlm(messages);

The default Agent converter retains user, assistant, and toolResult roles. Coding Agent instead maps bashExecution, custom, branchSummary, and compactionSummary messages into user messages, while excluded Bash messages are filtered out:

// Faithfully abridged from packages/coding-agent/src/core/messages.ts.
switch (m.role) {
  case "bashExecution":
    if (m.excludeFromContext) return undefined;
    return {
      role: "user",
      content: [{ type: "text", text: bashExecutionToText(m) }],
      timestamp: m.timestamp,
    };
  case "custom": {
    const content =
      typeof m.content === "string"
        ? [{ type: "text" as const, text: m.content }]
        : m.content;
    return { role: "user", content, timestamp: m.timestamp };
  }
  case "branchSummary":
    return {
      role: "user",
      content: [
        {
          type: "text" as const,
          text: BRANCH_SUMMARY_PREFIX + m.summary + BRANCH_SUMMARY_SUFFIX,
        },
      ],
      timestamp: m.timestamp,
    };
  case "compactionSummary":
    return {
      role: "user",
      content: [
        {
          type: "text" as const,
          text:
            COMPACTION_SUMMARY_PREFIX + m.summary + COMPACTION_SUMMARY_SUFFIX,
        },
      ],
      timestamp: m.timestamp,
    };
  case "user":
  case "assistant":
  case "toolResult":
    return m;
}

bashExecutionToText() and the two summary prefix/suffix constants are declared in the same pinned file. The excerpt preserves all four coding-specific role branches instead of standing in invented conversion helpers.

The type transition is intentionally lossy:

AgentMessage[]                           Message[]
├─ user ------------------------------> user
├─ assistant -------------------------> assistant
├─ toolResult ------------------------> toolResult
├─ compactionSummary -----------------> user summary
├─ bashExecution(excluded) -----------> removed
└─ custom ----------------------------> user content

Provider adapters never need to understand Coding Agent’s storage or UI message types.

The order of the two hooks is part of the contract. transformContext can reason about application-only types before anything is discarded. convertToLlm then performs the final projection into the provider union. Reversing them would make compaction or Extension logic blind to messages that the model should not receive directly but that still carry useful application state.

Phase C: build Context and call the selected model

The loop creates a fresh provider-facing wrapper for each Turn:

// Faithfully abridged from packages/agent/src/agent-loop.ts at 107d79f1.
const llmContext: Context = {
  systemPrompt: context.systemPrompt,
  messages: llmMessages,
  tools: context.tools,
};

It resolves a current API key, then calls the injected function:

// Faithfully abridged from packages/agent/src/agent-loop.ts at 107d79f1.
const response = await streamFunction(config.model, llmContext, {
  ...config,
  apiKey: resolvedApiKey,
  signal,
});

For ordinary Agent Core applications, pass the current model collection method with its receiver:

const streamFn = models.streamSimple.bind(models);

Coding Agent wraps the same contract rather than passing Models directly:

// Faithfully abridged from packages/coding-agent/src/core/sdk.ts.
streamFn: async (model, context, options) => {
  const providerRetrySettings = settingsManager.getProviderRetrySettings();
  const httpIdleTimeoutMs = settingsManager.getHttpIdleTimeoutMs();
  const effectiveTimeoutMs =
    httpIdleTimeoutMs === 0 ? 2147483647 : httpIdleTimeoutMs;
  const timeoutMs =
    options?.timeoutMs ?? providerRetrySettings.timeoutMs ?? effectiveTimeoutMs;
  const websocketConnectTimeoutMs =
    options?.websocketConnectTimeoutMs ??
    settingsManager.getWebSocketConnectTimeoutMs();
  const headerRunner = extensionRunnerRef.current;

  return modelRuntime.streamSimple(model, context, {
    ...options,
    timeoutMs,
    websocketConnectTimeoutMs,
    maxRetries: options?.maxRetries ?? providerRetrySettings.maxRetries,
    maxRetryDelayMs:
      options?.maxRetryDelayMs ?? providerRetrySettings.maxRetryDelayMs,
    transformHeaders: async (requestHeaders) => {
      const headers = mergeProviderAttributionHeaders(
        model,
        settingsManager,
        options?.sessionId,
        requestHeaders,
      );
      return headerRunner?.hasHandlers("before_provider_headers")
        ? headerRunner.emitBeforeProviderHeaders(headers ?? {})
        : (headers ?? {});
    },
  });
},

The surrounding sdk.ts scope supplies settingsManager, extensionRunnerRef, mergeProviderAttributionHeaders, and modelRuntime. The wrapper applies timeout and retry settings, provider attribution, and the Extension header hook. Credential resolution remains an Agent Loop concern through getApiKey; the loop still sees only StreamFn.

Context partTypical stability across TurnsWhy it can still change
systemPromptOften stableprepareNextTurn or product settings may replace it
toolsOften stableExtensions or a Tool result may change availability
messagesGrows each TurnAssistant and Tool result messages are appended
modelUsually stableprepareNextTurn can select another model

Provider adapters own cache-control serialization. Rebuilding the small Context object does not itself define a cache hit; provider-visible content and provider cache semantics do.

Phase D: stream and replace the assistant message in place

streamAssistantResponse() reserves one transcript slot on start, replaces that slot with each partial, and finally replaces it with the completed message:

// Faithfully abridged from packages/agent/src/agent-loop.ts at 107d79f1.
case "start":
  partialMessage = event.partial;
  context.messages.push(partialMessage);
  await emit({ type: "message_start", message: { ...partialMessage } });
  break;

case "text_delta":
case "toolcall_delta":
case "thinking_delta":
  partialMessage = event.partial;
  context.messages[context.messages.length - 1] = partialMessage;
  await emit({ type: "message_update", message: { ...partialMessage }, assistantMessageEvent: event });
  break;

case "done":
case "error":
  finalMessage = await response.result();
  context.messages[context.messages.length - 1] = finalMessage;
  await emit({ type: "message_end", message: finalMessage });
  return finalMessage;

The actual switch also handles *_start and *_end events. If a stream reaches completion without a start event, the implementation appends the final message and synthesizes message_start before message_end.

start          messages[last] = empty/initial AssistantMessage
text_delta     messages[last] = newer partial AssistantMessage
toolcall_end   messages[last] = partial with complete ToolCall
done/error     messages[last] = final AssistantMessage

One slot avoids storing every token delta as a conversation message. Subscribers still receive each typed update for rendering.

4.5 Stop and termination checks

The hard-stop check occurs before Tool selection:

// Faithfully abridged from packages/agent/src/agent-loop.ts at 107d79f1.
if (message.stopReason === "error" || message.stopReason === "aborted") {
  await emit({ type: "turn_end", message, toolResults: [] });
  await emit({ type: "agent_end", messages: newMessages });
  return;
}

For every other final reason, the loop inspects actual Tool blocks. length is a special safety branch: arguments may be syntactically salvageable but incomplete, so Pi emits a failed Tool result for every call and executes none. After a normal Turn, shouldStopAfterTurn sees the completed-Turn context first, followed by the ordinary steering poll. Only a continuing inner loop later runs prepareNextTurn and may poll steering a second time before its next turn_start.

4.6 Execute Tool calls

The two modes preserve conversation order in different ways:

StageSequential modeParallel mode
PreflightOne call at a timeSource order, before any allowed execution begins
ExecutionOne call at a timeAllowed prepared calls run concurrently
tool_execution_endSource orderCompletion order
ToolResultMessageSource orderSource order after the batch settles

If any targeted Tool declares executionMode: "sequential", the whole assistant batch runs sequentially. Preflight resolves the Tool, applies prepareArguments, validates the schema, and calls beforeToolCall:

// Faithfully abridged from packages/agent/src/agent-loop.ts at 107d79f1.
const preparedToolCall = prepareToolCallArguments(tool, toolCall);
const validatedArgs = validateToolArguments(tool, preparedToolCall);
const beforeResult = await config.beforeToolCall?.(
  { assistantMessage, toolCall, args: validatedArgs, context: currentContext },
  signal,
);

Unknown Tools, invalid arguments, thrown preflight code, blocked calls, and observed aborts become immediate error results. afterToolCall runs only after an allowed Tool actually executes; it may replace content, details, usage, isError, or terminate before final events:

// Faithfully abridged from packages/agent/src/agent-loop.ts at 107d79f1.
const afterResult = await config.afterToolCall?.(
  {
    assistantMessage,
    toolCall,
    args,
    result,
    isError,
    context: currentContext,
  },
  signal,
);

result = {
  ...result,
  content: afterResult?.content ?? result.content,
  details: afterResult?.details ?? result.details,
  usage: afterResult?.usage ?? result.usage,
  terminate: afterResult?.terminate ?? result.terminate,
};
isError = afterResult?.isError ?? isError;

For each finalized call, Pi emits tool_execution_end, then a message_start/message_end pair for the normalized ToolResultMessage. Under an un-aborted batch this produces one result per call. If abort is observed while preparing a batch, later source calls may never start.

Batch termination uses every:

// Faithfully abridged from packages/agent/src/agent-loop.ts at 107d79f1.
const terminate =
  finalizedCalls.length > 0 &&
  finalizedCalls.every((entry) => entry.result.terminate === true);

A mixed batch continues. A blocked beforeToolCall result participates only if it sets both block: true and terminate: true. afterToolCall can add or remove the executed result’s termination hint.

Parallel mode deliberately separates preparation from execution. Pi prepares calls in assistant source order and records immediate failures before it launches the allowed calls concurrently. tool_execution_end can therefore reflect real completion order, while Tool result messages wait until all launched work settles and then return to source order. The model sees a deterministic transcript even when the UI shows one Tool finishing before another.

The terminate flag is runtime-only. createToolResultMessage() copies content, details, usage, added Tool names, error state, and identity fields, but not terminate. The next provider request never receives a nonstandard termination field in its Tool result. Agent Core consumes the hint while deciding whether automatic continuation is needed.

4.7 turn_end, hooks, events, and steering

The post-Turn order is fixed:

turn_end
  -> save { message, toolResults, context, newMessages }
  -> shouldStopAfterTurn(completed-Turn snapshot)
  -> if true: agent_end
  -> otherwise: getSteeringMessages()
  -> if the inner loop continues: prepareNextTurn(saved snapshot)
  -> apply returned context/model/thinkingLevel
  -> if the post-Turn poll was empty: getSteeringMessages() again
  -> turn_start

prepareNextTurn does not itself force another Turn. It runs at the start of an iteration already warranted by Tool continuation or pending messages. Coding Agent installs prepareNextTurnWithContext to refresh its system prompt, Tool registry, selected model, and thinking level from live session state.

The event path for a Tool Turn is:

OrderEvent
1turn_start
2assistant message_start, zero or more message_update, message_end
3tool_execution_start, optional updates, tool_execution_end
4Tool result message_start and message_end
5turn_end

Agent.processEvents() reduces state before invoking listeners. Listeners are awaited in subscription order, so assistant message_end is a barrier: beforeToolCall sees Agent.state.messages already containing the assistant request. Raw agentLoop() streams preserve event order but do not turn asynchronous consumer work into a producer barrier.

Settlement extends past event emission. agent_end guarantees that the loop will produce no later events, but Agent.state.isStreaming stays true while awaited agent_end listeners run. Only finishRun() clears streaming state and pending Tool IDs, resolves waitForIdle(), and removes the active run. This lets a subscriber flush a session or telemetry buffer before await agent.prompt() returns.

4.8 Back to the top of the loop

// Faithfully abridged from packages/agent/src/agent-loop.ts at 107d79f1.
while (hasMoreToolCalls || pendingMessages.length > 0) {
  // one assistant response and its Tool batch
}

Automatic continuation comes from a non-terminating Tool batch. Steering continuation comes from the post-Turn poll. If Tool continuation already warrants another iteration and that poll was empty, preparation gets one more steering poll before turn_start. If neither continuation condition holds, the inner loop ends. shouldStopAfterTurn can exit before any of these post-Turn queue decisions.

4.9 The follow-up outer loop

At the stable boundary, Agent Core polls only the follow-up queue:

// Faithfully abridged from packages/agent/src/agent-loop.ts at 107d79f1.
const followUpMessages = (await config.getFollowUpMessages?.()) || [];
if (followUpMessages.length > 0) {
  pendingMessages = followUpMessages;
  continue;
}
break;

The outer continue returns to the inner loop, where follow-up messages receive normal message events before the next assistant call. They remain part of the same agent_start/agent_end run. A hard error/aborted path or shouldStopAfterTurn returns before this poll.

Agent.continue() is a separate public action after a run has settled. With a user or Tool-result tail, it starts a new run from existing transcript state. With an assistant tail, it can consume already queued steering or follow-up messages; without either queue it rejects. Every accepted path emits a new agent_start. It should not be confused with the outer loop extending the current run.

4.10 Steering versus follow-up

DimensionSteeringFollow-up
Enqueue APIagent.steer(message)agent.followUp(message)
Poll pointBefore the first inner iteration; after each completed Turn; again after preparation when that poll was empty and the loop continuesOnly after the inner loop would stop
EffectInfluences the next available TurnStarts another Turn after current work reaches a stable boundary
Does it interrupt a running Tool?NoNo
Queue modesone-at-a-time or allone-at-a-time or all
Hard error / stop-hook behaviorNot polledNot polled

Coding Agent maps input entered while streaming to one of these queues and exposes their modes in settings. Steering is appropriate for “use the fixture instead.” Follow-up is appropriate for “after that, summarize the diff.”

5. Summary: three loop designs to carry forward

1. ReAct is the core rhythm

Reason in AssistantMessage
  -> Act through ToolCall
  -> Observe through ToolResultMessage
  -> Reason again

One Turn contains one assistant response plus its Tool batch. One run can contain many Turns.

2. Termination is a state decision

stopReason describes provider completion, but control also depends on Tool blocks, truncated-call safety, batch-wide terminate, shouldStopAfterTurn, steering, follow-up, abort, errors, and deferred ownership. The runtime ends only at a defined boundary; it does not ask the model to certify task completeness.

3. Keep the kernel small and layer product policy

BoundaryAgent Core ownsCoding Agent adds
ModelStreamFn contract and per-Turn callModels runtime settings, retries, headers, credentials
Messagestransforms, conversion boundary, transcript eventscoding-specific message conversion and persistence
Toolsvalidation, hooks, scheduling, resultscoding Tools, permission policy, Extension wrapping
Interactionsteering/follow-up queues and lifecycle eventsterminal/RPC mapping, UI, session behavior

This separation lets a small domain Agent use Agent directly while the full coding assistant keeps richer policy in AgentSession and its Extensions.

6. Next stop

Chapter 4 opens the StreamFn boundary: model collections, provider registration, request conversion, normalized streaming events, and error handling.

Version boundary: this walkthrough follows Pi 0.85.0 at commit 107d79f11072bbc8a3a757ed7fd69596bee7d68c and Node.js >=22.19.0.

On this page