扩展系统:工具、Hooks 与热重载的拼装规则

系列第 5 篇,共 8 篇。

Pi Durable 的 harness 本身很小:调度、提交、恢复、压缩。Agent 具体会什么,几乎全部来自扩展。

一个扩展不是插件目录,也不是配置文件,而是一个命名捆绑包:tools、sections、hooks、wraps、tasks。对话只保存扩展和工具的名字,每次使用时对着进程内的 registry 解析。这个设计让热重载、按对话选能力、重启后重新绑定成为同一套机制。

一、一个扩展包含什么

const Coding = defineExtension({
    name: "coding",
    sections: [
        section("preamble", () => "You are a concise coding assistant.", { tag: false }),
    ],
    hooks: [
        hook(ToolTask, {
            beforeTool: (call) => (isDangerous(call) ? { block: "Needs approval" } : undefined),
        }),
    ],
    tools: [readTool, writeTool, editTool, bashTool],
    wraps: [],
    tasks: [],
});

五类成员的作用:

成员作用何时生效
sections构造 system prompt 片段每次模型请求前重新渲染
tools提供给模型或脚本调用请求准备时解析
hooks介入内置任务的生命周期任务执行时
wraps装饰「最终胜出」的同名工具工具解析后
tasks扩展自带的任务类型任务创建时
flowchart TD
    R["Registry(进程内)"] --> E1["Extension: coding"]
    R --> E2["Extension: ops"]
    R --> E3["Extension: approval"]
    E1 --> T1["tools"]
    E1 --> S1["sections"]
    E1 --> H1["hooks"]
    E2 --> W1["wraps"]
    E3 --> T2["tasks"]
    C["Conversation(存名字)"] -.->|"解析"| R

Registry 不落存储。对话的 pi.agent 文档里只保存名字,所以扩展被卸载后,选中它的对话只是暂时拿不到它,重新安装即恢复。

二、解析管线:一次请求用到了什么

一次模型请求的配置在准备时固定,但执行环境在每次使用时构建。这个区别很重要:

flowchart LR
    A["Conversation 的 pi.agent"] --> B["按名字解析扩展"]
    B --> C["合并 tools / sections / hooks"]
    C --> D["构造 system prompt 与工具列表"]
    D --> E["模型请求(配置固定)"]
    B --> F["每次工具调用构建 ExecutionEnv"]
    F --> G["按当前 cwd / 容器解析"]
  • 模型、prompt、提供的工具列表:请求准备时固定,变更从下一次请求生效。
  • cwd、执行环境、扩展解析:每次使用时读取,因此改 cwd 或换扩展可以影响模型已经发出的工具调用。

这也是一个常见踩坑点:你以为改了配置,正在跑的那一轮会立刻用新模型;实际上模型要下一轮才换,而工具环境可能已经变了。

2.1 按对话裁剪能力

configure() 修改 pi.agent,可以精确选择扩展与工具:

await thread.configure({
    tools: { remove: [deploy] },          // 从默认中去掉 deploy
    modelTools: [readTool],               // 只把 read 提供给模型;工具仍可调用 bash
    extensions: { add: [Reviewer] },      // 在默认基础上增加
    instructions: "Only read; never edit files.",
    cwd: "/work/repo",
}, context);

modelTools 是一个容易误解的字段:它只缩小「模型被提供哪些工具」的范围,不会把工具从其他工具手里拿掉。脚本、嵌套调用仍然可以调用它们。

工具的 callers 则从另一个方向限制:

const codemode = defineTool({ name: "codemode", callers: ["model"], ... });
const search = defineTool({ name: "mcp__github__search", callers: ["tools"], ... });

["model"] 表示只有模型能调用,脚本不能启动脚本;["tools"] 表示永不提供给模型,只允许嵌套调用。

三、System Prompt:section 的渲染顺序

System prompt 由选中扩展的 sections 按顺序渲染,每次请求前重建。一个 section 看得到解析后的 agent、为该请求构建的环境,以及已提交的 documents。

