Agent Loop
Heart of the conversation loop
“Every Agent is a loop: stream model → execute tool → feed back”
Deep Dive
Every Agent is a loop: stream model → execute tools → feed back results
Learning Objectives
After this chapter you will be able to:
- Distinguish the four core concepts: turn / run / steering / follow-up
- Explain why AsyncGenerator is the ideal modeling for an Agent loop
- Describe the four encapsulation layers (AgentHarness → Agent → agent-loop → pi-ai) and their responsibility boundaries
- Understand how the inner/outer dual-loop structure supports steering and follow-up message injection
- Distinguish when to use parallel vs sequential tool execution
Key Source Files
| File | Responsibility |
|---|---|
packages/agent/src/agent.ts | Agent class: holds state, exposes prompt(), drives the loop |
packages/agent/src/agent-loop.ts | runAgentLoop: turn progression, tool execution, queue draining |
packages/agent/src/types.ts | AgentState / AgentEvent / AgentTool type definitions |
Core Concepts: Build the Coordinate System First
Before diving into code, let's establish pi's runtime vocabulary — every subsequent chapter builds on these concepts:
| Concept | Definition | Analogy |
|---|---|---|
| turn | One complete round of "LLM response + possible tool calls" | A player making one move |
| run | The full execution from one user input until the agent finally stops (contains multiple turns) | An entire game |
| message | The carrier of information between user, agent, and tools | The move record on the board |
| steering | An intervention message the user types mid-run — processed immediately after the current turn completes | A coach shouting from the sideline; adjust on the very next play |
| follow-up | A message the user types mid-run, but held until the agent is about to stop naturally | Waiting in line; deal with it after this game ends |
| save point | A consistency checkpoint between turns — queued session writes are flushed here | A save point |
Why do steering / follow-up matter? Traditional Agent frameworks treat user input as one-time fuel that can only be given before a run starts. pi's dual-queue design lets users course-correct in real time (steering) or append tasks (follow-up) while the agent is working — this is the key to production-grade interactive experience.
The Loop's Position in the Architecture: Four Layers
agent-loop doesn't exist in isolation — it's the third of four encapsulation layers:
Responsibility boundaries:
- AgentHarness is the master controller: manages concurrency (Phase lock), config (turn snapshots), persistence (session read/write) — but never makes LLM calls or executes tools itself; everything is delegated downward (→ Chapter 2)
- Agent is a lightweight Bridge: manages concurrency + stores messages + receives/queues messages; doesn't decide when to stop, doesn't manage config, doesn't persist
- agent-loop is a pure function: given input (messages + context + config) → outputs an event stream. No side effects; testable, replaceable, mockable
- pi-ai is the provider unification layer: routes by
model.apito Anthropic/OpenAI/Google/Bedrock... (→ Chapters 6 & 7)
Design philosophy — "delegate, don't cohere": each layer only does what it's best at, delegating the "doing" responsibilities downward. The orchestration layer stays thin; lower layers can evolve independently without affecting upper-layer logic.
Design Motivation: Why AsyncGenerator?
Problem: An Agent loop might run 1 turn or 20; the caller needs real-time progress (streaming text, tool calls, errors) while retaining external control over "when to stop."
Alternatives and trade-offs:
| Approach | Pros | Fatal Flaw |
|---|---|---|
| Callbacks (onEvent) | Simple | No pause/resume, inverted control flow |
| RxJS Observable | Rich operators | Heavy dependency; pi demands minimal core |
| Promise<AgentEvent[]> | Simplest | Must wait for completion, no streaming |
| AsyncGenerator | Lazy, pausable, for-await consumption, zero deps | Cannot multicast (pi compensates with subscribe) |
pi chose AsyncGenerator because it natively matches "loop + streaming + interruptible" semantics without any third-party dependency — consistent with pi's "minimal core" iron rule (→ Chapter 13 Extension System).
AgentState: The Loop's Entire Input
An Agent holds AgentState with five fields:
interface AgentState {
systemPrompt: string // System prompt defining persona and constraints
model: Model // Current model (includes provider, api info)
thinkingLevel: ThinkingLevel // none | brief | extended — controls reasoning depth
tools: AgentTool[] // Registered tool set
messages: AgentMessage[] // Full conversation history
}
Analogy: Think of AgentState as a chess position — all information lives on the board; the player (loop) makes decisions solely from the board. Externalized state means:
- Snapshotable: serialize the entire position at any time (→ Chapter 4 Session JSONL Tree)
- Persistable: write to file/DB and restore later (→ Chapter 20 SQL Backend)
- Replayable: re-run with the same messages for identical behavior (→ Chapter 21 Evals)
prompt(input) does something simple: push the user message into messages, then drive the loop.
agentLoop: The Teaching Skeleton
First the minimal form — 5 lines of core:
// Teaching version: the essence of an Agent loop
export async function* agentLoop(state: AgentState, cfg: AgentLoopConfig) {
while (true) {
const resp = yield* streamTurn(state.messages, state, cfg) // call model
if (resp.stopReason !== "tool_use") return // model done → stop
yield* executeTools(resp.content, state) // run tools → write back
}
}
This is the heart of every Agent framework. But pi's production version adds two critical dimensions on top: message injection (steering / follow-up) and external stop policies.
runLoop: The Production Inner/Outer Dual Loop
pi's runAgentLoop is actually a three-level nested structure:
runAgentLoop(prompts, context, config, emit, signal, streamFn)
│
├── emit(agent_start)
├── emit(turn_start)
├── emit(message_start/end for prompts) ← inject this run's prompt messages
│
└── runLoop()
│
└── OUTER LOOP: handles follow-up messages
│
└── INNER LOOP: handles toolCalls + steering messages
│
├── emit(turn_start) ← skipped on first turn
├── inject pending steering messages
├── streamAssistantResponse() ← one full LLM call
├── executeToolCalls() ← if any
├── emit(turn_end)
├── prepareNextTurn()
├── shouldStopAfterTurn() ← external stop policy
└── getSteeringMessages() ← if any, continue inner
│
└── (no toolCalls + no steering) → return to outer
OUTER:
└── getFollowUpMessages() ← if any, inject into inner and continue
└── (no follow-up) → emit(agent_end)
Why dual-loop instead of single?
| Scenario | Single loop | Dual loop |
|---|---|---|
| User shouts "don't use npm, use pnpm" mid-run | Cannot handle; must wait for run to end | steering: injected immediately after current turn |
| User appends "after fixing, also run the tests" | Cannot handle | follow-up: caught when agent is about to stop; work continues |
| External max-turns limit | Hardcoded in the loop | shouldStopAfterTurn callback; policy pushed down to caller |
The inner loop's exit condition is "no toolCalls and no steering" — the model no longer needs tools AND the user hasn't interrupted. Only then may it exit. The outer loop then checks follow-up one more time; only when all queues are empty does it emit agent_end.
streamAssistantResponse: Anatomy of One LLM Call
The core action of each turn is streamAssistantResponse() — it's not just "calling an API," it's a data transformation pipeline:
AgentMessage[] system prompt + conversation history
│
▼
[transformContext] optional: prune/inject context (Hook can modify)
│
▼
[convertToLlm] convert custom-role messages to standard LLM messages
│ ├── compactionSummary → { role: "user", content: "<summary>..." }
│ ├── branchSummary → { role: "user", content: "<summary>..." }
│ ├── user/assistant/toolResult → pass through
│ └── other custom roles → filtered out
│
▼
Message[] standard LLM message format
│
▼
streamFn(model, context, options) pi-ai unified streaming call (→ Chapter 7)
│
▼
EventStream streaming events
├── start → emit(message_start)
├── text_delta → emit(message_update)
├── toolcall_delta → emit(message_update) ← partial-JSON streaming parse
├── done → emit(message_end)
└── error → emit(message_end)
Key points:
transformContext/convertToLlmare both injected from outside (assembled by Agent or AgentHarness); the loop itself knows nothing about "what messages look like"streamFnis pi-ai's unified entry: routes bymodel.apito the corresponding provider, handling auth, retries, stream parsing- Events are raised via the
emitcallback — upper layers dispatch solely onAgentEvent.type, never depending on loop internals
Tool Execution: parallel/sequential and Hooks
tool_use events → collect this batch of tool calls
│
beforeToolCall(tool, args)
│
┌─────────┴─────────┐
▼ ▼
parallel: sequential:
Promise.all for...of await
│ │
└─────────┬─────────┘
▼
afterToolCall(tool, result)
│
▼
result → messages → next turn
Each AgentTool uses TypeBox for parameter schemas (runtime validation + auto-generated JSON Schema for the model). executionMode determines whether a batch of tool calls runs in parallel or sequentially:
- parallel (default): read, grep and other read-only tools don't interfere; parallelism speeds things up
- sequential: edit, bash and other side-effectful tools must run serially to avoid race conditions
beforeToolCall / afterToolCall are AOP aspects — AgentSession installs:
- Session persistence (every tool result written to JSONL)
- Telemetry (latency, token counts)
- Permission interception (→ Chapter 5 Built-in Tools)
A Real Run's Event Sequence
Connecting all four layers, here's the complete event flow for one "fix a bug" run:
User: "Fix this bug"
│
▼ AgentHarness.prompt()
│ phase="turn" → createTurnState() → session.buildContext()
│
▼ runAgentLoop()
│
├── agent_start
├── turn_start
├── message_start/end (user: "Fix this bug")
│
├── ── Inner loop · Turn 1 ──
│ ├── message_update (text: "Let me analyze this bug...")
│ ├── message_update (toolcall: read("src/bug.ts"))
│ ├── message_end
│ ├── tool_execution_start → tool_execution_end (read)
│ ├── turn_end ← save point: flush session writes
│ └── getSteeringMessages() → [] ← user didn't interrupt
│
├── ── Inner loop · Turn 2 ──
│ ├── message_update (toolcall: edit("src/bug.ts"))
│ ├── message_update (toolcall: bash("npm test"))
│ ├── tool_execution_start/end × 2 ← parallel execution
│ ├── turn_end
│ └── getSteeringMessages() → []
│
├── ── Inner loop · Turn 3 ──
│ ├── message_update (text: "Fixed, tests pass")
│ ├── message_end ← stopReason ≠ tool_use → exit inner
│ └── turn_end
│
├── Outer: getFollowUpMessages() → [] ← no queued tasks
└── agent_end
│
▼ AgentHarness: flushPendingSessionWrites + phase="idle" + emit(settled)
Note three critical moments:
turn_end= save point — queued session writes are flushed here (→ Chapter 2)- Exiting the inner loop requires "no toolCalls and no steering" — if the user steers after Turn 2 ("don't run the tests"), Turn 3 will see that message
- Only after
agent_enddoes the Harness release the Phase lock — the entire run is one atomic operation
Engineering Insights
-
5 lines of core + 792 lines of engineering: A teaching loop is just 5 lines of while-true, but pi's 792 lines handle streaming interruption, partial JSON tolerance, abort signal propagation, queue draining, error recovery — this is the cost of production grade.
-
Generators can't multicast: An AsyncGenerator has exactly one consumer. pi uses
agent.subscribe(fn)at the Agent layer for event broadcasting, letting TUI, session writes, and telemetry listen simultaneously (→ Chapter 2 AgentHarness). -
The cost of externalized state: The messages array grows with conversation length, eventually overflowing the model's context window. This necessitates compaction (→ Chapter 3 Compaction).
-
Dual loop = interactive capability: The inner/outer loop isn't just control-flow design — it's the engineering foundation for "users can interrupt while the agent is working." Without queue drain points, steering would have nowhere to inject.
Cross-Chapter Links
| Direction | Chapter | Relationship |
|---|---|---|
| Prerequisite | — | This chapter is the starting point |
| Next | Ch02 AgentHarness | Orchestration layer: Phase lock, 3 queues, Hooks, session management |
| Next | Ch03 Compaction | Solves the messages growth overflow problem |
| Next | Ch04 Session Tree | Data flushed at save points is written to the JSONL tree |
| Next | Ch05 Built-in Tools | Concrete tool implementations executed in the loop |
| Next | Ch07 Wire Protocols | The streaming protocol details behind streamFn |
Check Your Understanding
- If agentLoop were changed from AsyncGenerator to an
async functionreturningPromise<AgentEvent[]>, what capability would be lost? - The user types "don't use npm, use pnpm" while the agent is on Turn 2 — which queue does this message enter? When is it consumed?
- Why does the inner loop require "no toolCalls and no steering" to exit, rather than just checking stopReason?
- Why is
shouldStopAfterTurndesigned as an external callback rather than a hardcoded condition inside the loop?
Key Takeaways
- A turn is one LLM round; a run is one complete execution — steering injects immediately, follow-up catches at the end
- Four layers each own their scope: Harness orchestrates, Agent manages state, agent-loop is a pure loop, pi-ai unifies providers
- The loop is an AsyncGenerator — lazy, pausable, zero-dependency; events are the protocol
- The inner/outer dual loop is the engineering foundation for "interruptible while running"
- 5 lines is the core; 792 lines is engineering — production cost lives in edge cases
This article is based on source analysis of earendil-works/pi main branch.