Files
AgentCompany/docs/proposals/M10-agent-runtime-hooks-aggregation.md
T

11 KiB
Raw Blame History

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 没有统一的契约面。

当前问题:

  1. 注册点分散:pi-agent-factory.ts 里 transformContext: compactor、preTurn: inboxProjection、postTurn: costWriter ——每个 hook 各自 import,没有统一枚举
  2. 调用顺序隐式:哪个 hook 先跑、哪个后跑依赖 pi 内部约定,我们没有声明
  3. 失败处理不一致:一个 hook 抛错,其他 hook 是否还跑?—— 当前行为依赖 pi 语义
  4. 版本签名无契约: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 计划

  1. Phase 1(2 天):实现 AgentHooks 类,内部调用保留原有 4 个 hook 模块
  2. Phase 2(2 天):pi-agent-factory.ts 从散装 import 切到 new AgentHooks(...).toPiHooks()
  3. Phase 3(3 天):加版本兼容标识 + 启动校验
  4. Phase 4(1 周):补 fixture 和契约测试
  5. 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

验证标准

  1. Typecheck 通过:hook 签名与 pi 类型严格一致
  2. 启动校验:pi 版本不 satisfies 时应用启动 fail fast
  3. 契约测试覆盖:4 个主要 hook 都有 fixture + 验证
  4. Telemetry 可观测:hook 错误被记录到 telemetry,不是静默吞
  5. 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.ts
  • src/main/agent-runtime/inbox-projection.ts
  • src/main/agent-runtime/artifact-projector.ts
  • src/main/agent-runtime/agent-event-projection.ts
  • src/main/agent-runtime/hook-handlers.ts
  • src/main/agent-runtime/hook-rpc.ts
  • src/main/agent-runtime/runtime-bridge.ts
  • src/main/agent-runtime/runtime-message-injector.ts
  • src/main/agent-runtime/sqlite-session-store.ts
  • src/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