flowchart TD
    A["扩展安装顺序"] --> B["section 1"]
    B --> C["section 2"]
    C --> D["..."]
    D --> E["instructions(最后渲染)"]
    E --> F["完整 system prompt"]

规则:

  • 返回 undefined 的 section 被省略;
  • { tag: false } 表示不加 XML 标签包裹,默认会包成 <preamble>...</preamble>;
  • 对话的 instructions 最后渲染,作为 instructions section;
  • sections 与工具变更以位置化的 system entries 存进 transcript,只重发变化的部分,从而保持 provider 的 prompt cache 有效。

最后一条有一个实际约束:每次都返回不同内容的 section(比如当前时间)会破坏缓存。要么把它放在不敏感的位置,要么接受缓存失效的代价。

四、工具:独立任务与三个输出通道

每次工具调用都是一个独立的持久任务,其意图在 execute() 运行之前就已提交。

sequenceDiagram
    autonumber
    participant M as 模型
    participant G as Generation 任务
    participant T as Tool 任务
    participant S as Storage
    participant E as ExecutionEnv

    M-->>G: 工具调用(name + args)
    G->>S: commit(pi.tool 任务,含意图)
    T->>E: 执行
    E-->>T: 输出
    T->>S: commit(output / details)
    T-->>G: 结果
    G->>S: commit(pi.tool-result)

一个结果有三个通道:

通道给谁是否进 transcript
output模型是
structuredOutput调用该工具的程序否(留在任务文档)
detailsUI是

声明了 structuredOutputSchema 的工具必须返回匹配的 structuredOutput,Harness 会校验。程序调用没有该 schema 的工具时,拿到的是有界的 output:单个文本项 → 字符串,单张图 → 其 ImageContent,什么都没有 → "",其他情形 → 内容列表。

重放策略只有两种:

const searchIssues = defineTool({
    name: "search_issues",
    description: "Search the issue tracker",
    parameters: Type.Object({ query: Type.String() }),
    replay: "safe", // 只读,崩溃后重跑没问题
    execute: async (args, api) => {
        api.output(`searching for ${args.query}\n`);
        return { content: [{ type: "text", text: await tracker.search(args.query) }] };
    },
});

const deploy = defineTool({
    name: "deploy",
    description: "Deploy a version to production",
    parameters: Type.Object({ version: Type.String() }),
    // 无 replay:被崩溃打断的 deploy 只报告给模型,绝不重放
    execute: async (args) => ({
        content: [{ type: "text", text: await ci.deploy(args.version) }],
    }),
});

工具还能返回控制指令:

  • control: { terminate: true }:当一轮里所有结果都要求终止时,Run 直接结束;
  • control: { handoff: "..." }:触发会话 reset 并交接。

五、Hooks:介入任务生命周期

Hooks 让扩展介入内置任务,包括 generation、tool 与 compaction。

flowchart LR
    A["Generation 任务"] --> B["beforeRequest"]
    B --> C["模型请求"]
    C --> D["afterResponse"]
    D --> E["工具轮次"]
    E --> F["afterTools"]
    F --> G["onYield"]
    H["Tool 任务"] --> I["beforeTool"]
    I --> J["execute"]
    J --> K["afterTool"]

各 hook 的语义:

Hook能做什么异常行为
beforeRequest替换某次请求的消息上报并跳过
afterResponse观察响应上报并跳过
onYield用另一条用户消息继续这个 Run上报并跳过
afterTools一轮工具做完后跑一次上报并跳过
beforeTool阻断或改写参数阻断该调用
afterTool替换结果上报并跳过

多个扩展可以 hook 同一件事。它们按对话选择扩展的顺序组成链:

  • beforeTool 把改写后的参数沿链下传,第一个 block 终止链;
  • afterTool 把结果沿链下传;
  • onYield 在第一个「让 Run 继续」的 hook 处停止;
  • 像 afterResponse 这类观察者总是全部执行。

Hook 可能在崩溃后再次运行,所以做决策的 hook 要把决策存进 memo(随任务存储的小值,先写者胜):

