扩展系统:工具、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最后渲染,作为instructionssection; - 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 | 调用该工具的程序 | 否(留在任务文档) |
details | UI | 是 |
声明了 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["脚本输出 → 模型"]
脚本能挺过重启,靠的是确定性重放而不是进程快照:
- 沙箱用虚拟时钟、带种子的随机与 UTC 运行脚本;
- 每个工具结果在交给脚本之前先写入 journal;
- 重启后,工具重跑脚本,并按同样顺序把 journal 里的结果交给它;
- 这些调用按键重新附着到各自的嵌套调用,脚本继续。
代价是脚本必须是纯的(不能依赖真实时间或随机),收益是沙箱本身不必持久化。如果脚本走了不同分支、沙箱指纹变化、或 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 又该按什么顺序传播。
