Skip to content

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)交给每一层,谁需要谁自己监听。绳子从上往下穿,拽一下,每一层都感觉得到。

📘 概念取消点(cancellation point)
代码中显式检查 signal.aborted(或监听 abort 事件)并决定停下的位置。取消不是抢占式的——JavaScript 不会中途掐断你的函数,只有你自己查了才会停。所以「设计取消」等于「选取消点」:选少了,用户按完键要等很久;选多了,代码里全是判断;选错了位置,会在不该停的地方留下半截状态。
⚠️ 常见误解以为 AbortController 可以复用
abort() 是一次性、不可逆的:signal.aborted 一旦变成 true 就永远是 true,没有「复位」方法。所以每一轮回复都要 new AbortController()。复用同一个 controller 的后果是:用户第一次按 Ctrl+C 之后,之后每一轮都会在开头立刻被取消,看起来像「程序坏了」。

最小示例一:一个可以被打断的 sleep

从最底层开始。step03 起我们就用 sleep 来模拟模型吐字的节奏,它此前是最朴素的写法(step03-streaming/src/fake-model.ts:38,签名里连 signal 都没有)。step07 把它换成了这样:

ts
// 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 });
  });
}

三个细节值得逐个看:

  1. 先查一次 signal?.aborted。signal 可能在进入这个函数之前就已经被取消了,那就不必再造一个 Promise。
  2. 让 timer 和 abort 事件赛跑,谁先到谁 resolve。没有这一步,取消信号到了也得等这一觉睡完——用户按完 Ctrl+C 还要盯着不动的光标。
  3. 谁先到,都要清掉另一个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 的类型注释写得很明确:

ts
// src/types.ts:98-102
/**
 * 模型调用抽象。契约:**不允许 throw,错误编码成 error 事件**。
 * FakeModel 与真实 Provider(模型服务提供方)都实现这一个类型。
 */
export type StreamFn = (context: Context, options?: StreamOptions) => AsyncIterable<StreamEvent>;

于是取消在 FakeModel 里的表现就是「提前收尾」而不是「炸掉」:

ts
// 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 的选项多了一个字段:

ts
// src/agent-loop.ts:41-42
/** 取消信号。会被透传给 streamFn,循环自己也在几个点上检查它。 */
signal?: AbortSignal;

「透传给 streamFn」是一行(agent-loop.ts:106):

ts
for await (const event of streamFn(context, { ...streamOptions, signal })) {

「循环自己也检查」则一共三处,位置都是选过的:

取消点一:开始新一轮之前agent-loop.ts:97-101)。用户在第 1 轮的工具刚跑完、第 2 轮还没发请求时按下 Ctrl+C,不该再白白发一次请求出去。

ts
for (let turn = 1; turn <= maxTurns; turn++) {
  // 取消点一:开始新一轮之前。
  if (signal?.aborted) {
    yield { type: "abort" };
    return;
  }

取消点二:模型流结束之后agent-loop.ts:114-124)。这是最主要的一个——大多数取消都发生在模型正在吐字的时候,而 FakeModel 把取消编码成了 stopReason: "aborted"done 事件,所以循环只要读这个字段就够了:

ts
    if (!assistant) return;

    // 部分内容也要进上下文:用户已经在屏幕上看到它了,
    // 历史里少了这一段,下一轮模型和用户看到的就不是同一份对话。
    context.messages.push(assistant);

    // 取消点二:模型这一轮是被打断的,不再往下执行工具。
    if (assistant.stopReason === "aborted") {
      yield { type: "abort" };
      return;
    }

请注意这两句的顺序:先 push 再判断。半截助手消息照样进上下文——用户已经在屏幕上看到那半句话了,如果历史里没有它,下一轮模型看到的对话和用户记忆里的对话就是两份。

取消点三:每个工具执行之前agent-loop.ts:132-138)。一条助手消息可能声明了三次工具调用,取消发生时可能才跑完第一个:

ts
    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。核心是一个变量加一个函数:

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 取消这一轮;activeundefined 就表示「正等着你输入」,于是 Ctrl+C 退出。同一个按键,两种含义,全靠这一个变量区分。

主循环里,active 的生命周期严格等于一轮回复:

ts
// 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() 返回 undefinedmain.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.tsdelayMs 是 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 里只有四样东西。第一样是给外部代码看的接口:

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。它保管所有观察者,并负责派发:

ts
// 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 不是防御性编程的习惯动作,而是宿主与扩展之间的责任边界:扩展是别人写的代码,它崩了应该只影响它自己。

第三样是加载:

ts
// 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(dynamic import)
import(x) 是一个返回 Promise 的函数调用,路径可以是运行时才拼出来的字符串;而文件顶部的静态 import 语句路径必须在编译期确定、模块在程序启动时就全部加载。插件系统、按需加载都靠前者。代价是类型检查帮不上忙——await import(x) 的结果对 TypeScript 来说是未知形状,所以上面这段手工检查了「默认导出必须是函数」,检查完才敢断言成 ExtensionFactory