hook(ToolTask, {
    beforeTool: async (call, api, context) => {
        if (call.name !== "deploy") return undefined;
        let approved = await api.memo<boolean>("approval:deploy", context);
        approved ??= await api.memo("approval:deploy", await askInSlack(call), context);
        return approved ? undefined : { block: "Nobody approved the deploy." };
    },
});

六、Wraps 与同名替换

两个扩展可以声明同名工具。规则是:后安装的替换先安装的;wraps 则装饰最终胜出的那个工具。

flowchart TD
    A["内置 bash"] --> B["venv 扩展:同名 bash(替换)"]
    B --> C["最终胜出的 bash"]
    W["timing 扩展:wrapTool(bash)"] -.->|"装饰"| C
    C --> D["模型与脚本调用"]
const Venv = defineExtension({
    name: "venv",
    tools: [createBashTool({ commandPrefix: "source .venv/bin/activate" })],
});

const Timing = defineExtension({
    name: "timing",
    wraps: [
        wrapTool(createBashTool(), (bash) => ({
            ...bash,
            execute: async (args, api, context) => {
                const start = Date.now();
                try {
                    return await bash.execute(args, api, context);
                } finally {
                    metrics.record("bash", Date.now() - start);
                }
            },
        })),
    ],
});

Venv 替换内置 bash;Timing 不捕获具体实现,因此无论哪个 bash 胜出,计时都生效。

七、Code Mode:脚本作为可恢复的嵌套调用

@earendil-works/pi-durable/codemode 增加一个 codemode 工具:模型写 JavaScript,在 QuickJS 沙箱里以 await tools.<name>(args) 调用该对话的工具。只有脚本的输出到达模型。

flowchart TD
    M["模型"] --> C["codemode 工具"]
    C --> S["QuickJS 沙箱"]
    S --> T1["tools.read"]
    S --> T2["tools.bash"]
    S --> T3["tools.mcp__github__search"]
    T1 --> J["journal(每个结果先落盘)"]
    T2 --> J
    T3 --> J
    J --> S
    S --> O["脚本输出 → 模型"]

脚本能挺过重启,靠的是确定性重放而不是进程快照:

  1. 沙箱用虚拟时钟、带种子的随机与 UTC 运行脚本;
  2. 每个工具结果在交给脚本之前先写入 journal;
  3. 重启后,工具重跑脚本,并按同样顺序把 journal 里的结果交给它;
  4. 这些调用按键重新附着到各自的嵌套调用,脚本继续。

代价是脚本必须是纯的(不能依赖真实时间或随机),收益是沙箱本身不必持久化。如果脚本走了不同分支、沙箱指纹变化、或 timeout_ms 已过,重跑会以错误结束这次调用。

八、热重载

Registry 可以在对话运行时变更。用已存在的名字安装扩展,会一步替换它:

registry.install(await loadExtension("./ops.ts")); // 同名 "ops":替换
registry.uninstall(extension);                     // 按名字移除

运行时语义:

  • 已经在运行的工具调用,用它启动时的代码跑完;
  • 下一次调用才用新代码;
  • 对话存的是名字、而非代码,所以重启后采用新进程安装的东西。
sequenceDiagram
    participant R as Registry
    participant C as Conversation
    participant T as 运行中的工具

    R->>R: install("ops", v2)
    Note over T: 继续用 v1 跑完
    C->>R: 下一次解析
    R-->>C: 得到 v2

小结

扩展系统把「Agent 会什么」从框架里拆出来,交给可替换的命名捆绑包:

  • sections 决定模型看到什么;
  • tools 决定模型能做什么,每次调用都是持久任务;
  • hooks 介入任务生命周期,决策要存 memo;
  • wraps 装饰最终胜出的工具,同名工具后者替换前者;
  • Code Mode 把脚本变成可确定性重放的嵌套调用;
  • 热重载只替换代码,不改变对话里保存的名字。

下一篇进入多 Agent 场景:当工具创建子对话、子任务再创建子任务,谁拥有谁,abort 又该按什么顺序传播。