11 KiB
Proposal M10: agent-runtime 的 hook 聚合到 AgentHooks + 契约版本化
来源:问题.md M10(行 278-282)、问题.md C5(行 174-186) 状态:proposed(与 C5 契约测试互补) 提出:Roundtable 2026-04-20(Linus + Karpathy + Steve Jobs) Owner: TBD 估计工时:1.5 周 Blocking dependencies:无,但与 C5(hook 契约测试)协同
问题陈述
src/main/agent-runtime/ 当前散装 10 个文件:
| 文件 | 职责 |
|---|---|
| agent-event-projection.ts | run event 投影 |
| artifact-projector.ts | artifact 投影 |
| context-compactor.ts | context 压缩(B3 问题点) |
| cost-writer.ts | cost 记录写入 |
| hook-handlers.ts | hook 回调处理 |
| hook-rpc.ts | hook RPC 层 |
| inbox-projection.ts | inbox 投影 |
| runtime-bridge.ts | 主进程 ↔ pi 桥接 |
| runtime-message-injector.ts | 运行时消息注入 |
| sqlite-session-store.ts | pi session 持久化 |
其中 compactor / cost-writer / inbox-projection / artifact-projector 都是注入到 pi agent 的 hook,但分散在不同文件里,没有一个统一的 AgentHooks 聚合类。
额外痛点(对照 C5):hook 的签名、调用时序、block-type 约定深度耦合 pi 版本——B1 指出的 62 patch 漂移场景下,哪些 hook 还安全完全靠作者记忆。
根因分析
Linus 视角:散装的 hook 没有统一的契约面。
当前问题:
- 注册点分散:
pi-agent-factory.ts里transformContext: compactor、preTurn: inboxProjection、postTurn: costWriter——每个 hook 各自 import,没有统一枚举 - 调用顺序隐式:哪个 hook 先跑、哪个后跑依赖 pi 内部约定,我们没有声明
- 失败处理不一致:一个 hook 抛错,其他 hook 是否还跑?—— 当前行为依赖 pi 语义
- 版本签名无契约:pi 升级如果改了 hook 参数 shape,本地代码 compile-time 可能通过但 runtime 失败
Karpathy 视角(对应 C5):agent loop 的控制权在上游,本地只能通过 hook 介入。hook 是本项目和 pi 的唯一契约面——这个契约面没治理就是随机炸。
设计方案
AgentHooks 聚合类
新建 src/main/agent-runtime/agent-hooks.ts:
import type { PiAgentHooks } from "@mariozechner/pi-agent-core";
export interface AgentHooksConfig {
compactor: ContextCompactor;
costWriter: CostWriter;
inboxProjection: InboxProjection;
artifactProjector: ArtifactProjector;
eventProjection: AgentEventProjection;
logger: Logger;
}
export class AgentHooks {
constructor(private config: AgentHooksConfig) {}
toPiHooks(): PiAgentHooks {
return {
transformContext: async (messages, ctx) => {
try {
return await this.config.compactor.maybeCompact(messages, ctx);
} catch (e) {
return this.handleHookError("transformContext", e, { passthrough: messages });
}
},
preTurn: async (ctx) => {
await this.config.inboxProjection.inject(ctx);
},
postTurn: async (ctx) => {
await Promise.all([
this.config.costWriter.write(ctx),
this.config.artifactProjector.project(ctx),
this.config.eventProjection.record(ctx),
]);
},
onError: async (err, ctx) => {
this.config.logger.error("agent.hook.error", { err, ctx });
},
};
}
private handleHookError<T>(hookName: string, err: unknown, opts: { passthrough: T }): T {
this.config.logger.error("agent.hook.error", { hookName, err });
// Fail-safe policy: hook 内部错误不 crash agent loop,但记录 telemetry
return opts.passthrough;
}
}
契约版本标识
export const AGENT_HOOKS_PI_COMPAT = "0.67.x";
// 启动时校验
import { version as piAgentVersion } from "@mariozechner/pi-agent-core/package.json";
if (!satisfies(piAgentVersion, AGENT_HOOKS_PI_COMPAT)) {
throw new Error(`Agent hooks not compatible with pi-agent-core ${piAgentVersion}`);
}
升级 pi 前必须更新 AGENT_HOOKS_PI_COMPAT,强制开发者确认 hook 签名兼容性。
Fixture 驱动的契约测试(配合 C5)
src/main/agent-runtime/__fixtures__/
├── transform-context/
│ ├── input-30-messages.json
│ └── expected-output.json
├── pre-turn/
│ └── inbox-injection.json
└── post-turn/
└── cost-write.json
契约测试:每个 fixture 的 hook 输入/输出 shape 必须稳定。升级 pi 必须跑这套测试——如果 shape 变了,测试 fail,必须显式处理 migration。
hook-handlers.ts / hook-rpc.ts 归类
这两个文件是 pi runtime 和 main 进程之间的 RPC(hook 执行位置 ≠ 进程位置)。本 proposal 不合并它们到 agent-hooks.ts,保留独立文件,但 agent-hooks 成为入口——外部代码只 import AgentHooks,内部如何走 RPC 是实现细节。
Migration 计划
- Phase 1(2 天):实现
AgentHooks类,内部调用保留原有 4 个 hook 模块 - Phase 2(2 天):pi-agent-factory.ts 从散装 import 切到
new AgentHooks(...).toPiHooks() - Phase 3(3 天):加版本兼容标识 + 启动校验
- Phase 4(1 周):补 fixture 和契约测试
- Phase 5(持续):下次 pi 升级走完整 flow——跑 fixture 测试 + review
AGENT_HOOKS_PI_COMPAT
风险与回滚
| 风险 | 触发条件 | 回滚方法 |
|---|---|---|
| Hook 执行顺序变化 | 聚合后若 pi 顺序改变 | 聚合类维持和原来一致的执行顺序,加注释 |
| Fixture 维护成本 | 每次 pi 改 hook shape 都要更新 fixture | 接受——这正是契约测试的目的 |
| 兼容范围过严 | ^0.67.0 vs 0.67.68 |
升级时 explicit 更新 compat 字符串 |
| Hook 吞 error | handleHookError 掩盖真实 bug |
强制 telemetry 记录;同时让 critical 场景(例如 cost 写入失败)走 throw 而不是 passthrough |
验证标准
- Typecheck 通过:hook 签名与 pi 类型严格一致
- 启动校验:pi 版本不 satisfies 时应用启动 fail fast
- 契约测试覆盖:4 个主要 hook 都有 fixture + 验证
- Telemetry 可观测:hook 错误被记录到 telemetry,不是静默吞
- Refactor 零行为变化:相同 agent run,新旧方案产出的 event stream / cost record 一致
不在范围
- 不重写 compactor 内部逻辑(B3 / C3 是独立 proposal)
- 不改 pi 上游:只能在 hook 层适配
- 不做 hook 的 hot-reload:启动一次加载即可
- 不实现自动 pi 升级:升级仍是人工决策 + 跑 fixture
- 不合并 hook-handlers 和 hook-rpc:它们是不同抽象层(业务 vs 协议)
参考文件清单
src/main/agent-runtime/context-compactor.ts:52-73(B3 问题代码)src/main/agent-runtime/cost-writer.tssrc/main/agent-runtime/inbox-projection.tssrc/main/agent-runtime/artifact-projector.tssrc/main/agent-runtime/agent-event-projection.tssrc/main/agent-runtime/hook-handlers.tssrc/main/agent-runtime/hook-rpc.tssrc/main/agent-runtime/runtime-bridge.tssrc/main/agent-runtime/runtime-message-injector.tssrc/main/agent-runtime/sqlite-session-store.tssrc/main/worker/pi-agent-factory.ts:131(hook 注册点)pi-mono-reference/packages/agent/(上游 hook 接口定义)
附:为什么现在修
pi 升级(B1 已完成的 0.67.68 pin)之前团队每次升级 pi 都是赌博——hook 是否还兼容没人能验证。M10 + C5 的契约测试是把"我猜还能跑"变成"测试证明能跑"的唯一办法。
M10 单独的收益:代码可读性——新工程师打开 pi-agent-factory.ts 不用猜哪几个 hook 在做什么,看 AgentHooks 的 toPiHooks() 一目了然。
注意与 B3(compactor tool_use 切腰斩)的关系:B3 是 compactor 内部的正确性问题,M10 是 hook 接入层的组织问题。两个正交,B3 先做(BLOCKER),M10 后做。
附:gating matrix
| Gate | 条件 | 放行判断人 |
|---|---|---|
| G1 | AgentHooks 类与现有 pi 版本 0.67.68 契约对齐 |
Linus |
| G2 | 4 个主要 hook 每个有 fixture 和契约测试 | Karpathy |
| G3 | 启动时校验 pi 版本在兼容范围,不在则 fail fast | Owner |
| G4 | Telemetry 可观察 hook 错误率,7 天无新 silent drop | Owner |
| G5 | 下次 pi 升级走完整 flow 验证 | Owner |
附:与 C5 的关系
C5(hook 契约测试)是 M10 的验证手段——M10 做好聚合后,C5 提供契约测试 harness。两件事 logically 可以并行做,但实现上 M10 先于 C5 更自然(先有聚合类再给它加测试)。
附:反模式对比
Before(src/main/worker/pi-agent-factory.ts:131 附近):
const agent = createPiAgent({
transformContext: contextCompactor.maybeCompact.bind(contextCompactor),
preTurn: inboxProjection.inject.bind(inboxProjection),
postTurn: async (ctx) => {
await costWriter.write(ctx);
await artifactProjector.project(ctx);
},
// ...每次加 hook 都要改 factory
});
After:
const hooks = new AgentHooks({ compactor, costWriter, inboxProjection, artifactProjector, ... });
const agent = createPiAgent(hooks.toPiHooks());
// 加新 hook 只改 AgentHooks.toPiHooks(),factory 不动
附:为什么不选其他方案
方案 A(当前 proposal):AgentHooks 聚合类 + 版本兼容标识。
方案 B(完全重写 agent loop,不用 pi):成本远大于收益——pi 已经处理了 token 流、provider 切换、retry 等。
方案 C(把 hook 留在独立文件,但加统一的 factory helper):没解决"hook 入口分散"问题,签名仍各自 drift。
方案 A 成本低、收益清晰。
附:fixture 目录结构示例
src/main/agent-runtime/__fixtures__/
├── README.md
├── transform-context/
│ ├── 30-messages-with-tool-pairs.json
│ ├── 30-messages-no-tool-calls.json
│ ├── cjk-heavy-content.json
│ └── empty-input.json
├── pre-turn/
│ ├── inbox-with-unread-messages.json
│ └── inbox-empty.json
├── post-turn/
│ ├── successful-tool-call.json
│ ├── assistant-text-only.json
│ └── error-turn.json
└── version-matrix/
└── compat-0.67.68.json
每个 fixture 包含 input + expected output + 注释说明。升级 pi 时跑 pnpm test:hook-contracts 一键验证。
附:hook error handling 策略矩阵
| Hook | 错误策略 | 理由 |
|---|---|---|
| transformContext (compactor) | passthrough(返回原 messages) | compactor 挂不应该阻塞 agent 前进 |
| preTurn (inbox projection) | throw(让 agent loop 挂) | inbox 注入失败意味着 agent 读不到新消息,前进下去会 base 错误 |
| postTurn (cost writer) | throw | cost 写入失败是数据完整性问题,必须 crash |
| postTurn (artifact projector) | passthrough + telemetry | artifact 缺失不阻塞后续 |
| postTurn (event projection) | throw | event 丢失无法事后补,必须 crash |
| onError | 记 log | terminal hook,不能 rethrow |