Pi 的 Agent Loop 适合用来观察一个边界较清晰的 Agent Runtime 内核。它没有把 Session Tree、Plan、SubAgent、Workflow 等产品能力直接固化到低层循环中,核心执行链主要围绕 Message、Model、Tool、Steering、Follow-up 和生命周期事件展开。Pi Coding Agent 也明确强调 minimal coding harness,并通过 Extensions 和 Packages 扩展更多能力。[1]
本文只追踪一次 Prompt 的完整执行路径,不按类和文件逐项展开:
Agent.prompt()
↓
runPromptMessages()
↓
createContextSnapshot()
↓
runAgentLoop()
↓
runLoop()
↓
Model Stream
↓
Tool Execution
↓
Tool Result
↓
Next Model Request / End
分析同时保留两条线:
调用链
方法如何进入下一阶段
状态链
Agent State / Local Context 在每个阶段如何变化
如果只追调用链,很容易看到大量函数却仍然不清楚 Runtime 如何工作。Pi 这部分源码真正值得观察的地方,是低层 Loop、本次执行的 Local Context、长期存在的 Agent State 三者如何配合。
Agent 为什么要包在低层 Loop 外面
Pi 的低层执行逻辑位于 packages/agent/src/agent-loop.ts,外层 Agent 位于 agent.ts。源码将 Agent 描述为 low-level agent loop 的 stateful wrapper。[2]
这句话基本说明了两层的职责分工。
agent-loop.ts 更接近一个执行函数。它接收 Context、Prompt、Tool、Signal 和若干回调,持续推进模型和工具。
Agent 则是长期对象,维护当前配置和运行状态。
当前 Agent State 大致包括:
{
systemPrompt,
model,
thinkingLevel,
tools,
messages,
isStreaming,
streamingMessage,
pendingToolCalls,
errorMessage
}
关键在于这些字段承担不同生命周期,状态没有全部归入 messages。
可以把几类状态分开:
配置状态
systemPrompt / model / thinkingLevel / tools
稳定历史
messages
执行中状态
isStreaming / streamingMessage / pendingToolCalls
错误状态
errorMessage
这已经体现了 Runtime 的一个基本原则:稳定事实与仍在变化的执行状态应当分开表示。
例如 Assistant 正在流式生成时,streamingMessage 会持续变化。此时如果每个 chunk 都直接修改稳定 transcript,UI、持久化和恢复都会很难判断哪一条消息已经完成。
Pi 选择让完成后的 Message 进入 messages,执行中的半成品由 streamingMessage 表示。
Tool 也类似。pendingToolCalls 表达正在运行的 Tool,而 Tool Result 完成后才形成稳定消息。
因此外层 Agent 本质上是一个 Runtime State Holder。
prompt() 为什么禁止第二条主动执行链
入口很简单:
await agent.prompt(input)
prompt() 具有明确的活动状态约束,不能在同一 Agent 上任意并发调用。
当前实现会检查 activeRun。如果 Agent 已经处于活动状态,再调用一次 prompt() 会直接报错,并要求调用方使用 steer()、followUp(),或者等待当前活动结束。[2]
这个约束非常重要。
如果允许两个 prompt() 同时运行:
Prompt A ──→ Loop A ──→ Agent.messages
Prompt B ──→ Loop B ──→ Agent.messages
两个 Loop 会同时读取并修改同一份 Agent State。即使 JavaScript 层面没有共享内存线程竞争,也仍然会产生逻辑竞态:
A 读取 messages = M0
B 读取 messages = M0
A 产生 assistant A
B 产生 assistant B
A 执行 tool A
B 执行 tool B
后续 Context 顺序由谁决定,Tool Result 应该进入哪一条执行链,Steering 应该影响哪个 Loop,都需要额外协调。
Pi 选择更简单的模型:
一个 Agent
同一时刻
只有一条 active processing chain
新的输入如果要进入当前工作,必须通过 Steering / Follow-up Queue 获得明确交付语义。
这使 Agent 内部的 transcript 可以保持单调推进,而不需要在核心 Loop 中处理多个主动分支并发写入。
runWithLifecycle() 建立一次活动区间
输入经过 normalizePromptInput() 转换成 AgentMessage[] 后,进入:
runPromptMessages(messages)
runPromptMessages() 由 runWithLifecycle() 包裹。
这一层负责建立当前活动区间,包括:
创建 AbortController
创建 active promise
设置 isStreaming = true
运行实际 Loop
统一处理异常
结束时清理临时 Runtime State
因此一次 Prompt 在真正请求模型之前,就已经进入下面的状态变化:
idle
↓ prompt()
activeRun created
isStreaming = true
↓
runAgentLoop()
Pi 内部把这个活动对象称为 ActiveRun。需要注意,它只是 Runtime 当前 processing interval 的控制对象,不等价于产品层可能持久化的 Run Entity。
它主要服务三个需求:
知道当前 Agent 是否已有活动任务
允许 abort() 找到当前 AbortController
允许 whenIdle / promise 等待当前活动结束
这也是前一篇区分 Core Active Run 与 Application Run 的实际源码依据。
Context Snapshot 为什么要在进入 Loop 前创建
runPromptMessages() 会先从 Agent._state 建立 Loop 所需的 Context,而不会把整个 State 对象直接交给低层 Loop:
createContextSnapshot()
当前实现很小:
return {
systemPrompt: this._state.systemPrompt,
messages: this._state.messages.slice(),
tools: this._state.tools.slice(),
}
随后调用:
runAgentLoop(
messages,
this.createContextSnapshot(),
this.createLoopConfig(options),
event => this.processEvents(event),
signal,
this.streamFunction,
)
这一层分离值得仔细看。
如果低层 Loop 直接持有 Agent,它可以随时读写所有字段:
Agent
├── messages
├── streamingMessage
├── tools
├── model
├── listeners
└── queues
这样实现更直接,但 Loop 会与外层 Runtime 对象高度耦合。测试 Loop 时需要完整 Agent;复用 Loop 时也很难替换 State Owner。
Pi 选择给 Loop 一份起始 Context Snapshot:
Long-lived Agent State
│
│ snapshot
▼
Initial AgentContext
│
▼
Local Loop Context
低层 Loop 只处理自己需要的输入。
这里使用 slice() 也说明 Snapshot 的语义:数组容器被复制,本次 Loop 可以继续扩展自己的消息序列,而不会通过同一个数组引用直接修改外层 Agent.messages。
这里执行的是浅层数组复制,Message 对象本身仍可能共享引用。关键点在于集合的推进由 Loop Local Context 自己控制。
当前 Prompt 什么时候进入 Context
createContextSnapshot() 只复制开始执行前已有的 Agent.messages。
当前这次 prompt() 的新输入随后由 runAgentLoop() 加入:
const currentContext = {
...context,
messages: [...context.messages, ...prompts],
}
因此初始状态是:
Agent Stable Messages
+
Current Prompt
↓
Loop currentContext.messages
与此同时,Loop 发出用户消息相关生命周期事件:
agent_start
turn_start
message_start(user)
message_end(user)
这些事件通过 Event Sink 回到外层:
event => this.processEvents(event)
Agent.processEvents() 在 messageend 后把完成的 User Message 追加到 state.messages。[2][3]
到这里会出现一个非常重要的结构:
Loop Local Context
│
│ 用于当前计算
│
└──────────────┐
│ events
▼
Agent State
稳定运行状态
同一条 User Message 一方面已经存在于 Loop Local Context 中供本次计算使用,另一方面通过事件进入长期 Agent State。
两份表示服务于不同职责:
- Local Context 为正在进行的计算提供连续消息序列;
- Agent State 为外层观察者和后续执行提供稳定状态。
为什么不让 Loop 每次都重新读取 Agent State
可以设想另一种实现:每次模型请求前都从 Agent.messages 重新读取完整历史。
表面上看可以减少 Local Context,但会引入新的耦合。
当前 Turn 内刚产生的 Tool Result、Steering Message、临时 Context Transform 都需要先同步进 Agent State,下一次 Model Request 才能看到。这样低层执行过程会被外层状态提交时机控制。
Pi 的 Local Context 允许当前 Loop 自己保持连续性:
Prompt
↓
Assistant
↓
Tool Result
↓
Steering
↓
Assistant
这条计算序列可以在 Loop 内持续推进;外层 State 则通过 Event 逐步得到同样的稳定结果。
因此它更接近:
Local execution state machine
+
external state projection
后续 DSH Event Log 会把这种“执行事实 → 外部 Projection”的关系做得更加彻底。
runLoop() 为什么有两层循环
真正控制 Agent 推进的是 runLoop()。
把细节压缩以后,结构大致如下:
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
}
这两层循环直接编码了 Pi 对执行边界的判断。[3]
Inner Loop 负责“当前工作为什么还需要继续”:
模型产生了 Tool Call
→ 继续
有 Steering Message 等待消费
→ 继续
Outer Loop 负责“当前工作已经准备停止,但是否还有后续任务”:
有 Follow-up
→ 进入后续工作
没有 Follow-up
→ agent_end
因此三类输入被放在不同时间位置:
Tool Result
属于当前模型决策链的结果
Steering
尽快影响当前连续工作中的下一次模型决策
Follow-up
等当前连续工作结束后再继续
从源码结构可知,Pi 的控制语义由 Loop 的消费位置直接落实,API 文档与执行结构保持一致。
createLoopConfig() 是外层 Runtime 注入控制能力的位置
runAgentLoop() 并不直接知道 Steering Queue、Follow-up Queue 在 Agent 中如何存储。外层通过 createLoopConfig() 把这些能力以回调形式交给 Loop。[2]
概念上接近:
Agent owns queues / policy
│
▼
createLoopConfig()
│
├── getSteeringMessages
├── getFollowUpMessages
├── transformContext
└── other loop options
│
▼
runLoop()
这个位置体现了依赖方向:Loop 依赖“如何获得 Steering Message”这个能力,而不依赖 PendingMessageQueue 的具体实现。
因此如果上层需要改变消息来源,可以替换回调;如果某个产品不需要 Steering,也可以提供空实现。
这类设计比让 agent-loop.ts 直接 import SessionManager、ExtensionManager 或某个 Queue 类型更容易复用。低层执行只定义自己需要的能力契约,状态容器继续由外层持有。
从架构角度看,createLoopConfig() 与 Event Sink 分别构成两条方向相反的接口:
Outer Runtime ── config / callbacks ──→ Loop
Outer Runtime ←──── lifecycle events ── Loop
前者向执行内核提供策略和资源,后者把执行事实返回给外层。Loop 因而能够维持相对封闭的执行语义。
一次 Model Request 前发生了哪些转换
Inner Loop 准备发起下一次 Assistant Response 时,首先要处理本轮待进入 Context 的消息。
随后 Context 可以经过:
currentContext
↓
transformContext
↓
convertToLlm
↓
Provider Context
这几层非常适合区分 Runtime Message 与 Provider Message。
Agent Runtime 内部可能需要保存更丰富的消息类型,例如:
用户消息
Assistant Message
Tool Result
内部扩展消息
自定义上下文消息
Provider 并不一定接受这些结构。
convertToLlm() 的作用就是在真正请求模型时收敛协议。Pi 默认实现保留 user、assistant 和 toolResult 三类消息,Coding Agent 还可以提供自己的转换逻辑。[2]
因此更准确的数据链是:
Agent Messages
↓
Context Transform
↓
LLM-compatible Messages
↓
Provider Adapter
这条边界在复杂 Agent 中很重要,因为 Session、UI 和 Model Provider 不应该被迫共用同一种消息模型。
Streaming Message 为什么单独存在
模型开始 Streaming 后,Loop 会连续产生:
message_start
message_update*
message_end
外层 Agent.processEvents() 将这些事件归约到 Runtime State:
message_start / message_update
→ _state.streamingMessage = event.message
message_end
→ streamingMessage = undefined
→ _state.messages.push(event.message)
于是同一条 Assistant 输出经历两个状态:
Mutable / In-progress
streamingMessage
↓ message_end
Stable / Completed
messages[]
这条边界对前端非常实用。
UI 可以监听 message_update 实时显示 token,而历史消息列表仍然以完成后的 Message 为稳定单位。
如果后续需要持久化增量 chunk,也可以在更外层单独做 Event Log;低层 Agent 不需要把每个 streaming update 当成稳定 transcript。
因此 Pi 这里选择的是“运行态增量 + 完成态消息”的双层表示。
Assistant Message 如何触发 Tool Execution
Assistant Message 完成后,Loop 从它的 content 中提取 Tool Calls。
概念上,一个 Tool Call 会经历:
tool_execution_start
↓
execute tool
↓
tool_execution_update*
↓
tool_execution_end
↓
ToolResultMessage
外层 Agent 在 toolexecutionstart 时把对应 toolCallId 加入 pendingToolCalls,在结束事件后移除。[2]
这意味着运行时可以同时回答两个问题:
历史里有哪些 Tool Result
当前还有哪些 Tool 没执行完
Tool Result 随后进入 currentContext.messages,并加入当前 Loop 新产生的消息集合。
下一次模型调用必须保持正确的 Tool Call / Tool Result 关系:
Assistant
└── toolCall(id=1)
ToolResult(id=1)
Assistant
└── 根据 Tool Result 继续决策
这属于 Provider Context 协议约束,和 UI 展示无关。
Pi 相关 issue 也记录过孤立 Tool Result 被 Provider 拒绝的情况。[4] 对 Runtime 来说,消息历史不仅要“有这些内容”,还必须维持模型协议要求的结构关系。
多个 Tool Call 还涉及一个批次边界
一次 Assistant Message 可以同时产生多个 Tool Call。Runtime 此时需要决定这些 Tool 在当前 Step 中如何执行,以及什么时候认为这一批 Tool Work 已经结束。
可以抽象成:
Assistant Message
├── Tool Call A
├── Tool Call B
└── Tool Call C
↓
Tool Execution Batch
↓
Tool Results A/B/C
↓
next decision boundary
无论具体实现采用串行还是并行,下一次模型请求都需要得到与 Tool Call 对应的完整结果集合,或者得到明确的错误/中断结果。
这会影响 Steering 的交付时机。如果当前模型一次产生 A、B、C 三个 Tool Call,Steering 在 A 完成后就进入 Context,可能导致 B、C 仍然执行旧决策,而模型后续却已经看到新的约束。Pi 文档将 Steering 放在当前 Assistant Turn 的 Tool Calls 执行完成以后,再交付到下一次 LLM Call。[5]
因此 Tool Batch 本身也是一个稳定边界。Runtime 需要先完成前一次模型决策已经产生的直接 Effect,再让新输入影响下一次决策。
这类边界在支持并行 Tool 时尤其重要。并发可以优化执行时间,但不能破坏 Context 中 Tool Call / Tool Result 的对应关系和 Step 的完成条件。
Tool Result 为什么先进入 Local Context
工具执行完成后,最直接的需求是下一次 Model Request 必须立即看到结果。
因此 Tool Result 首先属于当前 Loop 的计算状态:
currentContext.messages += ToolResult
然后通过 Event 机制同步到外层状态。
如果反过来要求 Tool Result 先持久化、再从 Session 重建 Context,低层 Loop 会被持久化实现阻塞,并且难以作为一个可独立使用的执行内核。
Pi 的设计允许持久化成为外层消费者:
Tool Execution
↓
Tool Result
↓
Local Context continues
↓
Lifecycle Event
↓
Agent State / Session / UI
这条顺序把“当前执行能否继续”和“外部如何记录当前状态”解耦。
若产品要求严格 crash recovery,外层可以进一步引入 durable Event Log,并要求关键 Event 在推进前落盘。这属于更高层的持久化语义,不需要进入最小 Loop。
Steering 为什么在 Tool 完成后检查
工具执行期间,新的 Steering Message 可能已经进入 Queue。
Pi 不会在 Tool 执行一半时直接改变当前 Context。Tool 完成后,Inner Loop 再次调用:
getSteeringMessages()
Agent.createLoopConfig() 将它绑定到 steeringQueue.drain()。[2]
因此实际交付顺序是:
Model Request
↓
Assistant requests Tool
↓
Tool Execution
│
│ steering arrives
│
↓
Tool Result
↓
Drain Steering Queue
↓
Append Steering Message
↓
Next Model Request
Pi RPC 文档也明确规定,Steer 会在当前 Assistant Turn 的 Tool Calls 执行完成后、下一次 LLM Call 前交付。[5]
这个设计主要保护两个稳定边界。
第一,已经开始的 Tool Call 不会因为新的自然语言输入突然改变参数语义。
第二,下一次 Model Request 能够同时看到 Tool Result 和 Steering Message,从新的完整状态继续决策。
需要立即终止当前 Tool 时应使用 Abort;Steer 保持为后续决策输入。
这使“修改下一步方向”和“终止正在进行的工作”保持为两种独立控制操作。
Event Sink 为什么是整个结构的关键连接点
低层 runLoop() 没有直接写 Agent._state。它向 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() 一方面更新外层 Runtime State,另一方面把事件通知给 listeners。
于是同一个执行事实可以服务多个消费者:
Lifecycle Event
│
├── Agent State reducer
├── Terminal / UI
├── Session persistence
├── Extension hook
└── Telemetry
低层 Loop 不需要知道这些消费者是否存在。
这比在 Loop 中直接调用:
ui.update(...)
session.save(...)
extensions.emit(...)
telemetry.record(...)
更容易保持核心边界。
Pi Harness V2 的设计也强调,底层 agent-loop building blocks 不拥有 durable state,也不需要理解 Session、record 或 lane。[6]
从架构上看,Event Sink 是执行内核与外部 Harness 之间的一条窄接口。
Event 与 Event Sourcing 的边界
这里还需要区分一个容易混淆的问题。
Pi Loop 发出生命周期 Event,并不意味着低层 Agent 已经采用 Event Sourcing。
Event 可以只是通知机制:
state mutation
+
event notification
Event Sourcing 则要求事件成为 durable source of truth,状态由 Event Log 投影得到。
Pi 当前低层 Agent 仍然直接维护 _state.messages、streamingMessage 和 pendingToolCalls。Event 负责驱动和通知这些状态变化。
DeepSeek Harness 的 Session Event Log 会把事件提升到持久事实源层次,这是第二单元会重点分析的设计差异。
保持这个区分,可以避免看到 emit(event) 就直接把系统归类为 Event Sourcing。
Abort 如何贯穿当前执行链
runWithLifecycle() 创建的 AbortController 会把 signal 传入低层执行链。[2]
因此取消语义可以从 Agent 外层贯穿到模型调用和 Tool 执行。
理想路径是:
agent.abort()
↓
AbortController.abort()
↓
signal aborted
↓
model / tool observes signal
↓
current active execution stops
具体 Tool 是否能够及时响应取消,取决于 Tool 实现是否正确处理 Signal。Runtime 只能传播取消意图,无法强制一个完全不检查 Signal 的同步阻塞函数立即终止。
这也说明 Abort 与 Steering 的根本区别。
Steering 改变后续决策;Abort 直接作用于当前活动生命周期。
这两个控制维度如果混在一起,会让用户输入的行为变得难以预测。
Follow-up 为什么放在 Outer Loop
当 Inner Loop 已经没有 Tool Call,也没有 Steering Message,当前连续工作到达自然停止点。
此时 Runtime 才检查 Follow-up Queue:
Current work reaches stop boundary
↓
getFollowUpMessages()
│
├── empty → agent_end
│
└── has messages
↓
continue outer loop
因此 Follow-up 不参与当前 Turn 的最近模型决策,它在当前工作完成后形成后续工作。
这层语义如果和 Steering 共用一个 Queue,就需要在每次消费时重新判断“这条消息属于当前工作还是下一段工作”。Pi 直接用两类 Queue 和两层循环表达,代码结构与产品语义保持一致。
DeepSeek Harness 则进一步把这种差异抽象成 next-step / next-turn Target,下一篇会继续比较。
一次 Prompt 的完整状态链
把前面的局部过程合并,可以得到完整状态变化:
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
这张图基本可以作为阅读 agent.ts + agent-loop.ts 的主线。
从源码阅读角度,最值得保留的是三条边界
读完这一条调用链以后,Pi 的具体方法名可以暂时放下,三个结构边界更值得保留。
第一条是 State 与 Context 的边界。Agent._state 长期存在,Loop 使用 Snapshot 和 Local Context 推进当前计算。
第二条是 Callback 与 Event 的边界。外层通过 Loop Config 提供策略和资源,低层通过 Lifecycle Event 把执行事实返回给外部。
第三条是 Step-like 与 Turn-like 的边界。Tool / Steering 在内层继续当前工作,Follow-up 在停止边界以后进入后续工作。
State ──snapshot──→ Local Context
Runtime ──callbacks──→ Loop ──events──→ Runtime
Inner Loop ──stop boundary──→ Outer Loop
这三条边界比单独记住某个函数名更稳定,也更容易迁移到其他 Agent Framework 的源码阅读中。
Pi Loop 保留了哪些核心语义
从源码中可以把低层 Agent Loop 的职责收敛为六类:
Context Preparation
Model Invocation
Tool Execution
Input Delivery
Cancellation / Stop Condition
Lifecycle Events
Session Tree、长期持久化、Plan、SubAgent、Workflow、UI 都没有被固定到这个执行内核里。
这种边界有一个明显优势:同一套 Loop 可以被不同产品层复用。Terminal Coding Agent 可以在外层接 SessionManager 和 Extensions;其他应用也可以只使用低层 Agent。
代价同样存在。很多高级能力需要由外层 Runtime 自己补齐,例如 crash recovery、durable run、branch、approval 和多 Agent orchestration。Pi 选择保持核心较小,把这些复杂度留给更上层。
这一篇形成的设计判断是:
一个可复用的 Agent Loop 应优先维护执行语义。Context、Model、Tool、Input Delivery、Cancellation 和 Lifecycle Event 构成较稳定的内核;长期 Session、Workflow 和 UI 可以建立在这条内核之外。
下一篇继续分析 Input Delivery。Pi 的 Steering / Follow-up Queue 如何映射到 Step / Turn 边界,DeepSeek Harness 又如何通过 Inbox 的 target + wakeup 把这些 API 收敛为更一般的交付模型。
参考资料
[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