8.5 取消与 Extension Hook
本页分析版本earendil-works/pi@c13ffe12026-07-30本章解决什么问题:到 8.4 为止,我们的 Harness 已经能对话、能调工具、能存档,但它还缺两件让它「像个能用的产品」的能力——用户按下 Ctrl+C 只能杀掉整个进程;想加一个新工具只能改
src/tools/。本章给它补上取消与扩展(Extension):一次 Ctrl+C 只打断当前这一轮,一个外部.ts文件不改核心代码就能注册工具、观察事件。 前置知识:8.4 Session 保存与恢复(本章在它的基础上增量修改)、2.7 事件、回调与取消(AbortController)、5.6 取消与错误处理、7.1 Extension 系统。 学习目标:读完本章后你能
- 说出一条 AbortSignal 从 Ctrl+C 一直传到最底层
setTimeout的完整链路,并写出一个「timer 与 abort 赛跑」的可取消sleep;- 指出 Agent Loop(Agent 循环)里三个取消点的位置,并解释为什么刻意不在工具执行中途设第四个;
- 说清被取消的部分输出为什么仍要进上下文(Context)、仍要落盘;
- 用
onEvent/registerTool两个钩子设计一个最小扩展点,并用动态import()在运行时加载它;- 对照 Pi 的
ExtensionFactory/ExtensionAPI,说出我们省掉了什么、Pi 为什么需要那些。
本章对应实验目录 labs/mini-agent-harness/step07-abort-extensions。本章正文里出现的所有代码都直接取自该目录;终端输出有两个来源,都是真实运行录制、不是手写:非交互演示的输出取自该目录的 expected-output.txt,交互模式下按 Ctrl+C 的那段取自 research/cli-captures/step07-ctrlc.txt(伪终端采集,采集方式写在文件开头)。
建立直觉:取消是一根从上往下穿的绳子
先明确一件事:在 Agent 里,Ctrl+C 的含义不是「退出」,而是「我不想听这一段了」。
模型可能正在长篇大论,也可能正要调用一个你不想让它调的工具。这时用户想要的是:这一轮立刻停,会话留着,马上改口重问。真要退出,还有 /exit,以及空闲时再按一次 Ctrl+C。
难点在于,「这一轮」并不是一个函数,而是一叠嵌套的等待:
main.ts 的 while 循环
└── runAgentLoop 的 for 循环(第 1 轮、第 2 轮……)
└── streamFn 的 for await(一个 token 一个 token)
└── sleep 里那个 setTimeout(真实模型里这里是 fetch)abort() 发生在最上面一层,真正「卡住不动」的却是最下面那个 setTimeout。中间隔着三层,每一层都不知道上面发生了什么。AbortController 解决的就是这件事:它把一个引用(signal)交给每一层,谁需要谁自己监听。绳子从上往下穿,拽一下,每一层都感觉得到。
signal.aborted(或监听 abort 事件)并决定停下的位置。取消不是抢占式的——JavaScript 不会中途掐断你的函数,只有你自己查了才会停。所以「设计取消」等于「选取消点」:选少了,用户按完键要等很久;选多了,代码里全是判断;选错了位置,会在不该停的地方留下半截状态。 abort() 是一次性、不可逆的:signal.aborted 一旦变成 true 就永远是 true,没有「复位」方法。所以每一轮回复都要 new AbortController()。复用同一个 controller 的后果是:用户第一次按 Ctrl+C 之后,之后每一轮都会在开头立刻被取消,看起来像「程序坏了」。 最小示例一:一个可以被打断的 sleep
从最底层开始。step03 起我们就用 sleep 来模拟模型吐字的节奏,它此前是最朴素的写法(step03-streaming/src/fake-model.ts:38,签名里连 signal 都没有)。step07 把它换成了这样:
// src/fake-model.ts:57-68
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
if (signal?.aborted) return Promise.resolve();
return new Promise((resolve) => {
const timer = setTimeout(finish, ms);
function finish(): void {
clearTimeout(timer);
signal?.removeEventListener("abort", finish);
resolve();
}
signal?.addEventListener("abort", finish, { once: true });
});
}三个细节值得逐个看:
- 先查一次
signal?.aborted。signal 可能在进入这个函数之前就已经被取消了,那就不必再造一个 Promise。 - 让 timer 和 abort 事件赛跑,谁先到谁
resolve。没有这一步,取消信号到了也得等这一觉睡完——用户按完 Ctrl+C 还要盯着不动的光标。 - 谁先到,都要清掉另一个:
clearTimeout(timer)加removeEventListener("abort", finish)。少了这两行,程序功能上仍然正确,但一个长会话会在 signal 上堆积成百上千个再也不会触发的监听器,以及一堆空转的定时器。
第 2 点有个容易被误解的地方,值得先做一次实验再往下读:把 abort 监听整个删掉、只留 return new Promise((resolve) => setTimeout(resolve, ms));,本章后面那个 demo 的输出一个字都不会变(写作时实测过)。原因是下一次 signal?.aborted 检查照样会命中,只是要等这一觉自然睡醒才轮到它。也就是说,赛跑这件事改善的不是结果,是延迟——它不影响最终打印出什么,只影响用户从按下按键到看见 [已取消] 之间要等多久。delayMs 是 200 毫秒时你几乎察觉不到;换成真实模型,这个「一觉」是一次可能持续几十秒的 HTTP 响应等待(见 8.6),差别就非常刺眼了。
注意它 resolve 而不是 reject。这是延续 step03 定下的那条契约——错误与取消是事件,不是异常。sleep 抛异常的话,streamFn 就得包一层 try/catch,而 streamFn 的类型注释写得很明确:
// src/types.ts:98-102
/**
* 模型调用抽象。契约:**不允许 throw,错误编码成 error 事件**。
* FakeModel 与真实 Provider(模型服务提供方)都实现这一个类型。
*/
export type StreamFn = (context: Context, options?: StreamOptions) => AsyncIterable<StreamEvent>;于是取消在 FakeModel 里的表现就是「提前收尾」而不是「炸掉」:
// src/fake-model.ts:109-126(节选,吐 token 的循环)
for (const token of tokenize(reply)) {
if (delayMs > 0) await sleep(delayMs, signal);
// 每吐一个 token 前检查一次取消:把已生成的部分内容原样交出去,
// 用 stopReason "aborted" 标明「我是被打断的,不是说完了」。
if (signal?.aborted) {
yield {
type: "done",
message: {
role: "assistant",
content: text.length > 0 ? [{ type: "text", text }] : [],
stopReason: "aborted",
},
};
return;
}
text += token;
yield { type: "text_delta", delta: token };
}被取消的那条消息里带着已经生成的部分内容,这一点后面还会再出现两次(一次在循环里,一次在会话文件里),是本章最需要记住的取舍。
"aborted" 这个取值其实从 step03 起就留在 StopReason 里了(当时是 "stop" | "error" | "aborted",step04 加入 "toolUse" 变成今天的 src/types.ts:44),但直到本步骤才第一次被真正用上。留一个当时用不到的取值不算浪费:它在类型里先占好位置,后面所有 switch 才会在加上这条分支时被编译器提醒。
最小示例二:三个取消点,以及那个刻意不设的第四个
signal 到了循环这一层。runAgentLoop 的选项多了一个字段:
// src/agent-loop.ts:41-42
/** 取消信号。会被透传给 streamFn,循环自己也在几个点上检查它。 */
signal?: AbortSignal;「透传给 streamFn」是一行(agent-loop.ts:106):
for await (const event of streamFn(context, { ...streamOptions, signal })) {「循环自己也检查」则一共三处,位置都是选过的:
取消点一:开始新一轮之前(agent-loop.ts:97-101)。用户在第 1 轮的工具刚跑完、第 2 轮还没发请求时按下 Ctrl+C,不该再白白发一次请求出去。
for (let turn = 1; turn <= maxTurns; turn++) {
// 取消点一:开始新一轮之前。
if (signal?.aborted) {
yield { type: "abort" };
return;
}取消点二:模型流结束之后(agent-loop.ts:114-124)。这是最主要的一个——大多数取消都发生在模型正在吐字的时候,而 FakeModel 把取消编码成了 stopReason: "aborted" 的 done 事件,所以循环只要读这个字段就够了:
if (!assistant) return;
// 部分内容也要进上下文:用户已经在屏幕上看到它了,
// 历史里少了这一段,下一轮模型和用户看到的就不是同一份对话。
context.messages.push(assistant);
// 取消点二:模型这一轮是被打断的,不再往下执行工具。
if (assistant.stopReason === "aborted") {
yield { type: "abort" };
return;
}请注意这两句的顺序:先 push 再判断。半截助手消息照样进上下文——用户已经在屏幕上看到那半句话了,如果历史里没有它,下一轮模型看到的对话和用户记忆里的对话就是两份。
取消点三:每个工具执行之前(agent-loop.ts:132-138)。一条助手消息可能声明了三次工具调用,取消发生时可能才跑完第一个:
for (const toolCall of toolCalls) {
// 取消点三:每个工具执行之前。已经开始执行的工具让它跑完,
// 半途掐断一个正在写文件的工具比让它写完更危险。
if (signal?.aborted) {
yield { type: "abort" };
return;
}没有第四个取消点:工具执行中途。 这是刻意的。一个正在写文件的工具被从外面强行掐断,留下的是一个写了一半的文件;一个正在跑 git commit 的工具被掐断,留下的是一个说不清成没成的仓库状态。想支持这件事的正确做法不是从外面中断,而是把 signal 作为参数传进工具的 execute,由工具自己在安全点上停下——Pi 就是这么做的,本章后半部分会看到那一行。
return 之后,那些已经执行完的工具结果怎么办?答案是都留着。它们已经被 context.messages.push(result) 进了上下文,也会被写进会话文件。取消不是事务回滚,它只是「到此为止」。真实世界里工具的副作用(文件已经写了、命令已经跑了)本来也回滚不掉,假装能回滚只会让状态更难对齐。 最小示例三:CLI 里的 Ctrl+C
最上面一层在命令行界面(CLI,Command-Line Interface)的入口 src/main.ts。核心是一个变量加一个函数:
// src/main.ts:94-107
// ── Ctrl+C:正在回复时取消这一轮,空闲时退出 ────────────────────
let active: AbortController | undefined;
function handleInterrupt(): void {
if (active) {
active.abort();
return;
}
console.log("\n再见。");
rl.close();
process.exit(0);
}
// readline 会拦截终端的 Ctrl+C 并在自己身上触发 SIGINT,所以两个都要监听。
rl.on("SIGINT", handleInterrupt);
process.on("SIGINT", handleInterrupt);active 有值就表示「正在回复」,于是 Ctrl+C 取消这一轮;active 是 undefined 就表示「正等着你输入」,于是 Ctrl+C 退出。同一个按键,两种含义,全靠这一个变量区分。
主循环里,active 的生命周期严格等于一轮回复:
// src/main.ts:126-136
// 每一轮一个新的 AbortController:取消是「这一轮」的事,不是整个程序的事。
active = new AbortController();
const events = runAgentLoop({
streamFn,
context,
tools,
toolContext: { cwd: projectRoot },
signal: active.signal,
});
await renderEvents(withExtensions(events, host));
active = undefined;两个容易被跳过的点:
- 两个 SIGINT 监听都要挂。 终端下的 readline 会自己拦截 Ctrl+C 并在自己身上触发
SIGINT事件;而输入被重定向、或某些环境里没有走 readline 时,信号会直接落到process上。只挂一个,总有一种场景不生效。 - Ctrl+C 与 Ctrl+D 是两条完全不同的路。 Ctrl+C 是「打断这一轮」,走上面这个中断处理器;Ctrl+D 是「输入结束」,让 step01 就写好的
ask()返回undefined(main.ts:89-92),主循环正常break、正常保存会话、正常打印再见。把它们混成一件事,会得到「按 Ctrl+D 之后会话没存」这种很难查的问题。
这段代码在真实终端里跑起来是这样(真实采集,完整素材见 research/cli-captures/step07-ctrlc.txt。采集方式:用 Python 的 pty 模块 fork 一个真正的伪终端,在里面运行 tsx src/main.ts,先送进「说个长的」加回车,500 毫秒后送进一个字节 0x03——终端上 Ctrl+C 的真实字节——再过 1 秒在空闲提示符上再送一次;输出剥掉了 ANSI 控制序列):
你> 说个长的
[第 1 轮]
助手> 这是一段很长的回复,我会一个 token 一个
[已取消]
你>
再见。两次 Ctrl+C,两种结果:第一次落在吐字过程中,屏幕上留下半句话、换行打印 [已取消],然后**「你> 」提示符重新出现**,进程还活着,可以接着说下一句;第二次落在空闲的提示符上,打印「再见。」退出。这正是 handleInterrupt 里那个 if (active) 分支在两种时刻的两种表现。
必须用伪终端而不是管道来采集,是因为 readline 只有在终端下才会拦截 Ctrl+C 并在自己身上触发 SIGINT——用管道喂输入,走的就不是 rl.on("SIGINT") 那条路径。另外,半句话停在哪个字上取决于机器速度(main.ts 里 delayMs 是 40 毫秒),它不是一个可复现的固定值;下一节 demo 里那个 700 毫秒的版本才是。
图 8.5-1 一次 Ctrl+C 在 Mini Harness 里的完整传播
阅读顺序:从上到下。请重点看三处:① 最上面那个分岔就是「取消这一轮」与「退出程序」的分界,判据只是 active 是否有值;② signal 同时流向三个取消点和 streamFn,但真正让循环停下来的是取消点二——signal 负责让正在等待的 sleep 醒过来,stopReason: "aborted" 负责让循环退出,这两件事是分开的;③ 三条支路最终汇成同一个 abort 事件,扩展与渲染层各看一遍,谁也不知道对方存在。图中每个节点的文件与行号都能在 labs/mini-agent-harness/step07-abort-extensions 里对上。
最小示例四:不改核心代码加能力
第二件事:扩展。
设计一个扩展点,本质上要回答三个问题:外部代码长什么样、宿主给它什么、什么时候调用它。 我们的答案分别是:一个默认导出的工厂函数、一个叫 pi 的对象、加载时调用一次。
src/extensions.ts 里只有四样东西。第一样是给外部代码看的接口:
// src/extensions.ts:20-29
/** 扩展工厂拿到的 `pi` 对象。 */
export interface ExtensionAPI {
/** 注册一个事件观察者。多个扩展的观察者按注册顺序依次调用。 */
onEvent(handler: (event: HarnessEvent) => void): void;
/** 注册一个工具。名字与内置工具冲突时会抛错。 */
registerTool(tool: ToolDefinition): void;
}
/** 扩展的唯一形态:一个默认导出的工厂函数。 */
export type ExtensionFactory = (pi: ExtensionAPI) => void | Promise<void>;两个钩子(Hook,宿主预先留好、供外部代码挂上自己逻辑的位置),一个观察(onEvent),一个注入(registerTool)。这是能体现「扩展」这件事的最小组合:观察让扩展知道 Agent 在干什么,注入让扩展改变 Agent 能干什么。
第二样是它的实现 ExtensionHost。它保管所有观察者,并负责派发:
// src/extensions.ts:47-59
/**
* 把一个事件派发给所有观察者。
* 观察者抛异常不能影响宿主——扩展是别人写的代码,它崩了不该拖垮 Agent。
*/
emit(event: HarnessEvent): void {
for (const handler of this.handlers) {
try {
handler(event);
} catch (error) {
console.error(`[扩展错误] ${error instanceof Error ? error.message : String(error)}`);
}
}
}那个 try/catch 不是防御性编程的习惯动作,而是宿主与扩展之间的责任边界:扩展是别人写的代码,它崩了应该只影响它自己。
第三样是加载:
// src/extensions.ts:69-78
export async function loadExtension(file: string, host: ExtensionHost): Promise<void> {
const absolute = path.resolve(file);
const module = (await import(pathToFileURL(absolute).href)) as { default?: unknown };
const factory = module.default;
if (typeof factory !== "function") {
throw new Error(`扩展 ${file} 必须默认导出一个函数`);
}
await (factory as ExtensionFactory)(host);
host.loaded.push(path.basename(absolute));
}import(x) 是一个返回 Promise 的函数调用,路径可以是运行时才拼出来的字符串;而文件顶部的静态 import 语句路径必须在编译期确定、模块在程序启动时就全部加载。插件系统、按需加载都靠前者。代价是类型检查帮不上忙——await import(x) 的结果对 TypeScript 来说是未知形状,所以上面这段手工检查了「默认导出必须是函数」,检查完才敢断言成 ExtensionFactory。 pathToFileURL 那一步容易被忽略:动态 import() 的参数是一个 URL,Windows 上的绝对路径 C:\Users\... 会被解析成协议名为 c 的 URL。转成 file:// 开头的 URL 才在三大平台上都对。
第四样把事件流分给扩展:
// src/extensions.ts:85-93
export async function* withExtensions(
events: AsyncIterable<HarnessEvent>,
host: ExtensionHost,
): AsyncGenerator<HarnessEvent> {
for await (const event of events) {
host.emit(event);
yield event;
}
}这是一个「中间件」形状的异步生成器:进来一个事件,先给观察者看一眼,再原样交给下游。于是 renderEvents(withExtensions(events, host)) 这一行里,渲染层完全不知道扩展存在,扩展也不知道渲染层存在,两边只认同一个 HarnessEvent 类型。
用户那一侧:extensions/logger.ts
示例扩展放在 src/ 之外,这一点是故意的——它代表「用户的代码」,宿主是在运行时才知道它存在的。
// extensions/logger.ts:16-48(节选)
export default function loggerExtension(pi: ExtensionAPI): void {
let toolCalls = 0;
pi.onEvent((event) => {
switch (event.type) {
case "tool_call":
toolCalls += 1;
console.log(` [扩展] 第 ${toolCalls} 次工具调用:${event.toolCall.name}`);
break;
case "abort":
console.log(" [扩展] 观察到一次取消");
break;
default:
// 其他事件不关心。扩展只挑自己在意的事件处理。
break;
}
});
pi.registerTool({
name: "upper",
description: "把一段文本转换成大写字母。",
parameters: { /* …(省略:JSON Schema,与内置工具同一形状) */ },
async execute(args) {
return { content: (args.text as string).toUpperCase() };
},
});
}toolCalls 这个计数器住在工厂函数的闭包里——工厂作用域就是扩展的实例作用域,这是「工厂函数」这种形态自带的好处:不需要额外发明一套「扩展状态」的存储机制。
最后是宿主装配,在 main.ts 的开头:
// src/main.ts:41-51
// ── 工具与扩展 ─────────────────────────────────────────────────
const tools = createBuiltinTools();
const host = new ExtensionHost(tools);
for (const file of extensionFiles) {
try {
await loadExtension(path.resolve(projectRoot, file), host);
} catch (error) {
// 扩展加载失败不该让整个程序起不来,报告之后继续。
console.error(`加载扩展失败 ${file}:${error instanceof Error ? error.message : String(error)}`);
}
}「加载失败只报告不退出」是另一条责任边界:一个坏扩展不该让整个程序起不来。
图 8.5-2 两个钩子分别走哪条路
阅读顺序:从左到右。这张图要说明的是「观察」与「注入」是两条独立的路:registerTool 走的是上面那条,在加载时一次性把工具写进注册表,之后由 tools.specs() 进入上下文,模型才可能发起对它的调用;onEvent 走的是下面那条,每次 Agent Loop 吐事件时才被调用。两条路唯一的交汇点是那个 pi 对象——也就是 ExtensionHost 实例。请特别注意 WX 这个节点有两个下游:扩展和渲染各收到一份完整事件流,顺序是先扩展后渲染。
运行:demo 的真实输出
上面那段采集要靠伪终端手工驱动,没法放进 npm run 里逐字比对,所以本步骤和前面每一步一样提供了一个非交互的 src/demo.ts。它做两件事:加载 extensions/logger.ts 跑一次带工具的循环;然后用一个定时器在 700 毫秒时取消一段慢速回复。
cd labs/mini-agent-harness/step07-abort-extensions
npm install
npm run demo完整输出(expected-output.txt,真实运行录制):
Mini Agent Harness · step07 abort-extensions(demo)
已加载扩展:logger.ts
可用工具:calc、read_file、upper(upper 来自扩展)
=== 场景 1:扩展注册的工具 ===
你> 把 mini agent harness 转成大写
[第 1 轮]
助手> 交给 upper 工具。
[工具调用] upper {"text":"mini agent harness"}
[扩展] 第 1 次工具调用:upper
[工具结果] upper → MINI AGENT HARNESS
[第 1 轮结束]
[第 2 轮]
助手> 转好了。
[第 2 轮结束]
=== 场景 2:700ms 后取消一段慢速回复 ===
你> 说个长的
[第 1 轮]
助手> 这是一段很长
[扩展] 观察到一次取消
[已取消]
被取消时已生成的内容:「这是一段很长」
这条消息的 stopReason 是:aborted
它仍然被保存进了会话文件,当前共 6 条消息。场景 1 里值得确认的是「哪一行不该出现在这里」:upper 不在 src/tools/index.ts 的内置工具清单里(那里只有 calc 与 read_file),它是扩展在加载时通过 pi.registerTool() 塞进去的;缩进两格的 [扩展] 第 1 次工具调用:upper 来自扩展的 onEvent,而不是渲染层。这两行合在一起就是「不改核心代码加能力」的全部证据——src/ 下没有任何一个文件知道 upper 存在。
场景 2 的时间账算得很清楚:模型每 200 毫秒吐一个 token(我们的 tokenize 两个字一个 token),取消发生在第 700 毫秒。
图 8.5-3 场景 2 的毫秒级时间线
阅读顺序:从上到下按时间。这张图解释的是输出里为什么恰好是「这是一段很长」六个字:三个 token 分别在第 200、400、600 毫秒吐出,第四个原本要等到第 800 毫秒,而 abort() 发生在第 700 毫秒——sleep 里的 abort 监听器让这一觉提前醒来,醒来后第一件事就是发现 signal.aborted 为真。请注意 M-->>L 这一步:FakeModel 交出去的是一个正常的 done 事件,不是异常。为了压缩时间线,图里把三个 text_delta 直接画到了消费端,实际它们和其他事件一样由 runAgentLoop 原样 yield 出去(agent-loop.ts:107)。交互模式下按 Ctrl+C 走的是完全相同的代码路径,区别只是 abort() 的调用者从定时器换成了 handleInterrupt。
最后那三行是 demo.ts:82-84 打印的,它们证明了本章反复强调的那个取舍。会话文件里最后一行长这样(真实运行采集自 .sessions/demo.jsonl):
{"type":"message","id":"e6","parentId":"e5","message":{"role":"assistant","content":[{"type":"text","text":"这是一段很长"}],"stopReason":"aborted"}}半截内容原样存了下来,stopReason 记着「这是被打断的」。下次 npm start -- --continue 恢复时,用户会看到和上次离开时一模一样的历史。
回到 Pi 源码:同一套骨架的完整版
我们这 93 行的 extensions.ts 和 Pi 的扩展系统,骨架是同一套;差别在于 Pi 要处理的现实更多。逐项对照。
取消:Pi 的绳子也是每轮一根
Pi 的 Agent 类同样为每一次运行新建一个 AbortController,而不是全局复用一个:
runWithLifecycleactiveRun,把 abortController.signal 交给执行体;finally 里 finishRun() 清空 activeRun。上一轮的取消因此绝不会误伤下一轮。 aborthandleInterrupt 里那个 if (active) 判断。 让 Pi 的循环停下来的判据也和我们的取消点二是同一个:
stopReasonagent-loop.ts:121-124 是这一段的教学版,区别只是 Pi 把 error 和 aborted 合并处理,并且在退出前把两个收尾事件补齐。 真正的差别在工具那一侧。上文说过我们刻意不设「工具执行中途」的取消点,而 Pi 把选择权交给了工具自己:
executePreparedToolCalltool.execute 的第 3 个参数就是那条 signal。工具内部是否响应它、在哪个安全点响应,完全由工具的实现决定——abort 是请求,不是强杀。 除此之外,Pi 在工具的准备阶段里查两次 signal?.aborted:一次紧跟在 beforeToolCall 钩子返回之后(packages/agent/src/agent-loop.ts:629-635),一次在准备结束、把 prepared 交出去之前(:644-650)。两次命中都直接返回一条内容为 "Operation aborted" 的错误工具结果,不执行工具——这与我们的取消点三是同一个位置、同一个动机,只是我们直接 return,Pi 生成一条错误结果继续往下走。取消链更完整的追踪(ESC 按键的四路分派、signal 如何进入 fetch、取消后请求体里还剩什么)见 5.6 取消与错误处理。
扩展:形态完全一致,pi 对象大得多
Pi 的扩展形态和我们的一字不差:
ExtensionFactoryexport type ExtensionFactory = (pi: ExtensionAPI) => void | Promise<void>;——默认导出一个工厂函数,同步异步都行。我们的 src/extensions.ts:29 是它的逐字复刻。 加载流程也对得上:解析路径 → 导入模块 → 检查默认导出是不是函数 → 调用它:
loadExtensionloadExtensionModule 取工厂,取不到就返回错误字符串 "Extension does not export a valid factory function";随后 createExtension 造一个空集合的 Extension 对象,createExtensionAPI 造出 pi,最后 await factory(api)。整个函数不抛异常,失败以 { extension: null, error } 的形式返回。 「加载失败不退出」在 Pi 里被贯彻得更彻底:loadExtensionsInternal 逐个加载,把错误收集进一个 errors 数组一起返回(packages/coding-agent/src/core/extensions/loader.ts:508-546),调用方决定怎么展示。我们 main.ts 里那个 try/catch 是它的最小版。
差别从「用什么导入」开始。我们用的是运行时原生的动态 import()——它能导入什么完全取决于运行时。Node 从 v22.6 起自带「只擦除类型、不改写代码」的 TypeScript 支持,所以 extensions/logger.ts 用裸 node 也导得进来(本机 Node v26.5.0 实测:await import("./extensions/logger.ts") 拿到的 default 是一个函数);但只要扩展用了需要生成代码的语法,擦除就不够用了——本实验目录改用 tsx 启动,正是因为 src/extensions.ts 自己用了构造函数参数属性(后面「常见错误」里有这条的完整报错)。Pi 既不能假设用户的运行时有这个能力,也不能只支持「擦得掉的那部分 TypeScript」,于是自带了一个加载器:jiti。
loadExtensionModulevirtualModules;TypeScript 源码运行时用 virtualModules 加 tsconfig 路径;构建后的 Node 用 alias。 官方文档对这一点的说明是「Extensions are loaded via jiti, so TypeScript works without compilation.」(官方说明,来源文件 packages/coding-agent/docs/extensions.md:179)。virtualModules 那一支解决的是一个很具体的工程问题:整个 Pi 被编译进一个 Bun 单二进制之后,扩展里那句 import { ... } from "@earendil-works/pi-coding-agent" 该去哪里找模块——答案是找一张打包进二进制的模块表。
差别真正拉开的是 pi 对象本身。我们的 ExtensionAPI 有 2 个方法;Pi 的 interface 从第 1185 行排到第 1420 行,光是事件订阅 on(...) 就有 33 个重载:
ExtensionAPIExtensionAPI 的开头:33 个 on(...) 重载,每个事件名对应一种事件类型与一种返回值类型。project_trust、session_*、context、before_provider_request、turn_start、message_end、tool_call、input……我们的 onEvent(handler) 相当于把这 33 个重载压成一个「什么都给你,自己挑」。 剩下的方法按能力分成几类(源码事实,packages/coding-agent/src/core/extensions/types.ts:1237-1419):注册类(工具、斜杠命令、快捷键、CLI flag、消息与条目渲染器)、注入类(sendMessage / sendUserMessage / appendEntry)、会话操作类(setSessionName / setLabel / exec / getActiveTools / setActiveTools)、模型类(setModel / setThinkingLevel / registerProvider / unregisterProvider),最后是一个 events 属性。
pi.on(...) 订阅的是宿主发出的生命周期事件,由 ExtensionRunner 顺序 await 分发,部分事件的返回值能改写数据甚至取消操作;pi.events 是扩展之间的自由频道,宿主完全不参与。R07 调研笔记把这一点列为读者最容易混淆的地方,本书 7.1 Extension 系统 里有两者的完整对比图。 pi.events 的实现是一个 Node EventEmitter 的薄包装,整份 event-bus.ts 只有 33 行:
createEventBuson 里把 handler 包进 async try/catch,异常只 console.error——和我们 ExtensionHost.emit 里那个 try/catch 是同一条原则:一个扩展的异常不能影响别的扩展和宿主。 生命周期事件的分发同样是「捕获并继续」:
await handler(event, ctx);handler 抛错时上报给错误监听者后继续下一个(第 814-823 行)。session_before_* 这类事件的返回值若带 cancel: true 则短路返回——这是我们的 emit() 完全没有的「可取消事件」。 registerTool 之后发生了什么
我们的 registerTool 只有一行 this.tools.register(tool)。Pi 的多了一步:
registerToolextension.tools 之后调用 runtime.refreshTools()。注册与生效被拆成两步,因为 Pi 的扩展可以在运行期间注册工具,注册表需要被重建。 重建时有一个与我们相反的决定:
toolRegistryset 覆盖。同名时扩展工具赢——扩展可以替换掉内置的 read 或 bash。 我们的 ToolRegistry.register 遇到重名直接抛错(src/tools/types.ts:51-56)。哪种更好取决于定位:一种看法是,教学项目里「重名 = 写错了」远比「重名 = 我想覆盖」常见,抛错能更早暴露问题;代价是失去了「替换内置工具」这个在真实产品里很有用的能力。Pi 选了另一边,代价是一个不小心重名的扩展会静默地换掉内置工具。
两阶段初始化:一个我们没有的问题
Pi 的 pi 对象在扩展加载时还不能做事,只能注册:
createExtensionRuntimeExtensionRunner.bindCore() 之后才被拷进来。 有一个方法是例外——refreshTools 在这一阶段是空函数,注释写着「registerTool() is valid during extension load」(packages/coding-agent/src/core/extensions/loader.ts:193-194)。也就是说注册工具在加载期就合法,而 sendMessage、setModel 这些要等会话就绪。
我们没有这个问题,因为我们的 pi 只有两个方法,都是纯注册。从源码结构看,两阶段初始化是「扩展能力变多」之后必然出现的复杂度:能力越多,越容易出现「这个能力现在还不能用」的时刻,宿主就必须显式表达出来——抛一个说明清楚的错,比让扩展作者拿到一个半成品对象要好。
加载来源:我们是一个 flag,Pi 是一套发现规则
我们只支持 --extension <file>,可以给多次(src/main.ts:34-39)。Pi 的同名 flag 也在(packages/coding-agent/src/cli/args.ts:150,帮助文本 :266 写着「Load an extension file (can be used multiple times)」),但官方文档说明它「仅用于快速测试」(官方说明,来源文件 packages/coding-agent/docs/extensions.md:7),常规用法是放进约定目录让 Pi 自动发现:项目本地 .pi/extensions/、全局 ~/.pi/agent/extensions/,再加上配置里显式声明的路径(packages/coding-agent/src/core/extensions/loader.ts:699-723)。目录内的发现规则写在注释里:直接的 .ts / .js 文件、子目录里的 index.ts / index.js、带 "pi" 字段的 package.json 所声明的入口,不递归超过一层(loader.ts:631-640)。
还有一条我们完全没有的机制:项目本地的扩展受信任门控约束,只有当前项目被标记为可信时才会被加载(packages/coding-agent/src/core/package-manager.ts:2374-2382)。理由不难理解——.pi/extensions/ 是随仓库 clone 下来的代码,加载它等于执行一个陌生人写的程序。
R07 调研笔记还留下一条推断:「扩展的加载顺序同时决定了事件分发顺序与同名冲突的胜负」,源码里的 for-of 顺序与 first-wins 逻辑支持这个说法,但「项目扩展总是优先于全局扩展」这一表述尚未在官方文档里找到明文(尚未确认)。
对照表
| 关注点 | 我们的 step07 | Pi |
|---|---|---|
| 扩展形态 | 默认导出 (pi) => void | Promise<void> | 完全相同(ExtensionFactory,types.ts:1495) |
pi 的方法数 | 2 个:onEvent / registerTool | 数十个;仅 on(...) 就有 33 个重载(types.ts:1185-1420) |
| 事件订阅粒度 | 一个 handler 收全部事件,自己 switch | 按事件名订阅,类型逐个精确 |
| 事件返回值 | 忽略 | 分观察型 / 链式改写型 / 短路型 / 聚合型四类 |
| 模块加载 | 原生动态 import() + pathToFileURL | jiti,免编译跑 TypeScript(loader.ts:413-424) |
| 加载来源 | 只有 --extension | --extension 加两个约定目录加配置路径,带发现规则与信任门控 |
| 扩展抛异常 | try/catch 后打印,继续 | 上报错误监听者后继续(runner.ts:814-823);tool_call 事件例外,emitToolCall 不捕获异常(runner.ts:927-948),异常上传后阻断工具执行(agent-session.ts:482-487) |
| 同名工具 | 抛错 | 扩展工具覆盖内置(agent-session.ts:2517-2520) |
| 扩展能拿到会话状态吗 | 不能 | 能,通过受控的 ctx 对象而非内部状态本身 |
最后那一行是扩展 API 设计的核心决策:给得越多,扩展能做的越多,宿主以后越难改。我们这个最小版本干脆什么都不给;Pi 给的是一个受控的 ctx,并且明确区分了「可以产生结果的钩子」与「纯观察的事件」。
实践任务
labs/mini-agent-harness/step07-abort-extensions目标:确认 demo 的输出与 expected-output.txt 逐字一致,亲手体验交互模式下的 Ctrl+C,然后写一个属于你自己的扩展文件——不修改 src/ 下的任何一个文件,让模型用上一个新工具。
步骤与命令(在 labs/mini-agent-harness/step07-abort-extensions 目录下执行):
安装依赖并跑 demo,与标准输出逐字比对:
shnpm install npm run demo > /tmp/my-out.txt diff /tmp/my-out.txt expected-output.txt && echo "一致"进交互模式,试三种不同时机的 Ctrl+C:
shnpm start分别在:① 光标停在
你>什么都没输入时按;② 输入「说个长的」回车后、正在吐字时按;③ 吐字结束、回到你>之后按。不加载扩展时调用扩展工具,看它怎么失败:
shnpm start输入「把 mini agent harness 转成大写」(注意这次没有
--extension)。写你自己的扩展。新建
extensions/reverse.ts,参照extensions/logger.ts的形状,注册一个把文本倒序的reverse工具;然后在src/rules.ts里加两条规则,让「把 abcdef 倒过来」触发它、让toolResult:reverse有个收尾回复(剧本规则的写法见src/rules.ts:22-27那两条upper规则)。运行:shnpm start -- --extension extensions/reverse.ts故意让扩展崩溃,确认它拖不垮宿主。在你的
onEvent里针对tool_call事件抛一个异常(if (event.type === "tool_call") throw new Error("我崩了");),再跑一次第 4 步。
预期现象(以下为本书作者在本机实际执行的结果):
- 第 1 步
diff无任何输出并打印「一致」。 - 第 2 步:情况 ① 打印「再见。」并退出;情况 ② 屏幕上出现半句话后立刻换行打印
[已取消],然后再次出现你>提示符,可以继续对话;情况 ③ 与 ① 相同,退出。 - 第 3 步:输出里有
[工具结果] upper → 未注册的工具:upper(出错)——扩展没加载,工具就不存在,但循环不会崩,错误被回填给模型继续往下走。 - 第 4 步:启动横幅里
可用工具:calc、read_file、reverse多出了reverse,下面一行是已加载扩展:reverse.ts;输入那句话后能看到[工具调用] reverse {"text":"abcdef"}与[工具结果] reverse → fedcba。 - 第 5 步:
[工具调用] reverse ...之后紧跟一行[扩展错误] 我崩了,再往下[工具结果] reverse → fedcba照常出现,第 2 轮也照常完成。
如何判断成功:第 4 步做完时,回头数一数你改过哪些文件——src/extensions.ts、src/agent-loop.ts、src/main.ts、src/tools/ 应该一个字符都没动,你只新增了一个扩展文件,外加一条让假模型开口调用它的剧本规则(换成真模型时连这条规则也不需要)。这就是「扩展点」这三个字的全部意义。
常见错误:
- 直接用
node src/demo.ts而不是npm run demo:会报SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript parameter property is not supported in strip-only mode。Node 内置的类型擦除只会删类型、不会改写代码,而src/extensions.ts:37用了构造函数参数属性(constructor(private readonly tools: ToolRegistry) {})这种需要生成代码的语法。本步骤的package.json因此用tsx启动。 - 扩展文件忘了
export default:会得到「扩展 xxx 必须默认导出一个函数」——这正是extensions.ts:73-75那个检查在起作用。 - 注册的工具名和内置工具重名(比如叫
calc):直接抛「工具名重复:calc」,加载失败。这与 Pi 的行为相反,Pi 会让扩展覆盖内置。 - 在
--extension后面写了相对于自己 shell 的路径而不是相对于项目根目录的路径:main.ts:46用path.resolve(projectRoot, file)解析,所以路径是相对项目根目录的。
对应源码位置:src/fake-model.ts:57(可取消的 sleep)与 :113-123(吐 token 时的取消检查)、src/agent-loop.ts:97-101、:120-124、:132-138(三个取消点)、src/main.ts:94-107(Ctrl+C 处理)与 :126-136(每轮一个 controller)、src/extensions.ts 全文(93 行)、extensions/logger.ts(示例扩展)。
本章小结
- 在 Agent 里 Ctrl+C 的含义是「打断这一轮」而不是「退出程序」,判据是一个
active: AbortController | undefined变量:有值就取消这一轮,没值就退出。终端下 readline 会拦截 Ctrl+C,所以rl.on("SIGINT")与process.on("SIGINT")两个监听都要挂。 - AbortController 每一轮新建一个。
abort()不可逆,复用会导致此后每一轮都在开头被取消。 - 取消不是抢占式的:只有代码自己检查
signal.aborted才会停。我们的循环有三个取消点——开始新一轮之前、模型流结束之后、每个工具执行之前——没有「工具执行中途」这一个,因为半途掐断一个正在写文件的工具比让它写完更危险。Pi 的做法是把 signal 作为第 3 个参数传进tool.execute,由工具自己选安全点。 - 取消沿用 step03 的契约:
streamFn不抛异常,而是吐一个stopReason: "aborted"的done事件,带上已经生成的部分内容。这条半截消息照样进上下文、照样写进会话文件——用户看到过它,历史里就不能没有它。 - 最小扩展机制回答三个问题:外部代码是一个默认导出的工厂函数;宿主给它一个
pi对象;加载时调用一次。两个钩子一观察(onEvent)一注入(registerTool),配合动态import()就能做到「不改核心代码加能力」。 - 宿主与扩展之间有两条责任边界:扩展抛异常只打印不上抛;扩展加载失败只报告不退出。
- Pi 的形态与我们完全一致(
ExtensionFactory一字不差),差别在规模与现实:jiti 免编译加载、约定目录发现规则、信任门控、33 个精确的事件订阅重载、可改写与可取消的事件返回值、两阶段初始化、扩展工具覆盖同名内置工具。 - 关键术语:AbortController / AbortSignal、取消点(cancellation point)、停止原因(stopReason)、扩展(Extension)、扩展工厂(ExtensionFactory)、钩子(Hook)、动态 import(dynamic import)、事件总线(EventBus)。
- 关键源码索引:实验代码
labs/mini-agent-harness/step07-abort-extensions/(src/fake-model.ts:57-68、src/agent-loop.ts:97-138、src/main.ts:94-136、src/extensions.ts:20-93、extensions/logger.ts)。Pi 源码:packages/agent/src/agent.ts:306-314与:471-494(取消入口与每轮 controller)、packages/agent/src/agent-loop.ts:196-200(循环出口)与:666-693(signal 进工具);packages/coding-agent/src/core/extensions/types.ts:1185-1420(ExtensionAPI)与:1494-1495(ExtensionFactory)、packages/coding-agent/src/core/extensions/loader.ts:172-181(两阶段初始化)、:247-254(registerTool)、:405-433(jiti)、:459-485(loadExtension)、:631-640(发现规则)、:699-723(发现目录)、packages/coding-agent/src/core/extensions/runner.ts:796-828(事件分发)、packages/coding-agent/src/core/event-bus.ts:12-33(扩展间总线)、packages/coding-agent/src/core/agent-session.ts:2517-2521(扩展工具覆盖内置)、packages/coding-agent/src/cli/args.ts:150(--extension)。调研笔记:research/R07-extensions.md、research/R11-abort-errors.md。 - 自测问题:① 承接正文里那个实验:删掉
sleep的 abort 监听后,如果把delayMs从 200 毫秒改成 5000 毫秒,用户在交互模式下按 Ctrl+C 会经历什么?最终打印出来的那几行会不会变?这说明「取消及时」与「取消正确」是两件什么关系的事?② 取消点二那两行的顺序如果反过来(先判断stopReason再push),会话文件和下一轮的上下文分别会缺什么?③ 我们的withExtensions是先emit再yield;如果改成先yield再emit,观察到的现象会有什么不同?④ Pi 为什么需要「两阶段初始化」而我们不需要?如果给我们的ExtensionAPI加一个sendMessage(text)方法,这个问题会不会出现? - 下一章预告:到这里,Mini Harness 的骨架已经完整——CLI、消息、流式、循环、工具、会话、取消、扩展,八块都在,而且全程离线。8.6 接入真实模型(可选)会做最后一件事:把
createFakeModel换成一个真的 Provider,用同一个StreamFn类型接进去,看看上层到底要不要改。本章刻意没有展开的内容:signal 如何真正传进fetch并断开一个正在进行的 HTTP 连接(留给 8.6)、取消之后重试的三层策略(5.6)、Pi 事件返回值的四类语义与斜杠命令注册(7.1、7.3),以及扩展如何把自己的状态存进会话(6.5)。