ππ Agent
Chapter 1 / 21

Agent Loop

Heart of the conversation loop

Every Agent is a loop: stream model → execute tool → feed back
No simulation scenario available for this chapter

Deep Dive

~19 min read·19048 chars

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

FileResponsibility
packages/agent/src/agent.tsAgent class: holds state, exposes prompt(), drives the loop
packages/agent/src/agent-loop.tsrunAgentLoop: turn progression, tool execution, queue draining
packages/agent/src/types.tsAgentState / 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:

ConceptDefinitionAnalogy
turnOne complete round of "LLM response + possible tool calls"A player making one move
runThe full execution from one user input until the agent finally stops (contains multiple turns)An entire game
messageThe carrier of information between user, agent, and toolsThe move record on the board
steeringAn intervention message the user types mid-run — processed immediately after the current turn completesA coach shouting from the sideline; adjust on the very next play
follow-upA message the user types mid-run, but held until the agent is about to stop naturallyWaiting in line; deal with it after this game ends
save pointA consistency checkpoint between turns — queued session writes are flushed hereA 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:

1 AgentHarness · Orchestration Phase state machine · config snapshots · 3 queues · Hooks · session management 2 Agent · Stateful Wrapper Concurrency lock · transcript maintenance · steer / followUp queues 3 agent-loop · Stateless Loop Core Turn lifecycle · tool execution · queue draining · event emission 4 pi-ai · LLM Abstraction 40+ providers unified streaming · auth · retry · stream parsing

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.api to 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:

ApproachProsFatal Flaw
Callbacks (onEvent)SimpleNo pause/resume, inverted control flow
RxJS ObservableRich operatorsHeavy dependency; pi demands minimal core
Promise<AgentEvent[]>SimplestMust wait for completion, no streaming
AsyncGeneratorLazy, pausable, for-await consumption, zero depsCannot 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?

ScenarioSingle loopDual loop
User shouts "don't use npm, use pnpm" mid-runCannot handle; must wait for run to endsteering: injected immediately after current turn
User appends "after fixing, also run the tests"Cannot handlefollow-up: caught when agent is about to stop; work continues
External max-turns limitHardcoded in the loopshouldStopAfterTurn 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 / convertToLlm are both injected from outside (assembled by Agent or AgentHarness); the loop itself knows nothing about "what messages look like"
  • streamFn is pi-ai's unified entry: routes by model.api to the corresponding provider, handling auth, retries, stream parsing
  • Events are raised via the emit callback — upper layers dispatch solely on AgentEvent.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:

  1. turn_end = save point — queued session writes are flushed here (→ Chapter 2)
  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
  3. Only after agent_end does the Harness release the Phase lock — the entire run is one atomic operation

Engineering Insights

  1. 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.

  2. 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).

  3. 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).

  4. 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

DirectionChapterRelationship
PrerequisiteThis chapter is the starting point
NextCh02 AgentHarnessOrchestration layer: Phase lock, 3 queues, Hooks, session management
NextCh03 CompactionSolves the messages growth overflow problem
NextCh04 Session TreeData flushed at save points is written to the JSONL tree
NextCh05 Built-in ToolsConcrete tool implementations executed in the loop
NextCh07 Wire ProtocolsThe streaming protocol details behind streamFn

Check Your Understanding

  1. If agentLoop were changed from AsyncGenerator to an async function returning Promise<AgentEvent[]>, what capability would be lost?
  2. 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?
  3. Why does the inner loop require "no toolCalls and no steering" to exit, rather than just checking stopReason?
  4. Why is shouldStopAfterTurn designed 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.