Pi's Agent Loop serves well for observing a relatively well-defined Agent Runtime kernel. It doesn't directly hardcode product capabilities like Session Tree, Plan, SubAgent, and Workflow into the low-level loop. The core execution chain centers around Message, Model, Tool, Steering, Follow-up, and lifecycle events. Pi Coding Agent also explicitly emphasizes a minimal coding harness, extending more capabilities through Extensions and Packages.[1]
This article only traces the complete execution path of a single Prompt, without going through classes and files one by one:
Agent.prompt()
↓
runPromptMessages()
↓
createContextSnapshot()
↓
runAgentLoop()
↓
runLoop()
↓
Model Stream
↓
Tool Execution
↓
Tool Result
↓
Next Model Request / End
The analysis maintains two parallel lines:
Call Chain
How methods transition to the next phase
State Chain
How Agent State / Local Context changes at each stage
Following only the call chain easily leads to seeing many functions but still not understanding how the Runtime works. The truly noteworthy aspect of Pi's source code is how the low-level Loop, the Local Context for this execution, and the long-lived Agent State work together.
Why Agent is Wrapped Outside the Low-Level Loop
Pi's low-level execution logic resides in packages/agent/src/agent-loop.ts, while the outer Agent is in agent.ts. The source code describes Agent as a stateful wrapper around the low-level agent loop.[2]
This sentence essentially explains the responsibility division between the two layers.
agent-loop.ts is closer to an execution function. It receives Context, Prompt, Tool, Signal, and several callbacks, continuously advancing the model and tools.
Agent is a long-lived object, maintaining current configuration and runtime state.
Current Agent State roughly includes:
{
systemPrompt,
model,
thinkingLevel,
tools,
messages,
isStreaming,
streamingMessage,
pendingToolCalls,
errorMessage
}
The key point is that these fields carry different lifecycles, and the state isn't entirely bundled into messages.
We can separate the types of state:
Configuration State
systemPrompt / model / thinkingLevel / tools
Stable History
messages
In-Progress State
isStreaming / streamingMessage / pendingToolCalls
Error State
errorMessage
This already reflects a fundamental principle of Runtime: stable facts and execution state that's still changing should be represented separately.
For example, when an Assistant is streaming, streamingMessage continuously changes. If every chunk directly modified the stable transcript, it would be difficult for UI, persistence, and recovery to determine which message has already completed.
Pi chooses to let completed Messages enter messages, while in-progress half-products are represented by streamingMessage.
Tools are similar. pendingToolCalls represents Tools that are running, while Tool Results become stable messages only after completion.
Therefore, the outer Agent is essentially a Runtime State Holder.
Why prompt() Prohibits a Second Active Execution Chain
The entry point is simple:
await agent.prompt(input)
prompt() has explicit active state constraints and cannot be arbitrarily concurrent-called on the same Agent.
The current implementation checks activeRun. If the Agent is already active, calling prompt() again will directly error and require the caller to use steer(), followUp(), or wait for the current activity to end.[2]
This constraint is very important.
If two prompt() calls are allowed to run simultaneously:
Prompt A ──→ Loop A ──→ Agent.messages
Prompt B ──→ Loop B ──→ Agent.messages
Both Loops would read and modify the same Agent State simultaneously. Even without shared memory thread contention at the JavaScript level, logical race conditions would still occur:
A reads messages = M0
B reads messages = M0
A produces assistant A
B produces assistant B
A executes tool A
B executes tool B
Who determines the subsequent Context order? Which execution chain should Tool Results enter? Which Loop should Steering affect? All require additional coordination.
Pi chooses a simpler model:
One Agent
At the same time
Only one active processing chain
If new input needs to enter the current work, it must obtain explicit delivery semantics through Steering / Follow-up Queue.
This allows the transcript inside Agent to maintain monotonic progression without needing to handle multiple active branch concurrent writes in the core Loop.
runWithLifecycle() Establishes an Activity Interval
After input is converted to AgentMessage[] through normalizePromptInput(), it enters:
runPromptMessages(messages)
runPromptMessages() is wrapped by runWithLifecycle().
This layer is responsible for establishing the current activity interval, including:
Creating AbortController
Creating active promise
Setting isStreaming = true
Running actual Loop
Unified exception handling
Cleaning up temporary Runtime State at end
Therefore, before a Prompt truly requests the model, it has already entered the following state changes:
idle
↓ prompt()
activeRun created
isStreaming = true
↓
runAgentLoop()
Pi internally calls this activity object ActiveRun. Note that it's only a control object for the Runtime's current processing interval, not equivalent to a Run Entity that products might persist.
It mainly serves three needs:
Know if Agent already has an active task
Allow abort() to find the current AbortController
Allow whenIdle / promise to wait for current activity to end
This is also the actual source code basis for distinguishing Core Active Run from Application Run in the previous article.
Why Context Snapshot is Created Before Entering Loop
runPromptMessages() first creates the Context needed for Loop from Agent._state, rather than directly handing the entire State object to the low-level Loop:
createContextSnapshot()
The current implementation is minimal:
return {
systemPrompt: this._state.systemPrompt,
messages: this._state.messages.slice(),
tools: this._state.tools.slice(),
}
Then it calls:
runAgentLoop(
messages,
this.createContextSnapshot(),
this.createLoopConfig(options),
event => this.processEvents(event),
signal,
this.streamFunction,
)
This separation is worth examining carefully.
If the low-level Loop directly held Agent, it could read and write all fields at any time:
Agent
├── messages
├── streamingMessage
├── tools
├── model
├── listeners
└── queues
This would be more straightforward to implement, but the Loop would be highly coupled with the outer Runtime object. Testing Loop requires a complete Agent; reusing Loop makes it difficult to replace State Owner.
Pi chooses to give Loop an initial Context Snapshot:
Long-lived Agent State
│
│ snapshot
▼
Initial AgentContext
│
▼
Local Loop Context
The low-level Loop only handles the inputs it needs.
The use of slice() here also indicates Snapshot semantics: the array container is copied, so this Loop can continue extending its own message sequence without directly modifying outer Agent.messages through the same array reference.
This performs a shallow array copy; Message objects themselves may still share references. The key point is that collection progression is controlled by Loop Local Context itself.
When Does Current Prompt Enter Context
createContextSnapshot() only copies Agent.messages that existed before execution starts.
The new input from this prompt() is then added by runAgentLoop():
const currentContext = {
...context,
messages: [...context.messages, ...prompts],
}
Therefore, the initial state is:
Agent Stable Messages
+
Current Prompt
↓
Loop currentContext.messages
At the same time, Loop emits lifecycle events related to user messages:
agent_start
turn_start
message_start(user)
message_end(user)
These events flow back to the outer layer through Event Sink:
event => this.processEvents(event)
Agent.processEvents() appends the completed User Message to state.messages after messageend.[2][3]
A very important structure emerges here:
Loop Local Context
│
│ used for current computation
│
└──────────────┐
│ events
▼
Agent State
stable running state
The same User Message exists in two places: in Loop Local Context for this computation, and in long-term Agent State through events.
The two representations serve different responsibilities:
- Local Context provides continuous message sequence for ongoing computation;
- Agent State provides stable state for outer observers and subsequent execution.
Why Loop Doesn't Read Agent State Every Time
Another implementation can be imagined: reading complete history from Agent.messages before each model request.
Superficially this could reduce Local Context, but it would introduce new coupling.
Tool Results, Steering Messages, and temporary Context Transforms generated in the current Turn all need to be synchronized into Agent State first, so the next Model Request can see them. This way, low-level execution would be controlled by outer state commit timing.
Pi's Local Context allows the current Loop to maintain continuity by itself:
Prompt
↓
Assistant
↓
Tool Result
↓
Steering
↓
Assistant
This computation sequence can continue advancing within Loop; outer State gradually gets the same stable results through Events.
Therefore, it's closer to:
Local execution state machine
+
external state projection
Later, DSH Event Log will make this "execution fact → external projection" relationship even more thorough.
Why runLoop() Has Two Nested Loops
What truly controls Agent advancement is runLoop().
Compressing the details, the structure is roughly:
let pendingMessages = await getSteeringMessages()
while (true) { // outer: follow-up
let hasMoreToolCalls = true
while (hasMoreToolCalls || pendingMessages.length > 0) {
// inner: current work
// consume steering
// build provider context
// model request
// execute tools
// poll steering again
}
const followUps = await getFollowUpMessages()
if (followUps.length === 0) {
break
}
pendingMessages = followUps
}
These two nested loops directly encode Pi's judgment about execution boundaries.[3]
Inner Loop is responsible for "why current work needs to continue":
Model generated Tool Call
→ continue
Steering Message waiting to be consumed
→ continue
Outer Loop is responsible for "current work is ready to stop, but are there follow-up tasks":
Has Follow-up
→ enter subsequent work
No Follow-up
→ agent_end
Therefore, three types of input are placed at different temporal positions:
Tool Result
belongs to current model decision chain result
Steering
influences next model decision in current continuous work as soon as possible
Follow-up
continues after current continuous work ends
From the source structure, Pi's control semantics are directly implemented by Loop's consumption position; API documentation remains consistent with execution structure.
createLoopConfig() is Where Outer Runtime Injects Control Capability
runAgentLoop() doesn't directly know how Steering Queue and Follow-up Queue are stored in Agent. The outer layer passes these capabilities to Loop through createLoopConfig() in the form of callbacks.[2]
Conceptually close to:
Agent owns queues / policy
│
▼
createLoopConfig()
│
├── getSteeringMessages
├── getFollowUpMessages
├── transformContext
└── other loop options
│
▼
runLoop()
This position reflects the dependency direction: Loop depends on the capability of "how to obtain Steering Messages" without depending on PendingMessageQueue's specific implementation.
Therefore, if the upper layer needs to change message source, it can replace the callback; if a product doesn't need Steering, it can provide an empty implementation.
This kind of design makes agent-loop.ts easier to reuse than directly importing SessionManager, ExtensionManager, or some Queue type. The low-level execution only defines the capability contracts it needs; state containers continue to be held by the outer layer.
From an architectural perspective, createLoopConfig() and Event Sink constitute two opposite-direction interfaces respectively:
Outer Runtime ── config / callbacks ──→ Loop
Outer Runtime ←──── lifecycle events ── Loop
The former provides strategy and resources to the execution kernel; the latter returns execution facts to the outer layer. Loop can thus maintain relatively closed execution semantics.
What Transformations Happen Before a Model Request
When Inner Loop is preparing to initiate the next Assistant Response, it first processes messages pending entry into Context for this round.
Then Context can go through:
currentContext
↓
transformContext
↓
convertToLlm
↓
Provider Context
These layers are very suitable for distinguishing Runtime Message from Provider Message.
Agent Runtime may need to preserve richer message types internally, for example:
User Message
Assistant Message
Tool Result
Internal extension message
Custom context message
Provider doesn't necessarily accept these structures.
convertToLlm()'s role is to converge the protocol when actually requesting the model. Pi's default implementation preserves user, assistant, and toolResult three message types; Coding Agent can also provide its own conversion logic.[2]
Therefore, a more accurate data chain is:
Agent Messages
↓
Context Transform
↓
LLM-compatible Messages
↓
Provider Adapter
This boundary is important in complex Agents because Session, UI, and Model Provider shouldn't be forced to share the same message model.
Why Streaming Message Exists Separately
After the model starts Streaming, Loop continuously produces:
message_start
message_update*
message_end
Outer Agent.processEvents() reduces these events to Runtime State:
message_start / message_update
→ _state.streamingMessage = event.message
message_end
→ streamingMessage = undefined
→ _state.messages.push(event.message)
Thus the same Assistant output experiences two states:
Mutable / In-progress
streamingMessage
↓ message_end
Stable / Completed
messages[]
This boundary is very practical for frontends.
UI can listen to message_update to display tokens in real time, while the historical message list still uses completed Messages as stable units.
If subsequent persistence of incremental chunks is needed, Event Log can be done separately at a higher layer; low-level Agent doesn't need to treat each streaming update as a stable transcript.
Therefore, Pi here chooses a "running-state incremental + completed-state message" dual-layer representation.
How Assistant Message Triggers Tool Execution
After Assistant Message completes, Loop extracts Tool Calls from its content.
Conceptually, a Tool Call goes through:
tool_execution_start
↓
execute tool
↓
tool_execution_update*
↓
tool_execution_end
↓
ToolResultMessage
Outer Agent adds the corresponding toolCallId to pendingToolCalls at toolexecutionstart, and removes it after the end event.[2]
This means the runtime can simultaneously answer two questions:
What Tool Results are in history
Which Tools are still not executed
Tool Result then enters currentContext.messages and joins the message set newly generated by this Loop.
The next model call must maintain the correct Tool Call / Tool Result relationship:
Assistant
└── toolCall(id=1)
ToolResult(id=1)
Assistant
└── continues decision based on Tool Result
This belongs to Provider Context protocol constraints, unrelated to UI display.
Pi's related issue also recorded cases where isolated Tool Results were rejected by Provider.[4] For Runtime, message history must not only "have these contents" but also maintain the structural relationships required by the model protocol.
Multiple Tool Calls Also Involve a Batch Boundary
One Assistant Message can generate multiple Tool Calls simultaneously. The Runtime needs to decide how these Tools are executed in the current Step and when to consider this batch of Tool Work complete.
It can be abstracted as:
Assistant Message
├── Tool Call A
├── Tool Call B
└── Tool Call C
↓
Tool Execution Batch
↓
Tool Results A/B/C
↓
next decision boundary
Regardless of whether the specific implementation uses serial or parallel execution, the next model request needs to get the complete result set corresponding to Tool Calls, or get a clear error/interruption result.
This affects Steering's delivery timing. If the current model produces A, B, C three Tool Calls at once, Steering entering Context after A completes might cause B, C to still execute with old decisions, while the model has already seen new constraints for subsequent decisions. Pi documentation places Steering delivery after Tool Calls of the current Assistant Turn complete, before the next LLM Call.[5]
Therefore, Tool Batch itself is also a stable boundary. Runtime needs to first complete the direct effects already produced by the previous model decision, then let new input affect the next decision.
This kind of boundary is especially important when supporting parallel Tools. Concurrency can optimize execution time but cannot break the correspondence between Tool Call / Tool Result in Context and Step completion conditions.
Why Tool Result First Enters Local Context
After tool execution completes, the most direct need is that the next Model Request must immediately see the result.
Therefore, Tool Result first belongs to the current Loop's computation state:
currentContext.messages += ToolResult
Then it synchronizes to outer state through Event mechanism.
If, conversely, Tool Result must first be persisted, then Context rebuilt from Session, the low-level Loop would be blocked by persistence implementation and difficult to use as an independently executable kernel.
Pi's design allows persistence to be an outer consumer:
Tool Execution
↓
Tool Result
↓
Local Context continues
↓
Lifecycle Event
↓
Agent State / Session / UI
This sequence decouples "whether current execution can continue" from "how external records current state".
If a product requires strict crash recovery, the outer layer can further introduce durable Event Log and require key Events to be written to disk before advancing. This belongs to higher-level persistence semantics and doesn't need to enter the minimal Loop.
Why Steering is Checked After Tool Completes
During tool execution, new Steering Message may have entered Queue.
Pi doesn't directly change current Context mid-tool execution. After Tool completes, Inner Loop calls again:
getSteeringMessages()
Agent.createLoopConfig() binds it to steeringQueue.drain().[2]
Therefore, the actual delivery order is:
Model Request
↓
Assistant requests Tool
↓
Tool Execution
│
│ steering arrives
│
↓
Tool Result
↓
Drain Steering Queue
↓
Append Steering Message
↓
Next Model Request
Pi RPC documentation also explicitly specifies that Steer is delivered after Tool Calls of the current Assistant Turn execute complete, before the next LLM Call.[5]
This design mainly protects two stable boundaries.
First, already started Tool Calls won't have their parameter semantics suddenly changed by new natural language input.
Second, the next Model Request can see both Tool Result and Steering Message, continuing decisions from the new complete state.
Abort should be used when needing to immediately terminate current Tool; Steer remains as subsequent decision input.
This keeps "modifying next direction" and "terminating ongoing work" as two independent control operations.
Why Event Sink is the Key Connection Point of the Entire Structure
Low-level runLoop() doesn't directly write Agent._state. It emits events to Event Sink:
runLoop
│
├── agent_start
├── turn_start
├── message_start
├── message_update
├── message_end
├── tool_execution_start
├── tool_execution_update
├── tool_execution_end
├── turn_end
└── agent_end
│
▼
Agent.processEvents()
processEvents() on one hand updates outer Runtime State, on the other hand notifies listeners of events.
Thus the same execution fact can serve multiple consumers:
Lifecycle Event
│
├── Agent State reducer
├── Terminal / UI
├── Session persistence
├── Extension hook
└── Telemetry
Low-level Loop doesn't need to know whether these consumers exist.
This is easier to maintain core boundaries than directly calling in Loop:
ui.update(...)
session.save(...)
extensions.emit(...)
telemetry.record(...)
Pi Harness V2's design also emphasizes that底层 agent-loop building blocks don't own durable state, and don't need to understand Session, record, or lane.[6]
From an architectural perspective, Event Sink is a narrow interface between execution kernel and external Harness.
Boundary Between Event and Event Sourcing
One easily confusing issue still needs distinguishing here.
Pi Loop emits lifecycle Events, which doesn't mean low-level Agent has adopted Event Sourcing.
Event can just be a notification mechanism:
state mutation
+
event notification
Event Sourcing requires events to become durable source of truth, with state derived from Event Log projection.
Pi's current low-level Agent still directly maintains _state.messages, streamingMessage, and pendingToolCalls. Events are responsible for driving and notifying these state changes.
DeepSeek Harness's Session Event Log elevates events to persistent fact source level, which is a design difference the second unit will focus on analyzing.
Keeping this distinction avoids categorizing a system as Event Sourcing simply by seeing emit(event).
How Abort Penetrates the Current Execution Chain
AbortController created by runWithLifecycle() passes signal into low-level execution chain.[2]
Therefore, cancellation semantics can penetrate from Agent outer layer through to model call and Tool execution.
The ideal path is:
agent.abort()
↓
AbortController.abort()
↓
signal aborted
↓
model / tool observes signal
↓
current active execution stops
Whether a specific Tool can respond to cancellation in time depends on whether the Tool implementation correctly handles Signal. Runtime can only propagate cancellation intent; it cannot force a completely synchronous blocking function that doesn't check Signal to terminate immediately.
This also explains the fundamental difference between Abort and Steering.
Steering changes subsequent decisions; Abort directly acts on current activity lifecycle.
If these two control dimensions are mixed together, user input behavior becomes unpredictable.
Why Follow-up is Placed in Outer Loop
When Inner Loop has no Tool Call and no Steering Message, the current continuous work reaches a natural stopping point.
At this point, Runtime checks Follow-up Queue:
Current work reaches stop boundary
↓
getFollowUpMessages()
│
├── empty → agent_end
│
└── has messages
↓
continue outer loop
Therefore, Follow-up doesn't participate in the most recent model decision of current Turn; it forms subsequent work after current work completes.
If this layer of semantics shared a Queue with Steering, it would need to rejudge "does this message belong to current work or next segment" at each consumption. Pi directly uses two types of Queues and two nested loops to express this, keeping code structure consistent with product semantics.
DeepSeek Harness further abstracts this difference into next-step / next-turn Target, which the next article will continue comparing.
Complete State Chain of One Prompt
Merging the previous local processes, complete state changes can be obtained:
Agent.prompt()
↓
activeRun created
isStreaming = true
↓
Context Snapshot
↓
current Prompt appended to Local Context
↓
user message events
↓
Agent.messages += user message
↓
Provider Context built
↓
Model Stream
↓
streamingMessage updates
↓
assistant message_end
↓
Agent.messages += assistant
↓
Tool Call ?
│
├─ yes
│ ↓
│ pendingToolCalls += id
│ ↓
│ execute tool
│ ↓
│ pendingToolCalls -= id
│ ↓
│ ToolResult → Local Context
│ ↓
│ drain Steering
│ ↓
│ next Model Request
│
└─ no
↓
Steering pending ?
│
├─ yes → next Model Request
│
└─ no
↓
Follow-up pending ?
│
├─ yes → continue outer loop
│
└─ no
↓
agent_end
↓
cleanup Runtime State
↓
idle
This diagram can basically serve as the main line for reading agent.ts + agent-loop.ts.
From Source Reading Perspective, Three Boundaries are Most Worth Retaining
After reading this call chain, Pi's specific method names can be temporarily set aside; three structural boundaries are more worth retaining.
The first is the boundary between State and Context. Agent._state exists long-term; Loop uses Snapshot and Local Context to advance current computation.
The second is the boundary between Callback and Event. Outer layer provides strategy and resources through Loop Config; low-level returns execution facts to external through Lifecycle Event.
The third is the boundary between Step-like and Turn-like. Tool / Steering continue current work in inner layer; Follow-up enters subsequent work after stop boundary.
State ──snapshot──→ Local Context
Runtime ──callbacks──→ Loop ──events──→ Runtime
Inner Loop ──stop boundary──→ Outer Loop
These three boundaries are more stable than individually remembering some function names, and easier to migrate to source reading of other Agent Frameworks.
What Core Semantics Pi Loop Retains
From the source code, the responsibilities of low-level Agent Loop can be converged into six categories:
Context Preparation
Model Invocation
Tool Execution
Input Delivery
Cancellation / Stop Condition
Lifecycle Events
Session Tree, long-term persistence, Plan, SubAgent, Workflow, and UI are all not fixed into this execution kernel.
This boundary has an obvious advantage: the same Loop can be reused by different product layers. Terminal Coding Agent can connect SessionManager and Extensions at outer layer; other applications can also use only the low-level Agent.
The cost also exists. Many advanced capabilities need to be supplemented by outer Runtime itself, such as crash recovery, durable run, branch, approval, and multi-Agent orchestration. Pi chooses to keep the core small, leaving these complexities to higher layers.
The design judgment formed in this article is:
A reusable Agent Loop should prioritize maintaining execution semantics. Context, Model, Tool, Input Delivery, Cancellation, and Lifecycle Event constitute a relatively stable kernel; long-term Session, Workflow, and UI can be built outside this kernel.
The next article continues analyzing Input Delivery. How Pi's Steering / Follow-up Queue maps to Step / Turn boundaries, and how DeepSeek Harness converges these APIs into a more general delivery model through Inbox's target + wakeup.
References
[1] Pi Coding Agent README: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/README.md
[2] Pi Agent: https://github.com/badlogic/pi-mono/blob/main/packages/agent/src/agent.ts
[3] Pi agent-loop.ts: https://github.com/badlogic/pi-mono/blob/main/packages/agent/src/agent-loop.ts
[4] Pi issue #2119: https://github.com/badlogic/pi-mono/issues/2119
[5] Pi RPC docs: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/rpc.md
[6] Pi Harness V2 notes: https://github.com/badlogic/pi-mono/blob/main/packages/agent/docs/harness-v2.md