Pi's Agent Loop is useful for studying the core of an Agent Runtime with relatively clear boundaries. It doesn't hard-code product-level capabilities such as Session Tree, Plan, SubAgent, and Workflow directly into the low-level loop; instead, its core execution chain revolves around Message, Model, Tool, Steering, Follow-up, and lifecycle events. Pi Coding Agent also explicitly emphasizes a minimal coding harness and expands its capabilities through Extensions and Packages. [1]
This article traces only the complete execution path of a single prompt, rather than examining each class and file in turn:
The analysis follows two threads at once:
调用链
方法如何进入下一阶段
状态链
Agent State / Local Context 在每个阶段如何变化
If you follow only the call chain, you can end up seeing a large number of functions without understanding how the Runtime actually works. The part of Pi's source code most worth examining is how the low-level Loop, the Local Context for the current execution, and the long-lived Agent State work together.
Why Pi Wraps the Agent in a Low-Level Loop
Pi's low-level execution logic lives in packages/agent/src/agent-loop.ts, while the outer Agent lives in agent.ts. The source describes the Agent as a stateful wrapper around the low-level agent loop. [2]
This sentence essentially explains the division of responsibilities between the two layers.
agent-loop.ts is closer to an execution function. It takes a Context, Prompt, Tool, Signal, and several callbacks, and continuously drives the model and tools.
The Agent, by contrast, is a long-lived object that maintains the current configuration and runtime state.
The current Agent State roughly consists of:
The key is that these fields have different lifecycles; the state isn't all lumped into messages.
Several kinds of state can be separated:
This already reflects a basic Runtime principle: stable facts and execution state that is still changing should be represented separately.
For example, while the Assistant is streaming its response, streamingMessage keeps changing. If every chunk directly modified the stable transcript, it would be difficult for the UI, persistence, and recovery to determine which message had finished.
Pi lets a completed Message enter messages, while streamingMessage represents the half-finished message during execution.
Tools work similarly. pendingToolCalls represents the Tool currently running, while a Tool Result becomes a stable message only after it has completed.
The outer Agent is therefore essentially a Runtime State Holder.
Why prompt() Forbids a Second Active Execution Chain
The entry point is simple:
prompt() has an explicit active-state constraint: it cannot be invoked concurrently on the same Agent.
The current implementation checks activeRun. If the Agent is already active, a second prompt() call throws immediately, directing the caller to use steer(), followUp(), or wait for the current activity to finish. [2]
This constraint matters.
If two prompt() calls were allowed to run at once:
Prompt A ──→ Loop A ──→ Agent.messages
Prompt B ──→ Loop B ──→ Agent.messages
Both Loops would read and modify the same Agent State at the same time. Even if JavaScript has no shared-memory thread race, logical races would still arise:
A 读取 messages = M0
B 读取 messages = M0
A 产生 assistant A
B 产生 assistant B
A 执行 tool A
B 执行 tool B
Who determines the subsequent Context order, which execution chain a Tool Result belongs to, and which Loop a Steering Message affects would all require additional coordination.
Pi chooses a simpler model:
一个 Agent
同一时刻
只有一条 active processing chain
New input can enter the current work only through the Steering / Follow-up Queue, with explicit delivery semantics.
This keeps the Agent's internal transcript advancing monotonically, without requiring the core Loop to handle concurrent writes from multiple active branches.
runWithLifecycle() Establishes an Active Interval
After normalizePromptInput() converts the input to AgentMessage[], it enters:
runPromptMessages() is wrapped by runWithLifecycle().
This layer establishes the active interval for the current run, including:
创建 AbortController
创建 active promise
设置 isStreaming = true
运行实际 Loop
统一处理异常
结束时清理临时 Runtime State
Thus, before a Prompt makes an actual model request, it has already entered the following state transition:
idle
↓ prompt()
activeRun created
isStreaming = true
↓
runAgentLoop()
Pi internally calls this active object an ActiveRun. Note that it is only the control object for the Runtime's current processing interval; it is not equivalent to a Run Entity that the product layer might persist.
It primarily serves three needs:
知道当前 Agent 是否已有活动任务
允许 abort() 找到当前 AbortController
允许 whenIdle / promise 等待当前活动结束
This is also the concrete source-code basis for distinguishing Core Active Run from Application Run in the previous installment.
Why the Context Snapshot Is Created Before Entering the Loop
runPromptMessages() first builds the Context the Loop needs from Agent._state, rather than handing the entire State object directly to the low-level Loop:
The current implementation is small:
It then calls:
This separation deserves a closer look.
If the low-level Loop held the Agent directly, it could read and write every field at any time:
Agent
├── messages
├── streamingMessage
├── tools
├── model
├── listeners
└── queues
That implementation is more direct, but it tightly couples the Loop to the outer Runtime object. Testing the Loop would require a complete Agent, and reusing it would make it difficult to replace the State Owner.
Pi instead gives the Loop an initial Context Snapshot:
Long-lived Agent State
│
│ snapshot
▼
Initial AgentContext
│
▼
Local Loop Context
The low-level Loop handles only the inputs it needs.
The use of slice() also reveals the semantics of a Snapshot: the array containers are copied, so the current Loop can keep extending its own message sequence without modifying the outer Agent.messages through the same array reference.
This is a shallow array copy; the Message objects themselves may still share references. The key point is that progression of the collection is controlled by the Loop Local Context.
When the Current Prompt Enters the Context
createContextSnapshot() copies only the Agent.messages that existed before execution began.
The new input from the current prompt() is subsequently added by runAgentLoop():
The initial state is therefore:
Agent Stable Messages
+
Current Prompt
↓
Loop currentContext.messages
At the same time, the Loop emits lifecycle events related to the user message:
agent_start
turn_start
message_start(user)
message_end(user)
These events return to the outer layer through the Event Sink:
After messageend, Agent.processEvents() appends the completed User Message to state.messages. [2][3]
At this point, a highly important structure emerges:
The same User Message now exists in two places: in the Loop Local Context for use by the current computation, and in the long-lived Agent State by way of events.
The two representations serve different purposes:
- Local Context provides a continuous message sequence for the computation in progress;
- Agent State provides stable state for outer observers and subsequent executions.
Why the Loop Doesn't Reread Agent State Every Time
Consider an alternative implementation that reloads the full history from Agent.messages before every model request.
On the surface, this could reduce the Local Context, but it introduces new coupling.
Any Tool Result, Steering Message, or temporary Context Transform produced during the current Turn would first have to be synced into the Agent State before the next Model Request could see it. The low-level execution process would then be governed by the outer state's commit timing.
Pi's Local Context lets the current Loop maintain continuity on its own:
Prompt
↓
Assistant
↓
Tool Result
↓
Steering
↓
Assistant
This sequence can keep advancing inside the Loop, while the outer State receives the same stable results incrementally through Events.
It is therefore closer to:
Local execution state machine
+
external state projection
The later DSH Event Log will make this “execution fact → external projection” relationship more complete.
Why runLoop() Has Two Layers of Loops
The function that actually controls Agent progression is runLoop().
With the details compressed, its structure is roughly:
These two loops directly encode Pi's definition of execution boundaries. [3]
The Inner Loop handles the question of why the current work must continue:
模型产生了 Tool Call
→ 继续
有 Steering Message 等待消费
→ 继续
The Outer Loop handles the question of whether more work remains once the current work is ready to stop:
有 Follow-up
→ 进入后续工作
没有 Follow-up
→ agent_end
Thus, three kinds of input are placed at different points in time:
Tool Result
属于当前模型决策链的结果
Steering
尽快影响当前连续工作中的下一次模型决策
Follow-up
等当前连续工作结束后再继续
As the source structure shows, Pi's control semantics are implemented directly in where the Loop consumes these inputs; the API documentation and execution structure remain aligned.
createLoopConfig() Is Where the Outer Runtime Injects Control Capabilities
runAgentLoop() has no direct knowledge of how the Steering Queue and Follow-up Queue are stored in the Agent. The outer layer passes these capabilities to the Loop as callbacks through createLoopConfig(). [2]
Conceptually:
This position reflects the dependency direction: the Loop depends on the capability to obtain a Steering Message, not on the concrete PendingMessageQueue implementation.
So if an upper layer needs to change where messages come from, it can replace the callback; if a product does not need Steering, it can supply an empty implementation.
This design is easier to reuse than having agent-loop.ts directly import SessionManager, ExtensionManager, or a specific Queue type. The low-level execution layer defines only the capability contracts it needs, while the outer layer continues to own the state containers.
Architecturally, createLoopConfig() and the Event Sink form two interfaces that run in opposite directions:
Outer Runtime ── config / callbacks ──→ Loop
Outer Runtime ←──── lifecycle events ── Loop
The former supplies policy and resources to the execution core; the latter returns execution facts to the outside. This lets the Loop maintain relatively closed execution semantics.
What Transformations Occur Before a Model Request
Just before the Inner Loop issues the next Assistant Response, it must process the messages waiting to enter the Context for this turn.
The Context can then pass through:
currentContext
↓
transformContext
↓
convertToLlm
↓
Provider Context
These layers are well suited to distinguishing Runtime Messages from Provider Messages.
The Agent Runtime may internally need to retain richer message types, such as:
用户消息
Assistant Message
Tool Result
内部扩展消息
自定义上下文消息
A Provider may not accept those structures.
The role of convertToLlm() is to narrow the protocol at the point of the actual model request. Pi's default implementation preserves the user, assistant, and toolResult message types, while Coding Agent can provide its own conversion logic. [2]
The more accurate data chain is therefore:
Agent Messages
↓
Context Transform
↓
LLM-compatible Messages
↓
Provider Adapter
This boundary matters in complex Agents because Session, UI, and Model Provider should not be forced to share a single message model.
Why Streaming Message Exists as a Separate Entity
Once the model starts Streaming, the Loop produces a continuous sequence of:
message_start
message_update*
message_end
The outer Agent.processEvents() folds these events into Runtime State:
message_start / message_update
→ _state.streamingMessage = event.message
message_end
→ streamingMessage = undefined
→ _state.messages.push(event.message)
The same Assistant output therefore passes through two states:
Mutable / In-progress
streamingMessage
↓ message_end
Stable / Completed
messages[]
This boundary is especially useful for a frontend.
The UI can listen for message_update to display tokens in real time, while the message history still uses a completed Message as its stable unit.
If incremental chunks need to be persisted later, a separate Event Log can live at an even higher layer; the low-level Agent doesn't need to treat every streaming update as part of the stable transcript.
Pi therefore uses a dual representation here: “incremental runtime state + completed messages.”
How an Assistant Message Triggers Tool Execution
Once the Assistant Message is complete, the Loop extracts Tool Calls from its content.
Conceptually, a Tool Call goes through the following stages:
tool_execution_start
↓
execute tool
↓
tool_execution_update*
↓
tool_execution_end
↓
ToolResultMessage
At toolexecutionstart, the outer Agent adds the corresponding toolCallId to pendingToolCalls and removes it after the end event. [2]
This allows the runtime to answer two questions at the same time:
历史里有哪些 Tool Result
当前还有哪些 Tool 没执行完
The Tool Result then enters currentContext.messages and the set of new messages produced by the current Loop.
The next model invocation must preserve the correct Tool Call / Tool Result relationship:
Assistant
└── toolCall(id=1)
ToolResult(id=1)
Assistant
└── 根据 Tool Result 继续决策
This is a Provider Context protocol constraint and has nothing to do with UI presentation.
A related Pi issue also records cases where an orphaned Tool Result was rejected by the Provider. [4] For the Runtime, message history must not merely “contain these items”; it must also preserve the structural relationships required by the model protocol.
Multiple Tool Calls Also Involve a Batch Boundary
A single Assistant Message can produce multiple Tool Calls. At that point, the Runtime must decide how the tools execute within the current Step and when the batch of tool work is considered complete.
This can be abstracted as:
Regardless of whether the implementation runs them serially or in parallel, the next model request needs either a complete result set corresponding to the Tool Calls or an explicit error/interruption result.
This affects when Steering is delivered. If the current model produces three Tool Calls—A, B, and C—and Steering enters the Context after A completes, B and C may still execute the old decision even though the model later sees the new constraint. The Pi documentation places Steering delivery after the Tool Calls in the current Assistant Turn have completed and before the next LLM Call. [5]
The Tool Batch is therefore a stable boundary too. The Runtime must finish the direct effects produced by the previous model decision before new input can influence the next decision.
These boundaries are especially important when parallel Tools are supported. Concurrency can optimize execution time, but it must not break the Tool Call / Tool Result relationships in the Context or the completion conditions for the Step.
Why a Tool Result Enters the Local Context First
After a tool finishes, the immediate requirement is for the next Model Request to see the result.
The Tool Result therefore belongs first to the current Loop's computation state:
currentContext.messages += ToolResult
It is then synchronized to the outer state through the Event mechanism.
If the order were reversed, and the Tool Result had to be persisted before rebuilding the Context from a Session, the low-level Loop would be blocked by the persistence implementation and would be difficult to use as a standalone execution core.
Pi's design allows persistence to become an outer-layer consumer:
Tool Execution
↓
Tool Result
↓
Local Context continues
↓
Lifecycle Event
↓
Agent State / Session / UI
This sequence decouples “whether the current execution can continue” from “how the outside records the current state.”
If a product requires strict crash recovery, the outer layer can introduce a durable Event Log and require critical Events to reach disk before execution advances. That belongs to higher-level persistence semantics and doesn't need to enter the minimal Loop.
Why Steering Is Checked After Tool Execution
While a tool is running, a new Steering Message may already have entered the Queue.
Pi doesn't change the current Context midway through tool execution. Once the Tool completes, the Inner Loop calls the following again:
Agent.createLoopConfig() binds it to steeringQueue.drain(). [2]
The actual delivery order is therefore:
The Pi RPC documentation likewise specifies that Steer is delivered after the Tool Calls in the current Assistant Turn have completed and before the next LLM Call. [5]
This design primarily protects two stable boundaries.
First, a Tool Call that has already started doesn't suddenly acquire different parameter semantics because of new natural-language input.
Second, the next Model Request can see both the Tool Result and the Steering Message and continue making decisions from the new, complete state.
To terminate the current Tool immediately, use Abort; Steer remains input for the next decision.
This keeps “change the next direction” and “terminate work in progress” as two separate control operations.
Why the Event Sink Is the Key Connection Point
The low-level runLoop() doesn't write Agent._state directly. Instead, it emits events to an Event Sink:
processEvents() both updates the outer Runtime State and notifies listeners.
The same execution fact can therefore serve several consumers:
The low-level Loop doesn't need to know whether those consumers exist.
This is easier to maintain the core boundary than calling the following directly inside the Loop:
Pi Harness V2's design also emphasizes that the underlying agent-loop building blocks don't own durable state and don't need to understand Sessions, records, or lanes. [6]
Architecturally, the Event Sink is a narrow interface between the execution core and the external Harness.
The Boundary Between Events and Event Sourcing
One more easily confused distinction is worth making.
The fact that the Pi Loop emits lifecycle Events doesn't mean the low-level Agent has adopted Event Sourcing.
An Event may be nothing more than a notification mechanism:
state mutation
+
event notification
Event Sourcing, by contrast, requires Events to become the durable source of truth, with state projected from the Event Log.
Pi's current low-level Agent still directly maintains _state.messages, streamingMessage, and pendingToolCalls. The Events drive and announce those state changes.
The Session Event Log in DeepSeek Harness elevates Events to the level of a persistent source of truth; the second installment will focus on that design difference.
Keeping the distinction clear prevents us from classifying a system as Event Sourcing as soon as we see an emit(event).
How Abort Flows Through the Current Execution Chain
The AbortController created by runWithLifecycle() passes its signal through the low-level execution chain. [2]
Cancellation semantics can therefore flow from the outer Agent through to model calls and tool execution.
The ideal path is:
agent.abort()
↓
AbortController.abort()
↓
signal aborted
↓
model / tool observes signal
↓
current active execution stops
Whether a particular Tool can respond promptly depends on whether its implementation handles the Signal correctly. The Runtime can propagate the intent to cancel, but it can't force a synchronous blocking function that never checks the Signal to stop immediately.
This also highlights the fundamental difference between Abort and Steering.
Steering changes subsequent decisions; Abort acts directly on the current active lifecycle.
If these two control dimensions were mixed together, the behavior of user input would become difficult to predict.
Why Follow-up Lives in the Outer Loop
Once the Inner Loop has no more Tool Calls and no Steering Messages, the current continuous work has reached its natural stopping point.
Only then does the Runtime check the Follow-up Queue:
Follow-up therefore doesn't participate in the most recent model decision of the current Turn; it forms subsequent work after the current work completes.
If this semantic layer shared a Queue with Steering, every consumption would have to reclassify the message as belonging either to the current work or the next segment. Pi expresses the distinction directly through two Queue types and two loops, keeping the code structure aligned with the product semantics.
DeepSeek Harness goes further and abstracts this difference into a next-step / next-turn Target, which the next installment will compare.
The Complete State Chain for One Prompt
Combining the partial flows above yields the complete state transition:
This diagram can serve as the main thread for reading agent.ts + agent-loop.ts.
The Three Boundaries Most Worth Retaining from a Source-Code Reading Perspective
After following this call chain, you can set aside Pi's exact method names for a moment. Three structural boundaries are more worth retaining.
The first is the boundary between State and Context. Agent._state is long-lived, while the Loop uses a Snapshot and Local Context to advance the current computation.
The second is the boundary between Callback and Event. The outer layer supplies policy and resources through Loop Config, while the low-level layer returns execution facts through Lifecycle Events.
The third is the boundary between Step-like and Turn-like behavior. Tool / Steering continue the current work in the inner loop, while Follow-up enters subsequent work after the stopping boundary.
State ──snapshot──→ Local Context
Runtime ──callbacks──→ Loop ──events──→ Runtime
Inner Loop ──stop boundary──→ Outer Loop
These three boundaries are more stable than memorizing any single function name, and they transfer more easily to source-code reading in other Agent Frameworks.
Which Core Semantics the Pi Loop Preserves
From the source, the responsibilities of the low-level Agent Loop can be distilled 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 not baked into this execution core.
This boundary has an obvious advantage: the same Loop can be reused by different product layers. A Terminal Coding Agent can attach SessionManager and Extensions on the outside; other applications can use only the low-level Agent.
The tradeoff exists as well. Many advanced capabilities must be supplied by the outer Runtime, such as crash recovery, durable runs, branches, approvals, and multi-Agent orchestration. Pi keeps the core small and leaves that complexity to a higher layer.
The design judgment formed in this installment is:
A reusable Agent Loop should prioritize execution semantics. Context, Model, Tool, Input Delivery, Cancellation, and Lifecycle Event form a relatively stable core; long-lived Session, Workflow, and UI can sit outside it.
The next installment will continue with Input Delivery: how Pi's Steering / Follow-up Queue maps to Step / Turn boundaries, and how DeepSeek Harness uses an Inbox's target + wakeup to distill these APIs into a more general delivery model.
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