pathToFileURL 那一步容易被忽略:动态 import() 的参数是一个 URL,Windows 上的绝对路径 C:\Users\... 会被解析成协议名为 c 的 URL。转成 file:// 开头的 URL 才在三大平台上都对。

第四样把事件流分给扩展:

ts
// 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/ 之外,这一点是故意的——它代表「用户的代码」,宿主是在运行时才知道它存在的。

ts
// 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 的开头:

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 毫秒时取消一段慢速回复。

bash
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 的内置工具清单里(那里只有 calcread_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):

json
{"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,而不是全局复用一个:

packages/agent/src/agent.ts · runWithLifecycle
earendil-works/pi@c13ffe1第 471–494 行在 GitHub 查看 ↗
每次运行现造一个 AbortController,存进 activeRun,把 abortController.signal 交给执行体;finallyfinishRun() 清空 activeRun。上一轮的取消因此绝不会误伤下一轮。
earendil-works/pi@c13ffe1第 306–314 行在 GitHub 查看 ↗
取消入口只有两行:拿到本次运行的 controller 并按下。没有活动运行时它是安全的空操作——对应我们 handleInterrupt 里那个 if (active) 判断。

让 Pi 的循环停下来的判据也和我们的取消点二是同一个:

earendil-works/pi@c13ffe1第 196–200 行在 GitHub 查看 ↗
唯一的异常出口:assistant 消息的 stopReason 是 error 或 aborted 时,补发 turn_end 与 agent_end 后立刻 return。我们的 agent-loop.ts:121-124 是这一段的教学版,区别只是 Pi 把 error 和 aborted 合并处理,并且在退出前把两个收尾事件补齐。

真正的差别在工具那一侧。上文说过我们刻意不设「工具执行中途」的取消点,而 Pi 把选择权交给了工具自己:

packages/agent/src/agent-loop.ts · executePreparedToolCall
earendil-works/pi@c13ffe1第 666–693 行在 GitHub 查看 ↗
tool.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 的扩展形态和我们的一字不差:

earendil-works/pi@c13ffe1第 1494–1495 行在 GitHub 查看 ↗
export type ExtensionFactory = (pi: ExtensionAPI) => void | Promise<void>;——默认导出一个工厂函数,同步异步都行。我们的 src/extensions.ts:29 是它的逐字复刻。

加载流程也对得上:解析路径 → 导入模块 → 检查默认导出是不是函数 → 调用它:

earendil-works/pi@c13ffe1第 459–485 行在 GitHub 查看 ↗
loadExtensionModule 取工厂,取不到就返回错误字符串 "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。

earendil-works/pi@c13ffe1第 405–433 行在 GitHub 查看 ↗
用 jiti 导入扩展模块,因此扩展写 TypeScript 不需要预先编译。三种运行时三套模块解析策略:Bun 单二进制用打包进可执行文件的 virtualModules;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 个重载:

earendil-works/pi@c13ffe1第 1185–1231 行在 GitHub 查看 ↗
ExtensionAPI 的开头:33 个 on(...) 重载,每个事件名对应一种事件类型与一种返回值类型。project_trustsession_*contextbefore_provider_requestturn_startmessage_endtool_callinput……我们的 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 和 pi.events 当成同一套事件系统
它们是两套。pi.on(...) 订阅的是宿主发出的生命周期事件,由 ExtensionRunner 顺序 await 分发,部分事件的返回值能改写数据甚至取消操作;pi.events扩展之间的自由频道,宿主完全不参与。R07 调研笔记把这一点列为读者最容易混淆的地方,本书 7.1 Extension 系统 里有两者的完整对比图。

pi.events 的实现是一个 Node EventEmitter 的薄包装,整份 event-bus.ts 只有 33 行:

earendil-works/pi@c13ffe1第 12–33 行在 GitHub 查看 ↗
扩展间事件总线的全部实现。注意 on 里把 handler 包进 async try/catch,异常只 console.error——和我们 ExtensionHost.emit 里那个 try/catch 是同一条原则:一个扩展的异常不能影响别的扩展和宿主。

生命周期事件的分发同样是「捕获并继续」:

earendil-works/pi@c13ffe1第 796–828 行在 GitHub 查看 ↗
按扩展加载顺序、每个扩展内按注册顺序,逐个 await handler(event, ctx);handler 抛错时上报给错误监听者后继续下一个(第 814-823 行)。session_before_* 这类事件的返回值若带 cancel: true 则短路返回——这是我们的 emit() 完全没有的「可取消事件」。

registerTool 之后发生了什么

我们的 registerTool 只有一行 this.tools.register(tool)。Pi 的多了一步:

earendil-works/pi@c13ffe1第 247–254 行在 GitHub 查看 ↗
写进 extension.tools 之后调用 runtime.refreshTools()。注册与生效被拆成两步,因为 Pi 的扩展可以在运行期间注册工具,注册表需要被重建。

重建时有一个与我们相反的决定:

earendil-works/pi@c13ffe1第 2517–2521 行在 GitHub 查看 ↗
先用内置工具填满注册表,再用扩展工具逐个 set 覆盖。同名时扩展工具赢——扩展可以替换掉内置的 readbash

我们的 ToolRegistry.register 遇到重名直接抛错(src/tools/types.ts:51-56)。哪种更好取决于定位:一种看法是,教学项目里「重名 = 写错了」远比「重名 = 我想覆盖」常见,抛错能更早暴露问题;代价是失去了「替换内置工具」这个在真实产品里很有用的能力。Pi 选了另一边,代价是一个不小心重名的扩展会静默地换掉内置工具。

两阶段初始化:一个我们没有的问题

Pi 的 pi 对象在扩展加载时还不能做事,只能注册:

earendil-works/pi@c13ffe1第 172–181 行在 GitHub 查看 ↗
运行时初始化时,所有「动作类」方法都是抛错的桩:「Extension runtime not initialized. Action methods cannot be called during extension loading.」真实实现要等 ExtensionRunner.bindCore() 之后才被拷进来。

有一个方法是例外——refreshTools 在这一阶段是空函数,注释写着「registerTool() is valid during extension load」(packages/coding-agent/src/core/extensions/loader.ts:193-194)。也就是说注册工具在加载期就合法,而 sendMessagesetModel 这些要等会话就绪。

我们没有这个问题,因为我们的 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 逻辑支持这个说法,但「项目扩展总是优先于全局扩展」这一表述尚未在官方文档里找到明文(尚未确认)。

对照表

关注点我们的 step07Pi
扩展形态默认导出 (pi) => void | Promise<void>完全相同(ExtensionFactory,types.ts:1495)
pi 的方法数2 个:onEvent / registerTool数十个;仅 on(...) 就有 33 个重载(types.ts:1185-1420)
事件订阅粒度一个 handler 收全部事件,自己 switch按事件名订阅,类型逐个精确
事件返回值忽略分观察型 / 链式改写型 / 短路型 / 聚合型四类
模块加载原生动态 import() + pathToFileURLjiti,免编译跑 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 目录下执行):

  1. 安装依赖并跑 demo,与标准输出逐字比对:

    sh
    npm install
    npm run demo > /tmp/my-out.txt
    diff /tmp/my-out.txt expected-output.txt && echo "一致"
  2. 进交互模式,试三种不同时机的 Ctrl+C:

    sh
    npm start

    分别在:① 光标停在 你> 什么都没输入时按;② 输入「说个长的」回车后、正在吐字时按;③ 吐字结束、回到 你> 之后按。

  3. 不加载扩展时调用扩展工具,看它怎么失败:

    sh
    npm start

    输入「把 mini agent harness 转成大写」(注意这次没有 --extension)。

  4. 写你自己的扩展。新建 extensions/reverse.ts,参照 extensions/logger.ts 的形状,注册一个把文本倒序的 reverse 工具;然后在 src/rules.ts 里加两条规则,让「把 abcdef 倒过来」触发它、让 toolResult:reverse 有个收尾回复(剧本规则的写法见 src/rules.ts:22-27 那两条 upper 规则)。运行:

    sh
    npm start -- --extension extensions/reverse.ts
  5. 故意让扩展崩溃,确认它拖不垮宿主。在你的 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.tssrc/agent-loop.tssrc/main.tssrc/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:46path.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-68src/agent-loop.ts:97-138src/main.ts:94-136src/extensions.ts:20-93extensions/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.mdresearch/R11-abort-errors.md
  • 自测问题:① 承接正文里那个实验:删掉 sleep 的 abort 监听后,如果把 delayMs 从 200 毫秒改成 5000 毫秒,用户在交互模式下按 Ctrl+C 会经历什么?最终打印出来的那几行会不会变?这说明「取消及时」与「取消正确」是两件什么关系的事?② 取消点二那两行的顺序如果反过来(先判断 stopReasonpush),会话文件和下一轮的上下文分别会缺什么?③ 我们的 withExtensions 是先 emityield;如果改成先 yieldemit,观察到的现象会有什么不同?④ Pi 为什么需要「两阶段初始化」而我们不需要?如果给我们的 ExtensionAPI 加一个 sendMessage(text) 方法,这个问题会不会出现?
  • 下一章预告:到这里,Mini Harness 的骨架已经完整——CLI、消息、流式、循环、工具、会话、取消、扩展,八块都在,而且全程离线。8.6 接入真实模型(可选)会做最后一件事:把 createFakeModel 换成一个真的 Provider,用同一个 StreamFn 类型接进去,看看上层到底要不要改。本章刻意没有展开的内容:signal 如何真正传进 fetch 并断开一个正在进行的 HTTP 连接(留给 8.6)、取消之后重试的三层策略(5.6)、Pi 事件返回值的四类语义与斜杠命令注册(7.17.3),以及扩展如何把自己的状态存进会话(6.5)。

本书分析的 Pi 版本:earendil-works/pi@c13ffe1(2026-07-30)