源码考古:Pi Agent Runtime 如何在 Commit History 中成形
这不是 Git 使用教程,也不是把当前 package 图换一种方式再讲一遍。本章把 Pi 的历史版本当作一组可执行的设计实验:在隔离的临时 clone 中 detached checkout 每个关键节点,同时阅读父版本、提交版本、后续 repair 或 revert、测试断言和当前实现,回答一个更接近 Agent 研发的问题:一个能跑的 loop,怎样逐步变成边界更明确、失败语义更可信的 runtime?
当前对照快照为 Pi ccb946411(2026-08-28)。该提交主要增加源码注释;本文判断只依据可执行实现、类型、diff 和测试,不把注释本身当作历史动机证据。宏观演进全景见通过 Commit History 研究 Pi 的架构演进,当前静态 module 关系见Pi 当前架构。
本章不是第二条必须从头读到尾的时间线。做分享时先用 02 建全景,再按问题选读下面任一条链;每条链都能独立回答一个 runtime 设计问题。
先给出源码层结论
Section titled “先给出源码层结论”Pi Agent Runtime 的形态不是从一张模块图推导出来的,而是在五类失败压力下收紧的:
ffc9be886的AgentTransport同时承载 provider 适配和 loop 入口,导致状态在Agent、transport、pi-ailoop 三处共同决定;a055fd448把 loop 移回 Agent package,但它是明确不能编译的 WIP。直到3caf35940及中间修复把公开 seam 收敛为StreamFn + AgentLoopConfig + AgentTool,才能说 runtime 重新拿回了控制流所有权。d0a4c3702虽然把单一 queue 命名为steer和followUp,最初的steer仍会在一个 assistant tool batch 中途跳过剩余工具。208a2cc12删除skipToolCall(),测试要求整批工具完成后才注入 steering;安全点最终由执行顺序而不是 API 名称定义。c6fc08453合并 hooks 与 custom tools,只解决“扩展从哪里注册”;它没有自动解决“扩展在什么状态上观察”。63ac2df24为tool_call增加显式事件屏障,1ee0d28d7又补齐 extension-origin queue 的可观察性。extension 因而不是普通 callback,而是受顺序协议约束的 runtime 参与者。- compaction 从旁路
completeSimple(),演进为复用主 loop 的streamFn;随后又经历串行化、cache-friendly 方案整体 revert,以及97fa14e39对截断摘要的拒绝。源码最终把 compaction 当作“先验证、后落盘”的 checkpoint,而不是“拿到一段文本就保存”。 1d0c97471先在 memory backend 上建立 durability-oriented session/operation protocol,但此前已经存在可运行的AgentHarness;44289550a反而删除旧执行实现并提升一个新 scaffold。a838c069e、4a0e2f115随后收紧 JSONL fork/repair 的原子发布和损坏处理;当前HarnessNotImplemented测试同时证明 runnable recovery executor 仍未完成。
这些结论的共同点是:真正决定架构的不是目录移动,而是谁拥有状态转换、何处允许插入新输入、事件观察的是提交前还是提交后状态、失败能否留下半成品、崩溃后有什么足够信息可恢复。
flowchart TD
T[ffc9be886 transport owns loop entry] --> W[a055fd448 loop move WIP]
W --> S[3caf35940 StreamFn seam]
S --> Q[d0a4c3702 steer and followUp]
Q --> O[63ac2df24 ordered event sink and tool hooks]
O --> B[208a2cc12 tool batch barrier]
B --> L[351efc828 reject truncated effects]
S --> C[35f807cfa compaction uses StreamFn]
C --> R[f58c11562 serialized summaries]
R --> V[cff1cf52c then 8dab70281 revert]
V --> G[97fa14e39 validate before checkpoint]
S --> X[c6fc08453 unified extensions]
X --> O
O --> U[1ee0d28d7 queue visibility]
U --> P[32bcdc973 awaited listener settlement]
P --> D[1d0c97471 durability-oriented session protocol]
D --> H[44289550a public harness scaffold]
H --> J[a838c069e and 4a0e2f115 JSONL hardening]
图中的箭头只表示需要连续阅读的证据链,不表示后一个提交只由前一个提交“导致”。真实历史会交叉,例如 63ac2df24 同时改变事件 sink、tool hook 和并行工具执行,两天后的 208a2cc12 才修正并行/串行工具共同面对的 steering 安全点。
证据规则:怎样避免把 commit message 写成作者心理
Section titled “证据规则:怎样避免把 commit message 写成作者心理”本文对结论使用三种强度:
| 标记 | 本文如何使用 |
|---|---|
| 源码事实 | 能在指定 commit 的历史文件、symbol、diff 或测试断言中直接核对。 |
| 工程解释(推断) | 由多个源码事实支持,但 commit 没有直接陈述完整动机;同时列出支撑证据和不能推出的部分。 |
| 可迁移原则 | 面向自研 Agent 的设计建议,不声称 Pi 作者做出变更时就是这样思考。 |
历史版本在临时 clone 中切换,原 Pi 工作树不需要 checkout。下面只是本文取证方式的最小复现,不是本章主线:
scratch="$(mktemp -d)"git clone --shared --no-checkout . "$scratch/pi-history"git -C "$scratch/pi-history" checkout --detach ffc9be886git -C "$scratch/pi-history" diff ffc9be886^ ffc9be886 -- packages/agent packages/ai packages/coding-agentgit -C "$scratch/pi-history" checkout --detach 3caf35940每条链都按“约束 → 父子实现 → 测试契约 → 后续/current → 工程决策”展开。
链一:loop 到底由谁拥有——从 fat transport 到 StreamFn
Section titled “链一:loop 到底由谁拥有——从 fat transport 到 StreamFn”1. ffc9be886 的问题不是多了一层,而是控制权跨了三层
Section titled “1. ffc9be886 的问题不是多了一层,而是控制权跨了三层”源码事实。 ffc9be886 新建通用 packages/agent 时,packages/agent/src/agent.ts:AgentOptions 要求调用者传入 transport: AgentTransport。coding product 在 packages/coding-agent/src/main.ts 中这样组装:
const agent = new Agent({ initialState: { systemPrompt, model, thinkingLevel: "off", tools: codingTools }, transport: new ProviderTransport({ getApiKey: async () => apiKey! }),});表面上看,这是常见的 transport abstraction;沿调用链进入源码后,职责却不是简单的“网络 I/O 适配”:
coding-agent/main.ts -> Agent._prompt() -> messageTransformer(state.messages) -> transport.run(messages, userMessage, config, signal) -> ProviderTransport 解析 key / proxy -> pi-ai agentLoop(...) -> streamAssistantResponse() -> executeToolCalls()packages/agent/src/agent.ts:Agent 持有 _state、messageQueue、AbortController 和 listeners;packages/agent/src/transports/ProviderTransport.ts:run() 又从 @mariozechner/pi-ai 导入 agentLoop 并决定何时进入循环;真正的 turn/tool 状态机则在 packages/ai/src/agent/agent-loop.ts。AppTransport 也不只是把数据发往后端,它同样知道 loop protocol。
因此当时的所有权是:
| 状态或决策 | ffc9be886 的直接所有者 |
|---|---|
| 已接收消息、streaming 标志、pending tool ids、用户 queue | Agent |
| 认证、proxy/backend 差异、进入哪个 loop | transport |
| assistant streaming、tool validation/execution、下一 turn | pi-ai 的 agentLoop |
工程解释(推断)。 问题不在于“transport 模式不好”,而在于这个 transport 隐藏了控制流而不只隐藏传输细节。支撑证据是:两个 transport 都导入完整 agentLoop,而拥有可观察状态的 Agent 只能消费 transport 产出的事件。源码不能证明这一定造成了线上 bug,但能证明扩展 context transform、queue 策略或 tool 生命周期时需要同时跨越三层。
2. a055fd448 找到了更小 seam,但该快照不能当终态
Section titled “2. a055fd448 找到了更小 seam,但该快照不能当终态”源码事实。 a055fd448 的 diff 删除 ProviderTransport、AppTransport 和 AgentTransport,Git 将 packages/ai/src/agent/agent-loop.ts 识别为移动到 packages/agent/src/agent-loop.ts 的主体代码。AgentOptions 改为直接接收:
convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;streamFn?: StreamFn;getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;Agent._prompt() 不再调用 transport.run(),而是直接构造 AgentLoopConfig,调用 agentLoop() 或 agentLoopContinue()。provider 可变部分缩成 streamFn(model, context, options);message domain 与 provider domain 的转换则显式拆成 transformContext 后 convertToLlm。
但是这个 commit 的标题就是 WIP: Refactor agent package - not compiling。同一快照仍有未迁移引用和文档残留。把“类型出现在源码”直接写成“架构已经稳定”,会漏掉随后一串改变公开语义的修复:
| 后续提交 | 源码层修正 |
|---|---|
6ddc7418d |
继续清理 WIP,移除 runtime 不应拥有的 attachment 形状并简化 API。 |
e49e78732 |
在发送 agent_end 给 listeners 之前把 isStreaming 设为 false;事件观察者由此看到一致状态。 |
92898f486 |
StreamFn 允许返回 Promise,支持异步解析 proxy/settings。 |
4d1936d3d |
在 AgentInterface 调用边界补上默认 streamFn 与 API-key 行为。 |
41af99ccc |
prompt() 与 agentLoop() 从单条消息扩成多消息输入。 |
3caf35940 |
发布 v0.31.0,CHANGELOG 正式记录 transport 删除和新类型边界。 |
这条 repair 链说明,目录移动只完成了第一步;streaming 可观察状态、异步配置和批量 prompt 才把新 seam 补成可用协议。
3. 3caf35940 的稳定边界能从调用者和测试中看到
Section titled “3. 3caf35940 的稳定边界能从调用者和测试中看到”源码事实。 packages/agent/src/types.ts 中的 StreamFn 复用 streamSimple 参数/返回类型,并允许同步或异步获得 stream:
export type StreamFn = ( ...args: Parameters<typeof streamSimple>) => ReturnType<typeof streamSimple> | Promise<ReturnType<typeof streamSimple>>;AgentLoopConfig 明确列出 runtime 在每次 provider call 前需要的策略:model、convertToLlm、可选 transformContext、动态 getApiKey 和 queue 读取器;AgentContext 只保留 systemPrompt、messages、tools;AgentTool 把运行时 schema、执行和 partial update 放在同一个 capability contract 中。Agent 自己调用 agentLoop,因此 turn、tool、queue 和事件更新回到同一 runtime 所有者中。
packages/agent/test/agent-loop.test.ts 不是只测“有返回值”,而是在固定 seam:
- 事件包含
agent_start → turn_start → message_start/message_end → turn_end → agent_end; - custom
AgentMessage可由convertToLlm过滤或转成 provider message; transformContext必须先于convertToLlm;- tool start/end、真实 execute 和 tool-result message 都进入同一事件序列;
agentLoopContinue()拒绝空 context,不重复发 user message event,并允许调用者负责把 custom last message 转成 provider 可接受角色。
这些断言比 CHANGELOG 更强:它们说明 seam 不只是换名字,而是把“host 可定制什么”和“loop 必须保证什么”分开了。
4. 当前残留与代价
Section titled “4. 当前残留与代价”在 ccb946411,StreamFn 仍是 provider 边界,签名已从 Parameters<typeof streamSimple> 展开为显式 Model + Context + SimpleStreamOptions → AssistantMessageEventStream;runAgentLoop() / runAgentLoopContinue() 仍由 agent package 持有。后续增加的 AgentEventSink、tool hooks、queue getters 都在这个 ownership 上继续生长,说明该 seam 经受住了后续功能,而不是只存在于一次发布中。
代价同样可见:transport 类消失后,AgentLoopConfig 逐渐吸收 context conversion、queue、tool policy、hook、retry 等策略;Agent 的配置面变宽。更小的 provider seam 没有消除复杂度,只是把复杂度移到更符合状态所有权的位置。
可迁移原则。 自研 Agent 时先问“谁拥有 turn state machine”,再决定 adapter 形状。loop 应依赖最小的 provider request/stream 能力;认证、proxy、provider SDK 可放在 adapter 外层,但 adapter 不应偷偷决定 tool loop 或用户 queue 何时推进。
Go 对照与失效边界。 Go 中可以从一个窄接口起步:
type Streamer interface { Stream(ctx context.Context, model Model, in ModelContext, opts StreamOptions) (EventIterator, error)}不要机械照抄 StreamFn。TypeScript 函数类型和 AsyncIterable 可以结构化匹配;Go 需要另外决定 iterator 的错误模型、channel 关闭、context.Context 取消和 goroutine ownership。Pi 当前还要求 stream failure 以最终 assistant message/event 表达,这也不等价于 Go 方法返回的 error。
链二:queue 名称不是语义——安全点必须落到 tool batch
Section titled “链二:queue 名称不是语义——安全点必须落到 tool batch”1. d0a4c3702 把用户意图区分开了,但仍可能产生半批执行
Section titled “1. d0a4c3702 把用户意图区分开了,但仍可能产生半批执行”源码事实。 父版本只有 messageQueue、queueMode 和 getQueuedMessages()。d0a4c3702 将其拆为:
steeringQueue/steer():当前 run 中尽快影响下一次模型调用;followUpQueue/followUp():agent 本来要停止时才继续;getSteeringMessages()与getFollowUpMessages():分别由内层 tool loop 和外层 completion loop 消费。
这是实际状态机变化,不只是 rename。runLoop() 变成嵌套循环:内层处理 tool calls 与 steering,外层在即将结束时检查 follow-up。
但 packages/agent/src/agent-loop.ts:executeToolCalls() 在每个工具结束后调用 getSteeringMessages()。一旦读到新消息,它会对剩余 tool calls 调用 skipToolCall(),生成 "Skipped due to queued user message." 的 error tool result,然后进入下一次模型调用。
假设 assistant 一次返回 [writeFile, runTests]:旧语义可能执行 writeFile、伪造 runTests 的 skipped result,再把 steering 送给模型。外部副作用已经发生,但本批计划没有完成。
2. 中间的 63ac2df24 让问题从顺序循环扩展到并行批次
Section titled “2. 中间的 63ac2df24 让问题从顺序循环扩展到并行批次”63ac2df24 在 tool execution 中加入 toolExecution policy、顺序/并行 executor,以及 beforeToolCall / afterToolCall。但它仍在顺序执行的每个 tool 后轮询 steering;并行路径甚至会在 preparation 过程中轮询,并把已准备或尚未准备的调用转成 skipped result。
这说明“读 queue 的位置”已经是跨两种调度器的协议问题,不能继续藏在某个 for 循环的便利位置。
3. 208a2cc12 用删除代码确定真正的 safe point
Section titled “3. 208a2cc12 用删除代码确定真正的 safe point”源码事实。 208a2cc12 做了三个连在一起的改动:
executeToolCalls*()不再返回{ toolResults, steeringMessages },只返回完整ToolResultMessage[];- 顺序和并行 executor 都删除执行期间的
getSteeringMessages()轮询,并完全删除skipToolCall(); runLoop()在整批 tool results 加入 context、发出turn_end后,才调用getSteeringMessages()。
新的顺序是:
flowchart LR
A[assistant message with N tool calls] --> P[validate and prepare batch]
P --> E[execute every accepted call]
E --> M[emit every tool result]
M --> T[turn_end]
T --> Q[drain steering queue]
Q --> N[next model call]
对应测试从 should inject queued messages and skip remaining tool calls 政名为 should inject queued messages after all tool calls complete。断言也从 executed === ["first"] 改为:
executed === ["first", "second"];- 两个
tool_execution_end都不是 error; message_start顺序先出现tool:tool-1、tool:tool-2,之后才出现interrupt;- 第二次 provider call 的 context 能看到
interrupt。
这组断言定义了 steer 的精确语义:它不是取消当前 batch,而是在下一个模型决策点前插入消息。
4. 351efc828 把同一原则扩展到“不完整的模型输出”
Section titled “4. 351efc828 把同一原则扩展到“不完整的模型输出””351efc828 处理另一个 effect boundary:assistant 以 stopReason: "length" 结束时,stream 中 salvage 出来的 tool arguments 可能恰好仍通过 schema validation,却已经被静默截断。新函数 failToolCallsFromTruncatedMessage() 对该 assistant message 中的每个 tool call 生成 error result,完全不调用 tool,并让 loop 继续,以便模型重发完整调用。
测试构造 { value: "hel" } 这样仍满足字符串 schema 的截断参数,断言:
executed保持空数组;- tool end 为 error,内容提到 output token limit;
- loop 继续到第二次模型调用,而不是把整个 run 直接终止。
工程解释(推断)。 208a2cc12 与 351efc828 共同反映的约束是:模型输出不是普通函数调用列表,而是一个带完整性状态的 effect plan。前者要求一个已接受的 batch 到达边界后才允许新输入;后者要求不完整 plan 的任何 effect 都不得执行。这个抽象是由代码和测试共同归纳的,不是 commit message 中的一句总设计宣言。
可迁移原则。 为自研 runtime 明确定义至少三个时刻:plan accepted、accepted batch finished、next model decision allowed。这里的 finished 只表示每个已接受调用都走到 success/error result,不表示外部 effect 已原子提交。如果真要中途取消工具,不要用“伪造 skipped success/error”代替;需要为每个 tool 定义 cancellation、幂等键、补偿策略和“已经发生副作用”的记录。
Go 对照与失效边界。 可以把一个 assistant tool batch 类比为 unit-of-work barrier,但不能叫数据库事务:文件、shell、HTTP 等外部副作用通常不能原子 rollback。Go 中即使每个工具都接收 context.Context,取消也只表示请求取消,不证明 effect 没发生;必须在 tool contract 和 operation log 中表达结果不确定性。
链三:extension 从合并入口到有顺序保证的 runtime 参与者
Section titled “链三:extension 从合并入口到有顺序保证的 runtime 参与者”1. c6fc08453 合并的是能力入口,不是自动获得一致性
Section titled “1. c6fc08453 合并的是能力入口,不是自动获得一致性”源码事实。 在 c6fc08453^,AgentSessionConfig 同时接收 hookRunner?: HookRunner 和 customTools?: LoadedCustomTool[];实例内部有 _hookRunner 与 _customTools 两套生命周期。hook 处理 agent/session events,custom tool 则通过另一套 loader/wrapper 接到 tool execution。
c6fc08453 删除 core/hooks/* 和 core/custom-tools/*,统一到 core/extensions/{loader,runner,types,wrapper}。新的 ExtensionRunner 汇总:
- typed lifecycle handlers;
registerTool()、registerCommand()、shortcut、flag 与 renderer;sendMessage()/appendEntry();- 只读 session view、model registry、UI、abort 与队列等 host capability。
这不是只把文件夹改名。AgentSession 从两个 owner 收窄为一个 _extensionRunner,SDK 只加载一个 extension result,注册工具通过 wrapRegisteredTool() 适配到 AgentTool。
持久化模型也跟着区分两种 extension state:
| 历史类型 | 是否进入模型 context | 用途 |
|---|---|---|
CustomEntry |
否 | extension 自己恢复状态;buildSessionContext() 忽略。 |
CustomMessageEntry |
是 | 重建为 role custom 的 AgentMessage,再由 converter 决定如何给模型。 |
session version 从 2 升到 3,migrateV2ToV3() 把历史 hookMessage role 转成 custom。这说明 extension unification 触碰了 durable schema,而不只是运行时 callback 表。
2. c6 快照中的真实顺序暴露了 seam 的缺口
Section titled “2. c6 快照中的真实顺序暴露了 seam 的缺口”源码事实。 c6fc08453 的 AgentSession._handleAgentEvent() 对每个事件按下面顺序处理:
await _emitExtensionEvent(event)_emit(event) // 通知 UI/listenerif event.type == message_end: sessionManager.appendMessage(...)因此“扩展收到 message_end”并不等价于“消息已进入 durable session”。更关键的是,Agent loop 可能在 assistant message_end 发出后立即开始 tool call,而 AgentSession 的异步处理仍排在队列里。
不能从这里推出所有 extension handler 一定读到旧状态;但可以直接得出:当时没有一个统一的 persist-before-observe 保证,事件名称本身不足以说明可观察阶段。
3. 63ac2df24 没有全局重排,而是为 tool_call 加了一道精确屏障
Section titled “3. 63ac2df24 没有全局重排,而是为 tool_call 加了一道精确屏障”源码事实。 该提交在 agent package 增加:
AgentEventSink = (event) => Promise<void> | void;runAgentLoop()/runAgentLoopContinue();- loop 中对 message、turn、tool event 的
await emit(...); beforeToolCall和afterToolCall,位于参数校验之后、真实 execute 前后。
coding-agent 不再依靠通用 tool wrapper 拦截所有工具,而是在 AgentSession._installAgentToolHooks() 安装 host hooks。最关键的代码不是 hook 名称,而是:
this.agent.setBeforeToolCall(async ({ toolCall, args }) => { const runner = this._extensionRunner; if (!runner?.hasHandlers("tool_call")) return undefined;
await this._agentEventQueue; return runner.emitToolCall({ ... });});_agentEventQueue 串行执行 AgentSession 的异步 event processing。assistant 的 message_end 已进入该队列;tool 真正执行前等待队列 drain,才发送 extension tool_call。这不是把所有事件统一改成“持久化后通知”,而是对危险的 effect 前观察点加 barrier。
回归测试 packages/coding-agent/test/agent-session-concurrent.test.ts 让一次 assistant response 产生两个 tool calls。extension 在每次 tool_call 读取 session entries,测试要求两次 snapshot 都严格为:
["user", "assistant"]["user", "assistant"]同文件还让 assistant message_end extension handler 延迟 40ms,最终断言持久化角色顺序仍为 user → assistant → toolResult → assistant。测试面由此覆盖了“慢 handler 不应打乱 durable transcript”。
4. 1ee0d28d7 说明输入来源也是协议的一部分
Section titled “4. 1ee0d28d7 说明输入来源也是协议的一部分”1ee0d28d7 增加 AgentSessionEvent.queue_update,并在 _queueSteer()、_queueFollowUp()、queue drain 与 clearQueue() 时发当前 snapshot;interactive mode 新增 queue_update 分支刷新 pending display。
回归测试不是从 TUI 键盘输入,而是让 extension API 在 streaming 期间调用:
pi.sendUserMessage("Steer from extension", { deliverAs: "steer" });它断言 pending count 为 1、steering 列表包含消息、listener 观察到对应 queue_update,abort 后的下一次 provider context 最终看到这条消息。修复前 core queue 可以收消息,但 UI/observer 依赖本地交互路径更新,非交互 ingress 造成可观察状态失真。
同一 commit 还把该输入事件的 source 从 "extension" 改为 "interactive",测试也按 interactive 断言。源码能证明分类发生变化,不能仅凭本地证据确定为何采用这个命名;本文不把它扩写成作者动机。
5. 32bcdc973 把临时 barrier 收回到 listener settlement 契约
Section titled “5. 32bcdc973 把临时 barrier 收回到 listener settlement 契约”63ac2df24 的 _agentEventQueue 是针对当时 fire-and-queue handler 的修复,但它不是当前终态。32bcdc973 将 _handleAgentEvent 改成直接返回 Promise<void> 的异步 listener;Agent.processEvents() 会按订阅顺序 await listener,因而 loop 在 session 的 extension 分发、普通 listener 和 message persistence 完成前不会处理下一个事件。
同一提交删除 _agentEventQueue 以及 beforeToolCall 中显式等待它的代码,并把 retry/compaction 移到 agent.prompt() 完成后的 _handlePostAgentRun()。回归测试仍固定两项语义:慢 message_end handler 不打乱 user → assistant → toolResult... 的持久化顺序;tool_call handler 运行时,触发工具的 assistant message 已在 session 中。
这次后续修正说明:局部 queue barrier 解决了真实竞态,但更稳定的契约是“状态 owner 等待订阅者 settlement”。当前实现仍不是全局 persist-before-observe:AgentSession._handleAgentEvent() 内部先调用 extension message_end handler,再通知普通 listener,最后持久化消息;保证的是 loop 在 listener 整体完成前不进入 tool effect。
6. 当前残留与设计代价
Section titled “6. 当前残留与设计代价”在 ccb946411,_agentEventQueue 已不存在;Agent 先归约自身状态,再等待 AgentSession listener。AgentSession 仍协调 extension runner、queue mirrors、session persistence 和 UI-facing events;agent core 拥有 beforeToolCall / afterToolCall 与 ordered listener settlement。seam 成立的证据是 core 不导入 coding extension 类型,coding host 通过 hooks 接入;残留成本则是 AgentSession 成为一个需要维护多种时序的中心协调器。
异步 sink 也不是免费午餐:handler 会对 loop 形成 backpressure,错误或重入可能阻塞后续事件。Pi 用受限 context、动态读取当前 runner,以及对特定 barrier 的测试约束这个风险,而不是宣称“所有 extension 都安全”。
可迁移原则。 extension event 应带 phase 语义,例如 assistant_observed、assistant_persisted、tool_before_effect,或至少在契约中写清发生顺序。不要让 plugin 通过猜测 callback 名称决定是否可以读 durable state、修改参数或发起新 run。
Go 对照与失效边界。 Go 可以把 extension host 做成显式 capability interface,并由单 owner goroutine 串行状态变更;但 Pi 还依赖 TypeScript 结构类型、ESM 动态加载和运行时 TypeBox schema。Go plugin、RPC extension 或编译期注册的隔离/版本问题完全不同,不能直接复制 Pi 的 loader 设计。
链四:compaction 从辅助调用变成 checkpoint protocol
Section titled “链四:compaction 从辅助调用变成 checkpoint protocol”1. 35f807cfa 修的不是“能不能摘要”,而是旁路 provider seam
Section titled “1. 35f807cfa 修的不是“能不能摘要”,而是旁路 provider seam”源码事实。 父版本的 compaction 直接调用 completeSimple(),并要求 ModelRegistry 提供 API key/headers。主 Agent 即使通过自定义 streamFn 使用 proxy、动态认证或测试 provider,摘要仍绕回默认 provider 路径。
35f807cfa 增加 completeSummarization():传入 custom streamFn 时,等待它返回 stream 并调用 stream.result();未传入时才回退 completeSimple()。streamFn 继续沿 generateSummary()、turn-prefix summary、compact() 传到 AgentSession 的 manual/auto compaction。
AgentSession._getCompactionRequestAuth() 也按 seam 分流:只有 agent.streamFn === streamSimple 才强制 registry auth;custom stream 可自行解决 proxy/auth,registry 查询失败不再提前阻止 compaction。
对应测试在没有 registry API key 的条件下提供 custom streamFn,分别断言 manual compaction 和 auto compaction 能完成。测试直接证明的是:Pi 的 compaction 已遵守调用者为主 Agent 配置的 custom stream seam,而不是强制旁路到 registry auth。
可迁移原则。 如果 secondary LLM call 需要继承主 loop 的认证、路由、proxy 或审计策略,就应经过同一 policy seam;否则必须把差异作为显式 interface,而不是隐藏旁路。
2. f58c11562 的源码只支持“请求串行”,不支持“checkpoint 写入串行”
Section titled “2. f58c11562 的源码只支持“请求串行”,不支持“checkpoint 写入串行””split-turn compaction 需要两段摘要:较早 history 与当前 turn prefix。父版本用 Promise.all() 同时调用 generateSummary() 和 generateTurnPrefixSummary();f58c11562 改为先 await historyResult,再请求 turnPrefixResult。
这里必须收窄结论:两个函数只返回文本,之后合并成一个 CompactionResult,最终仍只 append 一条 compaction entry。源码不支持“修复两次 checkpoint write 交错”的说法。该 commit 没有增加本地测试。
工程解释(推断)。 串行化降低了同一个 streamFn 同时承载两次 summary request 的要求,可能适配非并发 proxy、rate limit、会话级路由或 provider cache 行为;支撑证据只有从 Promise.all 到顺序 await 的 diff 以及所有请求共用同一 stream seam。缺少 PR/issue 本文内容时,不能再确定具体故障机制。
3. cff1cf52c → 8dab70281:测试很多也不等于设计已稳定
Section titled “3. cff1cf52c → 8dab70281:测试很多也不等于设计已稳定”cff1cf52c 加入 CacheFriendlySummaryOptions、sourceContext、turnPrefixSourceContext、request/cache retention 传递以及大量 token-budget 测试,尝试把原 provider context 作为可缓存前缀参与 summary。AgentSession 当时并没有把这些 primitives 接成默认行为。
次日 8dab70281 明确 Revert "feat(coding-agent): add cache-friendly compaction primitives",删除新增类型、source-context construction 和相应测试,diff 规模与前一提交基本反向对应。
这里最重要的历史读法是:feature commit 的源码和绿测试可以证明方案“曾被实现并覆盖某些条件”,不能证明它经受住集成、成本或 provider 语义。当前架构结论必须沿到 revert 之后。
4. 97fa14e39 把 checkpoint 的接受条件写成一个 choke point
Section titled “4. 97fa14e39 把 checkpoint 的接受条件写成一个 choke point”父版本只拒绝 stopReason === "error" 或包含 tool call 的 summary;stopReason === "length" 即使只产生半段结构化文本,也会继续返回字符串,随后 AgentSession.appendCompaction() 将它变成未来每轮 context 的基础。
97fa14e39 新增 getSummarizationFailure(response, label),history summary 和 turn-prefix summary 都经过同一判断:
if (response.stopReason === "length") { return `${label} failed: generation hit the token cap and the summary is incomplete`;}回归测试 7048-compaction-truncated-summary.test.ts 先写入足够被 compact 的 user/assistant session,custom stream 返回文本 "partial summar" 且 stopReason: "length",然后同时断言:
session.compact()reject,并包含 token cap 错误;- session 中 compaction entry 数量仍为 0。
第二个断言才是 checkpoint contract:失败不仅要被报告,还不能改变 durable state。
5. 当前残留、成本与自研决策
Section titled “5. 当前残留、成本与自研决策”ccb946411 仍保留 getSummarizationFailure(),history 与 turn-prefix 两个入口都调用它;cache-friendly source-context primitives 不在当前实现中。当前事实支持“截断摘要不落盘”,不支持“摘要语义一定完整”——一个 stopReason: stop 的低质量摘要仍可能漏信息,这是 LLM checkpoint 无法靠类型完全解决的部分。
可迁移原则。 把 compaction 设计成两阶段操作:
prepare immutable input -> generate candidate through normal provider policy -> validate transport status + protocol invariants -> atomically append checkpoint -> rebuild active context失败测试必须同时断言旧 context/checkpoint 仍可用。对 cache、parallel summary 之类的优化,先证明不会改变 checkpoint identity、request policy 和失败原子性。
Go 对照与失效边界。 Go 可以用 Candidate 与 ValidatedCheckpoint 两个类型减少误写,并把 append 放在 repository transaction 中;但“摘要是否语义完整”不能靠静态类型证明。仍需 provider finish reason、结构校验、预算策略,必要时还要业务级质量门。
链五:durability-oriented session protocol 已经很深,recovery executor 仍是 scaffold
Section titled “链五:durability-oriented session protocol 已经很深,recovery executor 仍是 scaffold”1. 先纠正时间线:1d0c97471 之前已有可运行 harness
Section titled “1. 先纠正时间线:1d0c97471 之前已有可运行 harness”源码事实。 在 1d0c97471^,packages/agent/src/harness/agent-harness.ts:AgentHarness 已经直接调用 runAgentLoop(),实现 prompt()、compact()、queue、shutdown 和 session write flushing;packages/agent/test/harness/agent-harness.test.ts 覆盖真实 prompt、compaction abort 不落盘、queue drain、hook message persistence 等行为。
因此 1d0c97471 不能写成“第一次引入 AgentHarness”。它新增的是 packages/agent/src/harness/experimental/session/* 下由 memory backend 首先实现的 v2 durability-oriented session protocol,旧 runnable harness 同时仍在。
2. 1d0c97471 把“恢复所需事实”从 transcript 中分离出来
Section titled “2. 1d0c97471 把“恢复所需事实”从 transcript 中分离出来”新的 SessionStorage 不只存 message entry,还存全局递增 seq、lane pointer、fact 和 operation record。代表性 record 包括:
| record | 恢复问题 |
|---|---|
operation_started / operation_finished |
某个 run 是否已经开始、以何结果结束? |
abort_requested |
abort 是 durable intent,还是进程内一次 signal? |
step_attempt |
assistant/compaction/branch summary 的哪次 attempt 产生哪个 entry? |
tool_started + replay: "never" |
崩溃时工具可能已产生不可重放副作用吗? |
queue_enqueued / queue_cancelled |
尚未消费的 steer/follow-up/nextRun 是否可恢复? |
write_deferred |
哪个 entry 已分配但尚未提交? |
usage |
成本属于哪个 run、entry 和 attempt? |
Session 在 commit 前调用 assertJsonSerializable(),拒绝 cycle、非有限数、custom prototype、accessor 等不能稳定持久化的 payload。InMemorySessionStorage 不只是 test fake;它实现与未来 backend 共用的 protocol。
最有价值的证据是 session/testing/conformance.ts,它把 storage seam 变成可复用测试表面:
- entry、lane、record、fact 和 lane move 共用一个全局序列;
- duplicate id 失败后 state 不变;
- 多 lane 共享 tree,但各自 leaf 隔离;
- bounded/filter/cursor query 行为固定;
- queue cancellation 不 materialize 被取消目标;
- 两个 lane 的并发写入按唯一 seq 线性化;
- branch/tree fork 复制哪些 entry、lane、fact,以及明确不复制 operation records。
这比“定义了一个 SessionStorage interface”更能证明 seam 深度:新 backend 必须通过相同状态不变量,而不是只实现同名方法。
3. 44289550a 是非单调迁移:提升 durable API,同时撤掉旧执行器
Section titled “3. 44289550a 是非单调迁移:提升 durable API,同时撤掉旧执行器”44289550a 把 experimental session API 移到公开 harness/session,删除旧 repository/storage 族和大量旧 runnable harness tests,并把 AgentHarness 改写成新 public interface scaffold。
该 commit 的 agent-harness-scaffold.test.ts 只直接断言 prompt() 抛 HarnessNotImplemented;后续 14c89ad99、ed303b9bd 扩展 unavailable registry 和测试面。到 ccb946411,测试明确枚举 prompt、compact、navigateTree、resume、abort、三个 queue API、usage、wait/manual action、watch 和 lanes,要求全部以对应 HarnessNotImplemented.operation reject;带 operation records 的旧 session 在 AgentHarness.create() 中也以 create.restore reject。
这条历史链非常反直觉但很重要:
旧 runnable harness + 较弱 session model ↓ 不是原地完成迁移公开 durability-oriented session protocol + 新 harness interface scaffold ↓ 当前仍未接回 executor/recovery因此不能把“public durable types 更多”解释成“运行恢复已经完成”。这也是 deletion test:旧执行测试被删除,新 scaffold 测试主动固定 unavailable behavior,说明拆分代价是暂时失去一部分 runnable implementation,而不是纯增量解耦。
4. a838c069e 与 4a0e2f115 收紧 JSONL fork/repair 的失败语义
Section titled “4. a838c069e 与 4a0e2f115 收紧 JSONL fork/repair 的失败语义”源码事实。 a838c069e 在 packages/agent/src/harness/session/jsonl/storage.ts 增加 publishFileAtomically():先写同目录 .tmp,完成后用 renameFile() 发布;失败则 best-effort 清理 temp。它用于:
- fork:完整构造目标文件后才使目标路径可见;
- torn final line repair:先把 valid prefix 写到 temp,再替换原文件;
- storage 内用
tail: Promise<void>/enqueue()串行 mutation,保证内存 state 与 append 顺序一致。
测试注入 staging failure 与 rename failure,要求 fork 不出现在 list 中且不留下 .tmp;对 torn tail repair,要求 staging 失败时原文件字节保持不变。测试还区分“最后一行语法上残缺”与“中间坏行”:前者可截断修复,后者 reject 且不修改原文件。
4a0e2f115 的实际生产代码变化主要是 list 行为:空 header 或 invalid_entry header 被跳过,不让一个坏文件使整个 session listing 失败。该提交同时补强测试,固定以下边界:
- malformed header:open reject,list 跳过,原文件不改;
- complete 但语义非法的 final mutation:reject,不能当作 torn tail 截断;
- create/create、create/fork、fork/fork 同目标只有一个成功;失败后 reservation 必须释放;
- torn-tail repair 的 rename 失败时原 session 仍保持原样。
需要避免两个过度结论:第一,publishFileAtomically() 不覆盖每一次普通 JSONL append;第二,源码未展示数据库级 fsync/WAL 保证,rename 的崩溃语义还依赖文件系统与 env adapter。可核对结论是“fork/repair 不发布半成品、mutation 在单 storage 内线性化”,不是“任意机器故障下零数据丢失”。
5. durable storage 与 runnable durable harness 中间还缺什么
Section titled “5. durable storage 与 runnable durable harness 中间还缺什么”当前 session protocol 已经能描述 run、attempt、tool replay policy、queue 和 lane;当前 harness scaffold 却还没有把这些 record 驱动成执行状态机。至少还需实现并测试:
- 谁取得某个 lane 上 suspended operation 的执行所有权;
- provider stream 中断后从哪个 message/attempt 继续;
tool_started(replay: never)在结果未知时如何交给人工或业务补偿;- abort、close、worker crash 的区别;
- queue 在恢复后何时 materialize;
- 写入 record、外部 effect 与 result entry 之间的原子性或幂等协议。
这些是从 record 类型和未实现方法之间的缺口推导出的工程工作,不表示 Pi 已承诺具体实现方案。
可迁移原则。 自研生产 Agent 时把三层分开验收:
- durable data model:重开后数据不变量成立;
- durable operation protocol:能识别 in-flight、finished、unknown effect;
- runnable recovery executor:能抢占/恢复/人工接管,并防止重复副作用。
只完成第一层不能宣称“Agent 可恢复运行”。
Go 对照与失效边界。 Go 的 repository interface、append log、SQLite transaction 很适合实现前两层;真正执行还需要 lease/owner、幂等键、context.Context 生命周期和外部 side-effect ledger。Pi 当前 JSONL backend 偏单进程文件语义,不能直接外推为多 worker 分布式调度器。
五条链合起来,状态所有权发生了什么变化
Section titled “五条链合起来,状态所有权发生了什么变化”| 状态/决策 | 早期形态 | 历史收敛后的形态 | 仍然存在的成本 |
|---|---|---|---|
| provider 调用 | transport 同时决定 I/O 与 loop 入口 | runtime 持有 loop,注入 StreamFn |
loop config 变宽,compat/default stream 仍需维护 |
| 用户 queue | 单 queue;后改名但可中途跳过 tool | steer/follow-up 分义,steer 在 batch 后 drain | 真正的中途取消仍需 tool-specific 协议 |
| extension | hooks 与 custom tools 两套入口 | 统一 runner;effect 前有定点 barrier | AgentSession 承担 event、persistence、queue 协调 |
| compaction | 旁路 provider;文本即结果 | 共用 stream seam;验证后才 append checkpoint | 语义质量仍不能由 stop reason 完全证明 |
| durable run | runnable harness 与较弱 session model | 深 durability-oriented session/operation protocol + public scaffold | executor/recovery 尚未接通 |
最值得学习的架构动作不是“再拆一个 package”,而是把原先隐含在调用顺序里的约束变成窄 seam、状态记录和失败测试:
StreamFn把 provider 请求/事件流的主要 wire 差异留在 loop 外;认证、options、retry 与 compat 仍会进入更宽的配置面;- tool batch barrier 把“何时可 steer”写进状态机;
63ac2df24的_agentEventQueuebarrier 先把 effect 前观察阶段写进调用链,32bcdc973再把保证收敛为 awaited listener settlement;getSummarizationFailure()把 checkpoint 接受条件集中起来;- session conformance 把 backend 不变量变成可复用测试面;
HarnessNotImplemented则诚实暴露“接口设计”和“恢复执行”之间的未完成区。
给准备研发 Agent 的工程师:从历史提炼出的实现顺序
Section titled “给准备研发 Agent 的工程师:从历史提炼出的实现顺序”- 先固定 owner。 provider adapter 不拥有 loop,UI 不拥有 queue 真相,extension 不直接拥有 durable transcript;每类状态只有一个写 owner,其余通过命令或事件参与。
- 先画 effect boundary,再写 callback。 assistant 完整性、tool batch、steer、abort、checkpoint append 都要有明确先后;不要靠函数名暗示语义。
- 把事件分成观察与提交。 测试 handler 读到的是旧状态还是新状态。需要一致性的 effect 前 hook,应等待明确的 persistence barrier,而不是碰巧等一个 microtask。
- secondary LLM call 走同一 policy seam。 compaction、branch summary、judge 等调用不得绕过主 loop 的认证、路由、proxy、retry 与审计策略。
- 失败测试必须验证“没有半成品”。 tool 未执行、compaction entry 为零、fork 不可见、原文件不变,比只断言抛错更有架构价值。
- durability 分层验收。 schema、storage、operation log、executor、distributed ownership 是不同完成度;文档和指标不要把它们合并成一个“durable”标签。
- 保留 revert 的否决权。 有实现、有类型、有测试仍可能不适合当前产品;历史研究应把 revert 当作架构反馈,而不是从时间线删掉。
Go 类比总表:可以借什么,不能照搬什么
Section titled “Go 类比总表:可以借什么,不能照搬什么”| Pi 历史中的 seam | Go 中可采用的形状 | 类比失效处 |
|---|---|---|
StreamFn |
小 Streamer interface,外层 adapter 负责 provider/auth |
TS function/Promise/AsyncIterable 的错误与取消协议不同于 Go iterator/channel |
AgentTool + runtime schema |
typed command + decode/validate + execute 分层 | Go struct/interface 不自动验证外部 JSON;TypeBox 同时服务静态推导与运行时 schema |
| tool batch safe point | 单 owner state machine 或显式 barrier | goroutine/context cancellation 不保证外部 effect 未发生 |
| extension event barrier | capability interface + phase event + 串行 dispatcher | ESM 动态加载、结构类型与 Go plugin/RPC 的版本隔离不同 |
| compaction checkpoint | candidate/validated types + repository transaction | LLM summary 的语义完整性不能由类型系统证明 |
| session conformance | backend contract suite | JSONL 原子 rename 不是分布式一致性,也不是数据库事务 |
| operation records | append-only run/effect ledger | 有 ledger 不等于已有 worker lease、恢复 executor 或 exactly-once effect |
可复核证据索引
Section titled “可复核证据索引”下面的命令假定当前目录就是 Pi 源码根目录,只读 Git 对象;它们用于复核本文结论,不需要切换原工作树:
# loop ownershipgit show ffc9be886:packages/agent/src/agent.tsgit show ffc9be886:packages/agent/src/transports/ProviderTransport.tsgit diff a055fd448^ a055fd448 -- packages/ai/src/agent packages/agent/srcgit show 3caf35940:packages/agent/test/agent-loop.test.ts
# queue / tool safe pointgit diff d0a4c3702^ d0a4c3702 -- packages/agent/src packages/agent/testgit diff 208a2cc12^ 208a2cc12 -- packages/agent/src/agent-loop.ts packages/agent/test/agent-loop.test.tsgit diff 351efc828^ 351efc828 -- packages/agent/src/agent-loop.ts packages/agent/test/agent-loop.test.ts
# extension orderinggit diff c6fc08453^ c6fc08453 -- packages/coding-agent/src/core packages/coding-agent/testgit diff 63ac2df24^ 63ac2df24 -- packages/agent/src packages/coding-agent/src/core/agent-session.ts packages/coding-agent/test/agent-session-concurrent.test.tsgit diff 32bcdc973^ 32bcdc973 -- packages/coding-agent/src/core/agent-session.ts packages/coding-agent/test/suitegit diff 1ee0d28d7^ 1ee0d28d7 -- packages/coding-agent/src packages/coding-agent/test/agent-session-concurrent.test.ts
# checkpointgit diff 35f807cfa^ 35f807cfa -- packages/coding-agent/src/core/compaction packages/coding-agent/test/suite/agent-session-compaction.test.tsgit diff f58c11562^ f58c11562 -- packages/coding-agent/src/core/compaction/compaction.tsgit diff cff1cf52c^ cff1cf52c -- packages/coding-agent/src/core/compaction packages/coding-agent/testgit diff 8dab70281^ 8dab70281 -- packages/coding-agent/src/core/compaction packages/coding-agent/testgit diff 97fa14e39^ 97fa14e39 -- packages/coding-agent/src/core/compaction packages/coding-agent/test
# durable session versus runnable harnessgit show 1d0c97471^:packages/agent/src/harness/agent-harness.tsgit show 1d0c97471:packages/agent/src/harness/experimental/session/types.tsgit diff 44289550a^ 44289550a -- packages/agent/src/harness packages/agent/test/harnessgit diff a838c069e^ a838c069e -- packages/agent/src/harness/session/jsonl packages/agent/test/harness/sessiongit diff 4a0e2f115^ 4a0e2f115 -- packages/agent/src/harness/session/jsonl packages/agent/test/harness/session- 本文可以确认指定 commit 中的实现、类型、测试断言,以及这些 symbol 在
ccb946411是否仍存在。 - 本文没有把历史测试源码等同于当时 CI 真实通过;
a055fd448明确不能编译,必须由后续 repair 和 release 才能形成稳定证据。 f58c11562串行化的具体线上故障机制、cache-friendly primitives 被 revert 的完整产品原因,仅靠本地 Git 对象不能完全确认;相关段落已明确标为推断或只陈述 diff。- durable JSONL/session protocol 已有强测试证据;当前
AgentHarness的运行、恢复、effect reconciliation 仍由 scaffold 明确拒绝,不能称为已完成的 durable Agent runtime。