跳转到内容

源码考古: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 设计问题。

Pi Agent Runtime 的形态不是从一张模块图推导出来的,而是在五类失败压力下收紧的:

  1. ffc9be886 的 AgentTransport 同时承载 provider 适配和 loop 入口,导致状态在 Agent、transport、pi-ai loop 三处共同决定;a055fd448 把 loop 移回 Agent package,但它是明确不能编译的 WIP。直到 3caf35940 及中间修复把公开 seam 收敛为 StreamFn + AgentLoopConfig + AgentTool,才能说 runtime 重新拿回了控制流所有权。
  2. d0a4c3702 虽然把单一 queue 命名为 steer 和 followUp,最初的 steer 仍会在一个 assistant tool batch 中途跳过剩余工具。208a2cc12 删除 skipToolCall(),测试要求整批工具完成后才注入 steering;安全点最终由执行顺序而不是 API 名称定义。
  3. c6fc08453 合并 hooks 与 custom tools,只解决“扩展从哪里注册”;它没有自动解决“扩展在什么状态上观察”。63ac2df24 为 tool_call 增加显式事件屏障,1ee0d28d7 又补齐 extension-origin queue 的可观察性。extension 因而不是普通 callback,而是受顺序协议约束的 runtime 参与者。
  4. compaction 从旁路 completeSimple(),演进为复用主 loop 的 streamFn;随后又经历串行化、cache-friendly 方案整体 revert,以及 97fa14e39 对截断摘要的拒绝。源码最终把 compaction 当作“先验证、后落盘”的 checkpoint,而不是“拿到一段文本就保存”。
  5. 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。下面只是本文取证方式的最小复现,不是本章主线:

Terminal window
scratch="$(mktemp -d)"
git clone --shared --no-checkout . "$scratch/pi-history"
git -C "$scratch/pi-history" checkout --detach ffc9be886
git -C "$scratch/pi-history" diff ffc9be886^ ffc9be886 -- packages/agent packages/ai packages/coding-agent
git -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 必须保证什么”分开了。

在 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 做了三个连在一起的改动:

  1. executeToolCalls*() 不再返回 { toolResults, steeringMessages },只返回完整 ToolResultMessage[];
  2. 顺序和并行 executor 都删除执行期间的 getSteeringMessages() 轮询,并完全删除 skipToolCall();
  3. 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/listener
if 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。

在 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",然后同时断言:

  1. session.compact() reject,并包含 token cap 错误;
  2. session 中 compaction entry 数量仍为 0。

第二个断言才是 checkpoint contract:失败不仅要被报告,还不能改变 durable state。

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 时把三层分开验收:

  1. durable data model:重开后数据不变量成立;
  2. durable operation protocol:能识别 in-flight、finished、unknown effect;
  3. 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 的 _agentEventQueue barrier 先把 effect 前观察阶段写进调用链,32bcdc973 再把保证收敛为 awaited listener settlement;
  • getSummarizationFailure() 把 checkpoint 接受条件集中起来;
  • session conformance 把 backend 不变量变成可复用测试面;
  • HarnessNotImplemented 则诚实暴露“接口设计”和“恢复执行”之间的未完成区。

给准备研发 Agent 的工程师:从历史提炼出的实现顺序

Section titled “给准备研发 Agent 的工程师:从历史提炼出的实现顺序”
  1. 先固定 owner。 provider adapter 不拥有 loop,UI 不拥有 queue 真相,extension 不直接拥有 durable transcript;每类状态只有一个写 owner,其余通过命令或事件参与。
  2. 先画 effect boundary,再写 callback。 assistant 完整性、tool batch、steer、abort、checkpoint append 都要有明确先后;不要靠函数名暗示语义。
  3. 把事件分成观察与提交。 测试 handler 读到的是旧状态还是新状态。需要一致性的 effect 前 hook,应等待明确的 persistence barrier,而不是碰巧等一个 microtask。
  4. secondary LLM call 走同一 policy seam。 compaction、branch summary、judge 等调用不得绕过主 loop 的认证、路由、proxy、retry 与审计策略。
  5. 失败测试必须验证“没有半成品”。 tool 未执行、compaction entry 为零、fork 不可见、原文件不变,比只断言抛错更有架构价值。
  6. durability 分层验收。 schema、storage、operation log、executor、distributed ownership 是不同完成度;文档和指标不要把它们合并成一个“durable”标签。
  7. 保留 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

下面的命令假定当前目录就是 Pi 源码根目录,只读 Git 对象;它们用于复核本文结论,不需要切换原工作树:

Terminal window
# loop ownership
git show ffc9be886:packages/agent/src/agent.ts
git show ffc9be886:packages/agent/src/transports/ProviderTransport.ts
git diff a055fd448^ a055fd448 -- packages/ai/src/agent packages/agent/src
git show 3caf35940:packages/agent/test/agent-loop.test.ts
# queue / tool safe point
git diff d0a4c3702^ d0a4c3702 -- packages/agent/src packages/agent/test
git diff 208a2cc12^ 208a2cc12 -- packages/agent/src/agent-loop.ts packages/agent/test/agent-loop.test.ts
git diff 351efc828^ 351efc828 -- packages/agent/src/agent-loop.ts packages/agent/test/agent-loop.test.ts
# extension ordering
git diff c6fc08453^ c6fc08453 -- packages/coding-agent/src/core packages/coding-agent/test
git diff 63ac2df24^ 63ac2df24 -- packages/agent/src packages/coding-agent/src/core/agent-session.ts packages/coding-agent/test/agent-session-concurrent.test.ts
git diff 32bcdc973^ 32bcdc973 -- packages/coding-agent/src/core/agent-session.ts packages/coding-agent/test/suite
git diff 1ee0d28d7^ 1ee0d28d7 -- packages/coding-agent/src packages/coding-agent/test/agent-session-concurrent.test.ts
# checkpoint
git diff 35f807cfa^ 35f807cfa -- packages/coding-agent/src/core/compaction packages/coding-agent/test/suite/agent-session-compaction.test.ts
git diff f58c11562^ f58c11562 -- packages/coding-agent/src/core/compaction/compaction.ts
git diff cff1cf52c^ cff1cf52c -- packages/coding-agent/src/core/compaction packages/coding-agent/test
git diff 8dab70281^ 8dab70281 -- packages/coding-agent/src/core/compaction packages/coding-agent/test
git diff 97fa14e39^ 97fa14e39 -- packages/coding-agent/src/core/compaction packages/coding-agent/test
# durable session versus runnable harness
git show 1d0c97471^:packages/agent/src/harness/agent-harness.ts
git show 1d0c97471:packages/agent/src/harness/experimental/session/types.ts
git diff 44289550a^ 44289550a -- packages/agent/src/harness packages/agent/test/harness
git diff a838c069e^ a838c069e -- packages/agent/src/harness/session/jsonl packages/agent/test/harness/session
git 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。