跳转到内容

阅读pi源码所需要的 TS/JS 知识

ECMAScript 是 JavaScript 的语言标准;Node.js 是在服务端运行 JavaScript 的运行时,并提供文件系统、进程等平台能力。

Pi 使用 TypeScript 编写,但程序真正运行时,仍然遵循 JavaScript 与具体运行时的规则。TypeScript 负责在运行前描述数据之间的关系;文件系统和进程能力来自 Node.js,网络与流则依赖运行时实现的标准 API。

本版的 JavaScript 模块划分参考《JavaScript 高级程序设计(第 5 版)》的语言主线,只保留阅读 Pi 源码需要的部分。每个知识模块都先解释概念,再用最小例子观察行为,最后回到 Pi 的真实源码。

标题以 【Pi】 开头的小节,表示当前知识点在 Pi 源码中的实际应用。

下面出现的源码路径都相对于 Pi 仓库根目录,源码快照为 commit ccb9464114c2542aad444054f590f06b03a324ac(2026-08-28)。为了突出当前概念,代码片段会省略无关字段和错误分支,但不会改变关键的类型关系和执行顺序。代码块中的中文注释是本教程补充的阅读标记,不代表 Pi 源文件原本包含相同注释。

Node.js ESM:JavaScript 的模块管理机制

Section titled “Node.js ESM:JavaScript 的模块管理机制”

ESM 是 ECMAScript Modules 的缩写,是 JavaScript 的标准模块系统。它解决的核心问题是:如何把代码拆分成彼此独立的模块,并明确声明每个模块“提供什么”和“依赖什么”。

顶层 import 或 export 会明确把文件写成模块。Node.js 还会根据 .mjs / .cjs 扩展名,以及最近一层 package.json 的 type 字段,决定文件采用哪种模块格式。模块拥有自己的作用域;没有导出的变量只在当前模块内部可见。

下面是一个最小的两文件例子:

counter.js
// 没有 export:value 是 counter.js 的模块私有状态。
let value = 0;
// 有 export:next 是其他模块可见的公开入口。
export function next() {
// 每次调用修改的都是同一个模块实例中的 value。
value += 1;
return value;
}
main.js
// 相对路径导入 counter.js 明确导出的具名成员。
import { next } from "./counter.js";
// 两次调用复用同一个 counter.js 模块实例。
console.log(next()); // 1
console.log(next()); // 2

main.js 只能访问 counter.js 明确导出的 next,不能直接访问 value。同一个模块被加载后会保留自己的模块状态,因此两次调用操作的是同一个 value。

import 写的是当前模块的依赖,export 写的是其他模块可以使用的公开内容。它们共同形成一张模块依赖图,而不只是把几个文件拼接在一起。

具名导入可以在当前模块中改名;这里的 as 只改变本地绑定名称,与后文 TypeScript 的类型断言不是一回事:

// 导入模块导出的 resolve,并在当前文件中把它命名为 resolvePath。
import { resolve as resolvePath } from "node:path";

./counter.js、../config.js 这类以相对路径开头的字符串是相对模块说明符。typebox、typebox/value 这类不以 ./、../ 或 / 开头的名称是 bare specifier;Node.js 会按照 package 解析规则寻找它们。node:path 则使用 node: scheme 明确指向 Node.js 内置模块。

模块还可以直接重新导出另一个模块的成员:

// 只重新导出指定成员;当前模块中不会因此出现 createTool 变量。
export { createTool } from "./tool.js";
// 重新导出目标模块的全部具名导出。
export * from "./types.js";

集中重新导出公共成员的文件常被称为 barrel。读取 package 的 index.ts 时,需要沿这些重新导出继续找到真正的实现文件。

Node.js 在这里需要解决两个问题:当前文件按哪种模块格式加载,以及 package 对外允许导入哪些入口。

.js 只是 JavaScript 文件的扩展名,ESM 则是加载和执行模块的规则。Node.js 还支持较早的 CommonJS 模块系统,它使用 require() 和 module.exports。

package 是由 package.json 描述、可以被安装或导入的一组模块。在一个 package 范围内,type 字段告诉 Node.js 应当怎样解释普通 .js 文件:

{
"type": "module"
}

有了这项声明,Node.js 会把该 package 中的 .js 文件按 ESM 加载。.mjs 始终表示 ESM,.cjs 始终表示 CommonJS;文件扩展名和模块系统是两个不同概念。

package 还可以通过 exports 声明对外入口:

{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
}
}

types 供 TypeScript 查找类型声明,import 是 Node.js 真正加载的 JavaScript 入口。一个文件存在于仓库中,并不意味着 package 外部就可以绕过 exports 直接导入它。

TypeScript 的类型导入与运行时依赖

Section titled “TypeScript 的类型导入与运行时依赖”

TypeScript 沿用 ESM 的 import / export,并增加了只在类型检查阶段使用的导入:

// 混合导入:Static 只用于类型检查,Type 是运行时值。
import { type Static, Type } from "typebox";
// 整条导入都只用于类型检查,生成 JavaScript 时会消失。
import type { AgentTool } from "@earendil-works/pi-agent-core";

Type 是运行时值,代码执行时会用它创建 schema(后文会详细解释的数据结构说明),因此生成的 JavaScript 必须保留这个导入。Static 和 AgentTool 只参与类型检查,生成 JavaScript 时会被移除。

这不是两套模块系统。模块仍然是 ESM;import type 只是 TypeScript 对“这项依赖不会出现在运行时”的明确说明。

【Pi】如何用 ESM、类型导入和 exports 组织模块

Section titled “【Pi】如何用 ESM、类型导入和 exports 组织模块”

packages/coding-agent/src/core/tools/read.ts 的导入区同时展示了 Node.js 内置模块、package 依赖、类型导入和相对模块:

// Node.js 内置模块:node: 前缀明确标记内置来源。
// 部分内置模块也允许写成 "fs" 等无前缀形式,不能只凭前缀判断。
import { basename, resolve as resolvePath } from "node:path";
// Pi workspace package 的类型导入:只建立编译期关系。
import type { AgentTool } from "@earendil-works/pi-agent-core";
// 第三方 package:Static 是类型,Type 是运行时 schema builder。
import { type Static, Type } from "typebox";
// 当前 package 内的相对模块:运行时会真正加载。
import { getReadmePath } from "../../config.ts";
// 两个 bare specifier 的 workspace/第三方身份来自 package metadata,
// 不能只根据 import 字符串的外形判断。

Pi 根 package.json 声明 "type": "module",源码使用 ESM。源码中的相对导入显式写 .ts;构建时,TypeScript 根据 rewriteRelativeImportExtensions 把相对扩展名改成产物使用的扩展名。

Pi 是一个 monorepo,也就是一个仓库中包含多个相互关联的 package。TypeScript 的路径映射帮助这些 package 在开发时找到源码;发布后的公共边界仍由每个 package 的 exports 和实际构建产物决定。

JavaScript 的值分为原始值和对象值。变量保存原始值本身,保存对象时则持有指向对象的引用,因此多个变量可以共享同一个可变对象。按照 ECMAScript 的正式分类,JavaScript 有七种原始类型和一种 Object 类型;普通对象、数组、函数、Map、Set、日期、正则表达式以及 typed array(类型化数组)都属于对象值。

这一章真正要理解的不是背诵对象种类,而是对象身份、共享引用和可变性。

七种原始类型是:

  • undefined
  • null
  • boolean
  • number
  • bigint
  • string
  • symbol

原始值本身不可变。变量之间赋值时,会复制原始值本身:

let first = 1;
// 原始值赋值会复制数值 1,而不是共享一个可变对象。
let second = first;
// 重新绑定 second 不会影响 first。
second = 2;
console.log(first); // 1
console.log(second); // 2

对象值则具有身份。变量保存的是指向对象的引用,两个变量可以指向同一个对象:

const first = { count: 1 };
// 没有复制对象;second 与 first 指向同一个对象。
const second = first;
// 经由任一引用修改属性,另一方都能观察到。
second.count += 1;
console.log(first.count); // 2
console.log(first === second); // true

const 只是不允许变量重新指向另一个值,并不会冻结对象。first.count 仍然可以修改。

普通对象用键值对保存属性,数组用有序索引保存元素。Map 可以使用任意值作为键,Set 保存不重复的值。函数也属于对象:它除了可以被调用,还可以像其他值一样放进变量、数组或集合。

// 函数也是对象值,可以被保存在另一个对象的属性中。
function textLength(text) {
return text.length;
}
const values = {
object: { name: "Pi" }, // 普通对象
array: ["user", "assistant"], // 数组对象
map: new Map([["status", "idle"]]), // Map 对象
set: new Set(["read", "write"]), // Set 对象
function: textLength, // 函数对象
};

这些值的 API 不同,但共享同一个关键行为:变量之间传递它们时,传递的是对象引用,不会自动复制整个对象。

Map 和 Set 都会按插入顺序迭代。Map 使用 set 写入键值、get 读取、has 判断是否存在、delete 删除;Set 使用 add、has 和 delete 管理不重复的值:

const pending = new Map();
// Map 把请求 ID 映射到等待该请求的状态。
pending.set("req_1", { status: "waiting" });
console.log(pending.has("req_1")); // true
console.log(pending.get("req_1").status); // waiting
pending.delete("req_1");
const listener = () => console.log("changed");
const listeners = new Set();
listeners.add(listener);
// 同一个函数引用再次加入不会产生第二项。
listeners.add(listener);
console.log(listeners.size); // 1

字符串和数字键按值比较;对象与函数键按引用身份比较。两个内容相同但分别创建的对象仍是不同的 Map key 或 Set 成员。这也是 Pi 能用同一个 listener 函数引用精确取消订阅的基础。

对象展开语法可以创建一个新的外层对象:

const original = {
status: "ok",
details: { timings: [12] },
};
// 创建新外层对象;details 和 timings 仍与 original 共享。
const copied = { ...original };

此时 copied !== original,但 copied.details === original.details。展开语法只复制第一层属性,嵌套对象仍然共享引用,这就是浅复制。

显式写在展开后面的同名属性会覆盖旧值:

const result = {
// 先复制原对象的第一层属性。
...original,
// 后写的同名字段覆盖展开得到的 status。
status: "changed",
};

空值合并运算符 ?? 在左侧为 null 或 undefined 时使用右侧默认值。它不会把 false、0 或空字符串误判为“没有提供”:

const retries = 0;
// 0 不是 null/undefined,因此 ?? 保留它。
console.log(retries ?? 3); // 0
// 0 是 falsy,因此 || 选择右侧默认值。
console.log(retries || 3); // 3

对象解构会按属性名把对象成员绑定到局部变量:

const result = {
done: false,
value: { text: "hello" },
};
// 等价于分别读取 result.done 和 result.value。
const { done, value } = result;
console.log(done, value.text);

解构不会复制嵌套对象;这里的 value 与 result.value 仍指向同一个对象。它也可以在绑定时重命名,例如 const { value: message } = result 会创建局部变量 message。

可选链 ?. 在左侧为 null 或 undefined 时停止后续访问,并返回 undefined:

const response = { result: { text: "done" } };
// result 存在,因此继续读取 text。
console.log(response.result?.text); // done
// details 不存在;可选链返回 undefined,而不是抛出异常。
console.log(response.details?.timings); // undefined
const hooks = {};
// ?.() 只在 after 不为 null/undefined 时尝试调用它。
const updated = hooks.after?.(response);

?. 只处理 null 和 undefined,不会把 false、0 或空字符串当成缺失值。它适合读取可选成员,但不会验证已经存在的成员是否具有正确类型;after 如果存在却不是函数,after?.(...) 仍会抛出异常。

LIVE LAB对象展开、浅复制与空值合并
JavaScript
正在准备在线实验……
控制台等待加载
运行后在这里查看输出

【Pi】如何用 Hook 局部改写工具结果

Section titled “【Pi】如何用 Hook 局部改写工具结果”

Pi 的工具是模型可以请求 Agent 执行的具名能力,例如读取文件或运行命令。packages/agent/src/agent-loop.ts 在工具执行完成后调用 afterToolCall hook。Hook 是主流程在特定阶段调用的回调,让外部逻辑可以观察或调整结果。下面从 hook 已经返回 afterResult 的位置开始,只观察对象合并:

// 先保留工具执行得到的基线结果。
let result = executed.result;
let isError = executed.isError;
if (afterResult) {
result = {
// 创建新的外层结果,未覆盖的字段继续沿用旧值。
...result,
// hook 只有返回非 null/undefined 值时才覆盖对应字段。
content: afterResult.content ?? result.content,
details: afterResult.details ?? result.details,
usage: afterResult.usage ?? result.usage,
terminate: afterResult.terminate ?? result.terminate,
};
// isError 是原始值,直接选择 hook 值或旧值。
isError = afterResult.isError ?? isError;
}

{ ...result } 隔离了这次合并产生的外层结果,后面的字段再按需覆盖。没有被 hook 替换的嵌套值仍可能与旧结果共享引用。这里既没有深复制,也没有把结果变成不可变数据结构。

作用域决定一个变量名可以在哪些代码位置被访问。JavaScript 主要使用词法作用域:范围由函数和代码块在源码中的嵌套位置决定。

函数又是一等值:可以赋给变量、作为参数传入、放进集合,也可以从另一个函数中返回。函数会记住它创建时所在的词法作用域,这项能力叫闭包。

箭头函数是定义函数的另一种语法:(参数) => 表达式 会创建一个函数,并把表达式结果作为返回值。函数作为参数传递时,通常称为回调。下面的 run 不关心具体工作是什么,只负责在合适的时机调用它:

// work 是一个函数值;run 只负责决定何时调用它。
function run(work) {
return work();
}
// 箭头函数作为回调传入,run 内部调用后得到 "done"。
const result = run(() => "done");
console.log(result);

当函数作为对象属性并以 object.method() 形式调用时,普通函数中的 this 通常指向调用它的对象。箭头函数没有自己的 this,会使用创建位置外层作用域中的 this。

const session = {
messages: [],
// 普通方法的 this 由调用方式决定;session.add() 时指向 session。
add(message) {
this.messages.push(message);
},
addMany(messages) {
// 箭头函数没有自己的 this,沿用 addMany() 当前的 this。
messages.forEach((message) => this.add(message));
},
};

如果把 session.add 单独取出再调用,它就失去了原来的接收者。bind(session) 可以创建一个固定了 this 的新函数。

函数执行结束后,局部变量通常不再被外部访问。但如果返回的函数仍然引用这些变量,JavaScript 会保留对应的词法环境:

function createCounter() {
// value 是本次 createCounter() 调用创建的局部绑定。
let value = 0;
// 返回函数闭包捕获 value,使它在外层函数结束后仍然存在。
return () => {
value += 1;
return value;
};
}
const next = createCounter();
console.log(next()); // 1
console.log(next()); // 2

next 是闭包。它不只是保存了某次计算结果,而是持续引用同一个 value 绑定。

LIVE LAB闭包、箭头函数与 this
JavaScript
正在准备在线实验……
控制台等待加载
运行后在这里查看输出

【Pi】如何用闭包与 Set 管理事件订阅

Section titled “【Pi】如何用闭包与 Set 管理事件订阅”

packages/agent/src/agent.ts 把监听器函数放进 Set,并由 subscribe 返回取消订阅函数。下面隐藏 TypeScript 类型注解,只看 JavaScript 的闭包与对象引用;异步执行会在“JavaScript 的异步编程”一章说明。

// Set 按函数引用去重;同一个 listener 不会被重复保存。
listeners = new Set();
subscribe(listener) {
this.listeners.add(listener);
// 箭头函数词法捕获本次 listener 和 subscribe() 中的 this,
// 因此以后调用它,仍能从同一个 Agent 实例精确删除监听器。
return () => this.listeners.delete(listener);
}

返回的箭头函数闭包捕获了本次传入的 listener,因此以后可以精确删除同一个函数引用。Set 则防止同一个 listener 被重复添加。

对象是一组可以在运行时读写的属性。JavaScript 的对象继承不是把父对象的方法复制一份,而是让对象在找不到属性时,把查找委托给另一个对象。

每个普通对象内部都有一个 [[Prototype]] 链接,它指向另一个对象,也可以是 null。读取属性时,JavaScript 先检查对象自身;如果找不到,就沿 [[Prototype]] 逐层向上查找,这条路径就是 prototype chain(原型链)。可以使用 Object.getPrototypeOf() 检查这个内部链接。

这里有两个名称相近但含义不同的东西:

  • 对象的 [[Prototype]]:对象内部用于属性查找的链接。
  • 构造函数或 class 的 .prototype 属性:一个普通对象;使用 new 创建实例时,它会成为新实例的 [[Prototype]]。

class 是建立构造函数、原型方法和继承关系的运行时语法,不是 TypeScript 才有的类型声明:

class Container {
constructor() {
// 构造函数为每个实例创建自己的 children 数组。
this.children = [];
}
// 方法存放在 Container.prototype 上,由实例共享。
addChild(child) {
this.children.push(child);
}
}
// extends 建立 Panel.prototype → Container.prototype 的运行时关系。
class Panel extends Container {
constructor(title) {
// super() 先初始化父类负责的实例状态,之后才能使用 this。
super();
this.title = title;
}
}
const panel = new Panel("Tools");
panel.addChild("read");
// new Panel() 把新实例的 [[Prototype]] 指向 Panel.prototype。
console.log(Object.getPrototypeOf(panel) === Panel.prototype); // true
// extends 再把 Panel.prototype 的 [[Prototype]] 指向 Container.prototype。
console.log(Object.getPrototypeOf(Panel.prototype) === Container.prototype); // true
// children 是构造函数写入实例的自有属性。
console.log(Object.hasOwn(panel, "children")); // true
// addChild 没有被复制到实例;它是沿原型链找到的共享方法。
console.log(Object.hasOwn(panel, "addChild")); // false
flowchart LR
    Instance["panel<br/>自有属性:children、title"] -->|"[[Prototype]]"| PanelPrototype["Panel.prototype"]
    PanelPrototype -->|"[[Prototype]]"| ContainerPrototype["Container.prototype<br/>addChild()"]
    ContainerPrototype -->|"[[Prototype]]"| ObjectPrototype["Object.prototype"]
    ObjectPrototype -->|"[[Prototype]]"| Null["null"]

执行 panel.addChild 时,JavaScript 依次检查 panel、Panel.prototype 和 Container.prototype,最后找到 addChild。调用 panel.addChild("read") 时,方法中的 this 是 panel,所以修改的是这个实例自己的 children;this 由调用形式决定,不是由方法存放在哪个 prototype 上决定。如果实例自己定义了同名属性,它会遮蔽原型链上的属性。

Panel extends Container 建立了上图中的运行时继承关系。子类构造函数必须先调用 super(),才能使用 this;new 会建立原型链接并执行构造函数,但不会为每个实例复制一份原型方法。

实例字段保存每个对象自己的状态,方法通常放在 prototype 上共享。Getter 和 setter 是真正的运行时访问器,可以把一次属性读写转换成方法逻辑:

class InputGroup {
// # 字段由 JavaScript 引擎在运行时限制外部访问。
#focused = false;
// 读取 input.focused 时会执行 getter。
get focused() {
return this.#focused;
}
// 给 input.focused 赋值时会执行 setter。
set focused(value) {
this.#focused = value;
}
}

#focused 是 JavaScript 的运行时私有字段。TypeScript 的 private focused 主要是静态访问限制,生成 JavaScript 后未必保留同样的运行时私密性。

【Pi】如何用 class 继承复用 TUI 容器能力

Section titled “【Pi】如何用 class 继承复用 TUI 容器能力”

TUI 是 Terminal User Interface,即终端用户界面。packages/coding-agent/src/modes/interactive/components/extension-input.ts 中的输入组件继承了 Pi TUI 的 Container。下面先隐藏只参与类型检查的声明,只看它实际运行时的 JavaScript 结构:

// extends 是运行时继承;实例会真正获得 Container 的能力。
export class ExtensionInputComponent extends Container {
// 每个组件实例拥有自己的焦点状态。
_focused = false;
get focused() {
return this._focused;
}
set focused(value) {
this._focused = value;
// 同步把焦点状态下传给内部 Input 组件。
this.input.focused = value;
}
constructor() {
// 先执行父类构造,再创建并挂载子组件。
super();
this.input = new Input();
this.addChild(this.input);
}
}

extends Container 产生运行时继承:实例真正获得 addChild,构造时真正执行 super()。原源码还为这个 class 声明了 TypeScript 契约,后面的“TypeScript 的基础类型系统”会回到这层检查。

异步编程用于处理“现在发起、稍后完成”的工作,例如网络请求、定时器和子进程通信。JavaScript 使用 Promise 表示未来的结果。事件循环则负责在异步操作就绪后,把对应的后续任务安排回 JavaScript 执行。

Promise 是一个代表异步结果的对象。它有三种状态:

  • pending:尚未得到结果;
  • fulfilled:已经成功并保存结果值;
  • rejected:已经失败并保存失败原因。

Promise 一旦 fulfilled 或 rejected,就进入 settled 状态,之后不会再次改变。

使用 new Promise(executor) 可以把回调式的完成通知包装成 Promise。构造函数会立即同步调用 executor,并传入 resolve 和 reject 两个函数:

function delay(ms) {
return new Promise((resolve, reject) => {
// executor 在 new Promise(...) 期间立即执行;这里只是登记定时器。
const timer = setTimeout(() => {
// 这里传入普通字符串,因此 resolve(value) 会让 Promise fulfilled。
resolve("done");
}, ms);
if (ms < 0) {
clearTimeout(timer);
// reject(reason) 把 Promise 结算为 rejected,并保存失败原因。
reject(new Error("ms must be non-negative"));
}
});
}

第一次调用 resolve 或 reject 会锁定后续的结算路径,之后再次调用二者会被忽略。reject(reason) 会让 Promise rejected;resolve(value) 接收普通值时会让 Promise fulfilled,接收另一个 Promise 或 thenable 时则会采纳它之后的状态,因此仍可能暂时 pending,最终也可能 rejected。Promise 本身不是线程或 goroutine:真正的异步工作由定时器、网络或子进程 API 推进,Promise 只表示其未来结果。调用 async 函数时,函数体会立即执行到返回、抛错或第一个 await;await 后面的继续执行会被安排到之后,即使等待的是一个已经 fulfilled 的 Promise。

async 函数每次调用都会返回 Promise;普通返回值会成为 Promise 的成功结果,抛出的异常会成为失败原因:

// 调用 async 函数会立即得到 Promise,而不是直接得到字符串。
async function loadMessage() {
return "hello";
}
const promise = loadMessage();
// await 取得 Promise 的成功值;这里只暂停当前 async 上下文。
console.log(await promise); // hello

await 暂停的是当前 async 函数后面的语句,不是整个 JavaScript 运行时。等待期间,事件循环仍然可以执行其他已经就绪的任务。

Promise rejection 会在 await 位置表现为异常,因此可以使用普通的 try/catch/finally:

try {
// fulfilled 时继续执行下一行。
const message = await loadMessage();
console.log(message);
} catch (error) {
// rejected 时从 await 位置跳到这里。
console.error(error);
} finally {
// 无论成功还是失败都执行,适合释放资源。
releaseResources();
}

异步任务的启动、完成与结果顺序

Section titled “异步任务的启动、完成与结果顺序”

串行与并发首先取决于任务何时启动;启动顺序、完成顺序和结果顺序则是三个不同问题。

循环中立即 await 会形成串行执行:

for (const task of tasks) {
// 循环在这里等待,因此下一项尚未启动。
const result = await task();
results.push(result);
}

前一个任务完成后,循环才会启动下一个任务。如果先启动所有任务,再统一等待,它们就能并发推进:

// map 调用每个 task,先把所有异步工作启动起来。
const promises = tasks.map((task) => task());
// 等待全部完成;结果数组仍按 promises 的输入位置排列。
const results = await Promise.all(promises);

这里需要区分三种顺序:

  1. 启动顺序:代码在什么时候调用每个任务;
  2. 完成顺序:每个任务实际在什么时候结束;
  3. 结果顺序:调用方最终按什么顺序拿到结果。

Promise.all 的结果数组保持输入位置不变,即使后面的任务先完成。只要其中一个 Promise rejected,Promise.all 会立即 reject;已经开始的其他任务不会因此自动停止。

LIVE LABPromise 启动、完成与结果顺序
JavaScript
正在准备在线实验……
控制台等待加载
运行后在这里查看输出

AbortController 用来发出取消通知,AbortSignal 把这个通知传给实际执行工作的代码:

// controller 是取消通知的发出方,signal 是只读的接收端。
const controller = new AbortController();
// 工作函数必须接收并主动观察 signal。
startWork(controller.signal);
// 只改变 signal 状态并通知观察者,不会强杀 startWork。
controller.abort(new Error("user cancelled"));

调用 abort() 只会改变 signal 状态并派发事件,不会强行终止任意 Promise,也不会回滚已经发生的文件写入或网络副作用。底层操作必须主动检查 signal 或监听 abort 事件,取消才会真正生效。

一个支持取消的 API 至少要处理两种情况:调用前 signal 已经取消,以及运行中收到取消事件。

function wait(ms, signal) {
return new Promise((resolve, reject) => {
// 情况一:调用 wait 之前已经取消,立即失败。
if (signal.aborted) {
reject(signal.reason);
return;
}
// 正常完成路径:定时器到期后 resolve。
const timer = setTimeout(resolve, ms);
// 情况二:运行中收到取消,停止定时器并 reject。
signal.addEventListener("abort", () => {
clearTimeout(timer);
reject(signal.reason);
}, { once: true });
});
}

正式实现还要在正常完成和取消时移除监听器,避免资源长期保留。

LIVE LAB协作式取消与 finally 清理
JavaScript
正在准备在线实验……
控制台等待加载
运行后在这里查看输出

【Pi】如何让工具并发执行,同时保持事件实时、结果有序

Section titled “【Pi】如何让工具并发执行,同时保持事件实时、结果有序”

packages/agent/src/agent-loop.ts 的并行路径先按顺序预检工具调用,再把通过预检的调用保存为暂不执行的函数:

// 数组位置始终对应原始 toolCalls 的位置。
const finalizedCalls = [];
for (const toolCall of toolCalls) {
// 预检按调用顺序 await:校验参数、执行 hook、检查取消。
const preparation = await prepareToolCall(toolCall);
if (preparation.kind === "immediate") {
// 预检失败、被拦截或取消时,直接得到结果,不执行工具。
const finalized = {
toolCall,
result: preparation.result,
isError: preparation.isError,
};
// immediate 结果在预检阶段就发出结束事件。
await emitToolExecutionEnd(finalized);
finalizedCalls.push(finalized);
continue;
}
// 保存 thunk(延迟执行函数);此时工具尚未启动。
finalizedCalls.push(async () => {
const executed = await executePreparedToolCall(preparation);
const finalized = await finalizeExecutedToolCall(executed);
// 并发工具按各自真实完成时间发出结束事件。
await emitToolExecutionEnd(finalized);
return finalized;
});
}
// map 求值时调用全部 thunk,使通过预检的工具并发推进;
// Promise.all 本身只负责等待,并按输入位置组装结果。
const orderedResults = await Promise.all(
finalizedCalls.map((entry) =>
// immediate 值保持原位;函数值在此处真正启动。
typeof entry === "function" ? entry() : Promise.resolve(entry),
),
);

预检仍然是串行的,因为参数错误、hook 拦截或取消会直接产生 immediate 结果,并在预检阶段立即发出对应的结束事件;只有通过预检的调用才保存成延迟执行的函数。求值 map(...) 时,entry() 才真正启动这些工具;Promise.all 负责等待,值型结果则由 Promise.resolve 原样带入。通过预检的工具按实际完成顺序发出结束事件,最终结果仍按原始调用位置排列。

只要全局配置要求串行,或者批次中有工具声明 executionMode: "sequential",Pi 就让整批退回串行模式。Promise 只表达异步结果;究竟串行还是并发,仍由这段运行时代码决定。

packages/agent/src/agent.ts 对事件监听器采用另一种顺序:

for (const listener of this.listeners) {
// 循环内逐个 await:后一位必须等前一位完成。
await listener(event, signal);
}

Pi 会等待当前监听器完成,再调用下一位。同步监听器返回 void,异步监听器返回 Promise;await 可以处理两者。慢监听器因此会延后下一位监听器,也会延后本次 Agent 运行的收尾。

同一个文件还会为每次运行创建独立的 AbortController,再把 signal 传给模型流、工具预检、工具执行和事件监听器。每一层都必须主动响应 signal;Agent 只能传播取消意图,不能替底层操作撤销已经发生的副作用。

TypeScript 在 JavaScript 代码上增加静态类型。静态类型的作用是让编辑器和编译器在程序运行前发现不匹配的数据关系;它不会创建第二套运行时对象,也不会自动验证外部输入。

类型注解写在变量、参数和返回值上:

// 参数和返回值后的标注只供 TypeScript 检查。
function formatMessage(text: string, index: number): string {
// 模板字符串本身是 JavaScript 运行时代码。
return `${index}: ${text}`;
}

TypeScript 可以从初始值和返回表达式推断类型,因此不必给每个局部变量都写注解:

// 编译器根据 formatMessage 的返回类型推断 message。
const message = formatMessage("hello", 1);
// message 被推断为 string

生成 JavaScript 后,类型注解会消失:

// 类型擦除后只剩同一个 JavaScript 函数体。
function formatMessage(text, index) {
return `${index}: ${text}`;
}

参数和返回值在运行时仍然只是 JavaScript 值。类型检查约束的是经过 TypeScript 编译的调用方;模型输出、网络响应和配置文件不会因为这里写了 string 就自动变得可信。

JavaScript 的原始值在 TypeScript 中对应 string、number、boolean、bigint、symbol、null 和 undefined 等类型。数组有两种等价写法:

// 两个变量都是“元素必须为 string 的数组”。
const toolNames: string[] = ["read", "write"];
const eventNames: Array<string> = ["start", "done"];

T[] 更紧凑,Array<T> 在元素类型较复杂时有时更易读。元组仍是运行时数组,但类型系统会固定各位置的含义与类型:

// 第 0 项必须是请求 ID,第 1 项必须是开始时间。
const pendingEntry: [string, number] = ["req_1", Date.now()];

函数返回类型 void 表示调用方不应期待一个可用结果;普通函数执行完通常得到 undefined,异步函数则使用 Promise<void> 表示“只等待完成,不取得业务结果”:

function logMessage(message: string): void {
console.log(message);
}
async function flush(): Promise<void> {
await savePendingMessages();
}

参数后的 ? 表示调用时可以省略。函数内部读取它时,类型中包含 undefined:

function readFile(path: string, limit?: number): void {
// limit 在这里是 number | undefined。
console.log(path, limit);
}

在严格空值检查下,null 和 undefined 不会自动赋给 string、number 等类型;只有类型明确包含它们或成员本身可选时才允许。

unknown 表示“现在还不知道它是什么”。在读取属性或作为具体类型使用前,必须先通过运行时检查收窄:

function readPath(value: unknown): string {
// 先排除原始值与 null,才能使用对象操作。
if (typeof value !== "object" || value === null) {
throw new Error("expected an object");
}
// 再证明 path 存在且运行时值确实是 string。
if (!("path" in value) || typeof value.path !== "string") {
throw new Error("expected a string path");
}
// 两次检查后,TypeScript 才允许把 value.path 当作 string。
return value.path;
}

重复使用同一项检查时,可以把它写成自定义类型守卫。返回类型中的 value is T 称为类型谓词:函数返回 true 后,TypeScript 会在当前控制流中把参数收窄为 T。

// Record<string, unknown> 先理解为“字符串键对应未知值”的对象类型。
function isRecord(value: unknown): value is Record<string, unknown> {
// 返回值仍是普通 boolean;收窄依据来自这次真实的运行时检查。
return typeof value === "object" && value !== null;
}
function readName(value: unknown): string | undefined {
if (!isRecord(value)) return undefined;
// 通过守卫后可以读取属性,但属性值仍是 unknown,需要继续检查。
return typeof value.name === "string" ? value.name : undefined;
}

Record<K, V> 是 TypeScript 提供的工具类型,用来描述键属于 K、值属于 V 的对象;它的展开方式会在“类型运算与派生类型”一节说明。类型守卫函数会真正运行并返回布尔结果,而 as 不会执行检查;但 TypeScript 不会证明守卫实现与 value is T 完全一致,错误的谓词仍可能误导编译器。

any 则相当于暂时关闭这部分类型检查。它可以赋给几乎任何类型,也允许直接访问任意属性,因此错误更可能留到运行时才暴露。

类型断言 value as SomeType 只改变编译器对值的看法,不会检查、转换或复制运行时数据。后缀非空断言 value! 同样只影响编译器:它表示“我确认这里不是 null 或 undefined”,不会在运行时补值或执行检查。字段声明中的 property!: Type 则叫明确赋值断言,表示“我确认构造期间会给这个字段赋值”;它同样不会替代码完成初始化。

LIVE LABunknown 与运行时收窄
TypeScript
正在准备在线实验……
控制台等待加载
运行后在这里查看输出

这三个概念不在同一层:对象类型是一类类型表达式,interface 是声明对象契约的语法,type 是给任意类型表达式命名的语法。

先看对象值与对象类型的区别:

// 这是运行时真正存在的 JavaScript 对象值。
const readTool = { name: "read" };
// { name: string } 是匿名对象类型,只描述参数必须具有的成员。
function printTool(tool: { name: string }): void {
console.log(tool.name);
}
printTool(readTool);

{ name: string } 不会创建对象,也不是 class;它只要求传入值具有一个值为字符串的 name 属性。这里的成员结构就是对象类型。小写的 object 是另一个更宽泛的类型,只表示“不是原始值”,并没有说明对象包含哪些属性,不能替代 { name: string } 这样的结构描述。

当一份对象结构需要反复使用时,可以用 interface 给它一个名字:

// interface 声明名为 Tool 的对象契约,不会创建 Tool 对象。
interface Tool {
name: string;
// ? 表示属性可以省略;读取时要考虑 undefined。
description?: string;
// readonly 禁止通过 Tool 类型重新赋值,不会冻结底层对象。
readonly parameters: object;
// 方法可以返回异步结果;input 仍需在运行时收窄或校验。
execute(input: unknown): Promise<string>;
}

没有 ? 的成员必须存在;description? 可以省略。readonly 禁止经过 Tool 类型重新给属性赋值,但不会冻结底层对象。interface 还可以通过 extends 组合其他对象契约,也允许同名声明合并,因此适合描述可能继续扩展的公共对象结构。

type 声明的是类型别名:它给等号右边的类型表达式起一个名字,而不是创建一种新的运行时值或名义类型。它也能命名对象类型,但还可以表示 interface 不能直接表达的联合类型、元组和类型运算:

// type 同样可以命名对象类型。
type ToolSummary = {
name: string;
enabled: boolean;
};
// type 可以给字面量联合起名字。
type ToolName = "read" | "write";
// 对象联合表达两种互斥的结果形状。
type ToolResult = { text: string } | { error: string };

对于普通对象结构,interface Tool { ... } 与 type Tool = { ... } 都按成员结构检查,通常都能完成任务。需要可扩展、可合并的对象契约时常用 interface;需要联合、元组或其他类型运算时使用 type。两者都会在生成 JavaScript 时消失,不能拿 Tool 或 ToolResult 做 instanceof 等运行时判断。

对 Gopher 来说,需要特别注意两点:TypeScript 的 interface 可以声明属性,不只是方法集合;一个值只要成员兼容就能满足它,不需要显式声明实现。TypeScript 的 type UserId = string 也更接近 Go 的类型别名,不会像 type UserId string 那样得到一个与 string 区分开的新定义类型。

前面的 JavaScript class 会在运行时建立构造函数、原型方法和继承关系。TypeScript 还能在这套语法上增加只用于编译检查的约束:

  • implements Contract:检查 class 是否提供 interface 要求的成员,但不会把成员实现复制进来;
  • abstract:要求具体子类补全抽象方法,并阻止 TypeScript 代码直接创建基类实例;
  • private、protected、readonly:限制源码中可以访问或重新赋值成员的位置;
  • override:检查父类中确实存在一个兼容的方法。

这些关键字大多会随类型一起被擦除。JavaScript 的 #private 不同:#field 会保留到运行时,并由 JavaScript 引擎阻止 class 外部访问。

LIVE LABclass、继承与访问边界
TypeScript
正在准备在线实验……
控制台等待加载
运行后在这里查看输出

Pi 的 ExtensionInputComponent extends Container implements Focusable 正好把两层放在一起:extends 建立运行时继承,implements 检查组件是否满足 Focusable 契约。

TypeScript 主要按照对象的成员结构判断兼容性。一个对象只要满足 interface 描述的结构,就符合该接口类型,不需要由某个类创建:

// 这个普通对象没有 extends、implements 或 new。
const readTool = {
name: "read",
parameters: { type: "object" },
async execute(input: unknown) {
return String(input);
},
};
// 参数类型只检查成员结构是否兼容。
function register(tool: Tool): void {
console.log(tool.name);
}
// readTool 拥有 Tool 所需成员,因此可以直接传入。
register(readTool);

satisfies 可以在对象定义处检查契约,同时保留对象自身更精确的推断:

const checkedReadTool = {
name: "read",
parameters: { type: "object" },
async execute(input: unknown) {
return String(input);
},
// satisfies 检查整个对象满足 Tool,同时保留对象自身精确类型。
} satisfies Tool;

它和 as Tool 不同:satisfies 会报告不兼容字段,as 是让编译器接受一项断言。两者都不会在运行时验证对象。

LIVE LAB结构类型与可选方法
TypeScript
正在准备在线实验……
控制台等待加载
运行后在这里查看输出

函数类型描述参数和返回值:

// Listener 是函数类型,不是运行时函数。
type Listener = (
event: AgentEvent,
signal: AbortSignal,
// 同步 listener 返回 void,异步 listener 返回 Promise<void>。
) => Promise<void> | void;

函数重载让同一个函数在类型层公开多种调用方式。下面的 AgentMessage 和 ImageContent 是 Pi 的消息类型;这里不需要展开它们的字段,只观察两种调用签名:

// 下面两行是给调用方看的重载签名,不会各生成一个函数。
async function prompt(message: AgentMessage): Promise<void>;
async function prompt(input: string, images?: ImageContent[]): Promise<void>;
// 这是唯一的运行时实现,参数类型必须覆盖所有重载输入。
async function prompt(
input: string | AgentMessage,
images?: ImageContent[],
): Promise<void> {
// 一个运行时实现负责区分两种输入
}

前两行只描述调用方允许怎样使用函数,最后一个函数才是运行时实现。重载不会生成两个 JavaScript 函数,实际代码仍需用 typeof、Array.isArray 等检查区分输入。

【Pi】如何让普通对象满足工具契约

Section titled “【Pi】如何让普通对象满足工具契约”

packages/agent/src/types.ts 中的 AgentTool 是一个带有 schema、参数与结果泛型关系的 interface。这里先只观察结构兼容;完整的泛型关系留到泛型一章解释。

packages/coding-agent/src/core/tools/tool-definition-wrapper.ts 返回的只是一个普通对象:

// 返回普通对象;TypeScript 按成员结构检查它是否满足 AgentTool。
return {
name: definition.name,
label: definition.label,
description: definition.description,
parameters: definition.parameters,
executionMode: definition.executionMode,
// 箭头函数把调用原样转发给底层 definition。
execute: (toolCallId, params, signal, onUpdate) =>
definition.execute(toolCallId, params, signal, onUpdate),
};

它没有调用 new AgentTool()。这个普通对象拥有 AgentTool 所要求的成员,TypeScript 因此允许 Agent 使用它;不需要继承某个工具基类,也不需要在运行时构造一个 interface。

TypeScript 的联合类型与控制流收窄

Section titled “TypeScript 的联合类型与控制流收窄”

联合类型表示一个值可能属于多个候选类型。控制流收窄是 TypeScript 根据实际的 if、switch、typeof 或属性检查,逐步排除当前分支中不可能的候选类型。

最简单的联合可以写成:

function printId(id: string | number): void {
// typeof 是运行时检查;同时把当前分支的 id 收窄为 string。
if (typeof id === "string") {
console.log(id.toUpperCase());
} else {
// string 已被排除,这里只剩 number。
console.log(id.toFixed(0));
}
}

进入第一个分支后,id 被收窄为 string;第二个分支则只剩 number。

字符串字面量类型描述的不是任意字符串,而是几个明确允许的值:

// 变量只能取这三个字符串,而不是任意 string。
type StopReason = "stop" | "length" | "toolUse";

编译器会拒绝其他字符串。这种类型适合表达有限状态、事件名称和协议字段。

如果联合的每个对象成员都有同一个字面量字段,这个字段就可以作为判别字段:

type StreamEvent =
// 每个成员都有同名 type 字段,且值是不同的字符串字面量。
| { type: "start" }
// delta 只属于 text_delta 成员。
| { type: "text_delta"; delta: string }
| { type: "done"; reason: "stop" | "length" }
| { type: "error"; message: string };

switch (event.type) 不仅决定 JavaScript 执行哪个分支,还让 TypeScript 同步收窄整个对象:

function summarize(event: StreamEvent): string {
// switch 在运行时分支,也让 TypeScript 根据 type 收窄 event。
switch (event.type) {
case "start":
return "started";
case "text_delta":
// 这里 event 已是 text_delta 成员,因此可以读取 delta。
return event.delta;
case "done":
return event.reason;
case "error":
return event.message;
default: {
// 所有成员都处理后,event 应当是不可能存在的 never。
const unhandled: never = event;
return unhandled;
}
}
}

never 表示不可能存在的值。当所有成员都已经处理,default 中的 event 才能收窄为 never。以后新增事件却忘记增加 case 时,赋值会在编译期报错。

LIVE LAB判别联合、收窄与穷尽检查
TypeScript
正在准备在线实验……
控制台等待加载
运行后在这里查看输出

【Pi】如何用判别联合安全处理流式事件

Section titled “【Pi】如何用判别联合安全处理流式事件”

packages/agent/src/proxy.ts 的 ProxyAssistantMessageEvent 使用 type 作为判别字段:

type ProxyAssistantMessageEvent =
// type 是判别字段;其余字段是该事件独有的 payload。
| { type: "start" }
| { type: "text_start"; contentIndex: number }
| { type: "text_delta"; contentIndex: number; delta: string }
| { type: "toolcall_start"; id: string; toolName: string }
| { type: "done"; reason: "stop" | "length" | "toolUse" }
| { type: "error"; reason: "aborted" | "error"; errorMessage?: string };

processProxyEvent 按 type 分支后,可以直接访问对应成员才拥有的字段。Extract<Union, Members> 还会从已有联合中选择允许的结束原因,避免重复写另一套字符串。

这项安全只建立在“输入已经符合联合结构”的前提上。要获得运行时保证,网络 JSON 还需要结构校验;当前 proxy 路径主要使用 JSON.parse(...) 和类型断言,并没有用 TypeBox 检查事件形状。因此,这个联合表达的是代码期待收到的结构,不是对网络字节已经合规的证明。

泛型使用类型参数保存多个位置之间的关系。它不是让代码“什么类型都接受”,而是在复用代码的同时,让输入、输出和对象成员保持对应。

同一个类型参数出现在输入和输出位置时,可以保留二者之间的类型关系。

// T 同时出现在参数和返回值中,保存“输入什么就返回什么”的关系。
function identity<T>(value: T): T {
return value;
}
// 调用处推断 T;这里分别保留字面量 "hello" 和 3。
const text = identity("hello"); // "hello"
const count = identity(3); // 3

T 由调用处推断。这里的返回值分别保留调用处推断出的字面量类型 "hello" 和 3。若把返回类型直接写成 unknown,这层关系就会丢失。

工具通常需要同时保持输入和输出关系:

// Input 与 Output 是两个相互独立、但会贯穿调用链的类型参数。
type Executable<Input, Output> = {
execute(input: Input): Output;
};
function runTool<Input, Output>(
// tool.execute 决定 Input 与 Output 的对应关系。
tool: Executable<Input, Output>,
// input 必须与同一个 Input 兼容。
input: Input,
): Output {
// 返回值继续保留同一个 Output,而不是退化为 unknown。
return tool.execute(input);
}

Input 同时约束工具和调用参数,Output 同时描述工具结果和 runTool 的返回值。

LIVE LAB泛型推导与工具契约
TypeScript
正在准备在线实验……
控制台等待加载
运行后在这里查看输出

泛型约束说明类型参数至少要满足什么能力:

// extends 是泛型约束:T 可以有更多字段,但至少必须有 name。
function getName<T extends { name: string }>(value: T): string {
return value.name;
}

T 可以拥有更多字段,但必须有 name: string。泛型还可以提供默认类型参数,让调用方不传时使用默认关系。

// 调用方省略 TDetails 时,details 使用 undefined。
type ToolResult<TDetails = undefined> = {
content: string;
details: TDetails;
};
const simpleResult: ToolResult = {
content: "done",
details: undefined,
};
const timedResult: ToolResult<{ duration: number }> = {
content: "done",
details: { duration: 12 },
};

= undefined 只规定省略类型实参时的默认值;显式写入 <{ duration: number }> 后,所有使用 TDetails 的位置都会跟着改变。

对象属性通常需要允许以后重新赋值,因此 TypeScript 可能把字面量 "parallel" 放宽为 string。as const、satisfies 和 const 类型参数从三个不同位置控制这种推断:

type ExecutionOptions = {
mode: "sequential" | "parallel";
};
const ordinary = { mode: "parallel" };
// ordinary.mode 被放宽为 string,因为属性仍可修改。
const fixed = { mode: "parallel" } as const;
// fixed.mode 是 readonly 的字面量类型 "parallel"。
const checked = { mode: "parallel" } satisfies ExecutionOptions;
// checked 必须满足契约,同时保留自己的精确推断;它没有被转成 ExecutionOptions。
// const 类型参数要求推断时尽量保留字面量和 readonly 信息。
function keepLiterals<const T>(value: T): T {
return value;
}
const config = keepLiterals({ mode: "parallel" });
// config.mode 保留为 readonly 的字面量 "parallel"。

as const 作用于当前表达式,并为对象或数组保留字面量与只读信息;satisfies 检查表达式符合某个类型,但不把表达式直接改成那个类型;const T 则作用于泛型调用处的推断。三者都只影响类型检查,不会像 Object.freeze() 一样冻结运行时对象。

【Pi】如何用泛型关联工具 schema、参数和结果

Section titled “【Pi】如何用泛型关联工具 schema、参数和结果”

现在回到 packages/agent/src/types.ts 中 AgentTool 的真实声明。这里继承的 Tool 是 Pi 自己的基础工具接口,与上面的教学类型 Executable 不是同一个类型。TypeBox 用 TSchema 表示 schema 的基础类型,用 Static<S> 从 schema S 推导 TypeScript 类型。下面只保留与这组泛型关系直接相关的成员:

interface AgentTool<
// schema 类型:必须满足 TSchema;省略时默认使用宽泛的 TSchema。
TParameters extends TSchema = TSchema,
// 进度与最终结果详情的类型;省略时默认 any。
TDetails = any,
> extends Tool<TParameters> {
execute: (
toolCallId: string,
// Static 从同一个 TypeBox schema 推导真正的参数类型。
params: Static<TParameters>,
signal?: AbortSignal,
// TDetails 同时约束进度回调……
onUpdate?: AgentToolUpdateCallback<TDetails>,
// ……以及最终 Promise 结果中的 details。
) => Promise<AgentToolResult<TDetails>>;
}

这里有两条需要保留的类型关系:

  • TParameters extends TSchema 约束第一个类型参数必须是 TypeBox schema,= TSchema 是调用方省略它时使用的默认类型;
  • Static<TParameters> 从这份 schema 推导 params,TDetails 则同时连接进度回调与最终结果中的 details。

因此,为 read 工具传入源码中的 readSchema 后,execute 的 params 会自动得到 path、offset 等字段。这里的泛型让 schema 与实现参数保持一致,并让进度回调与最终结果共享同一个 details 类型。TypeBox 如何在运行时检查数据,会在下一章说明。

TypeScript 可以通过 keyof、索引访问、映射类型和条件类型,从已有类型派生新类型。常见工具包括:

  • keyof T:取得对象类型的键联合;
  • T[K]:取得属性对应的类型;
  • 映射类型:逐个遍历键并生成新属性;
  • 条件类型:使用 A extends B ? X : Y 在类型层选择结果;
  • infer:给条件类型匹配到的内部类型命名;
  • Extract、Exclude、Pick、Partial:常用类型运算的标准封装。

先用一个不依赖 Pi 的例子逐层推演:

type ToolFields = {
name: string;
retries: number;
enabled: boolean;
};
// keyof 取得全部属性名:"name" | "retries" | "enabled"。
type ToolFieldName = keyof ToolFields;
// 索引访问取得这些属性对应的值类型:string | number | boolean。
type ToolFieldValue = ToolFields[ToolFieldName];
// 映射类型遍历每个属性名,并为对应值增加 Promise。
type AsyncToolFields = {
[K in keyof ToolFields]: Promise<ToolFields[K]>;
};
// 第一步:每个属性映射为“属性名本身”或 never。
type StringFieldMap = {
[K in keyof ToolFields]: ToolFields[K] extends string ? K : never;
};
// 得到 { name: "name"; retries: never; enabled: never }。
// 第二步:索引映射结果,把所有值取成联合。
type StringFieldName = StringFieldMap[keyof ToolFields];
// "name" | never | never 最终化简为 "name"。

条件类型 A extends B ? X : Y 在类型层选择结果。上例中,不符合 string 的字段变成 never;对映射结果做索引访问后,never 会从联合中消失,于是完成按条件筛选属性名的过程。

infer 用在条件类型中,为匹配到的内部类型命名:

// Promise<infer Value> 匹配成功时,Value 代表 Promise 内部的结果类型。
type PromiseValue<T> = T extends Promise<infer Value> ? Value : T;
type Loaded = PromiseValue<Promise<{ bytes: number }>>;
// Loaded 是 { bytes: number }。

常用工具类型只是这些运算的标准封装:Record<K, V> 生成键值对象,Pick<T, K> 选择属性,Partial<T> 把属性变为可选,Extract<U, M> 保留联合中匹配 M 的成员,Exclude<U, M> 则排除它们。交叉类型 A & B 要求同一个值同时满足两边。

这些运算发生在编译期,不会遍历或修改真正的 JavaScript 对象。

LIVE LAB条件类型、infer 与映射类型
TypeScript
正在准备在线实验……
控制台等待加载
运行后在这里查看输出

【Pi】如何用映射类型保持模型 ID 与 API 对应

Section titled “【Pi】如何用映射类型保持模型 ID 与 API 对应”

packages/ai/src/model-catalog.ts 从模型分组中推导所有 model ID:

下面的真实类型把前文的 Record<K, V>、映射类型、条件类型、索引访问和交叉类型组合在一起。

// 外层 key 是 API 名,内层 key 是该 API 下的 model ID。
type ModelGroups = Record<string, Record<string, object>>;
type ModelId<TGroups extends ModelGroups> = {
// 先遍历每个 API,再取该分组的全部 model ID。
[TApi in keyof TGroups]: keyof TGroups[TApi];
// 对映射类型做索引访问,把各组 model ID 合并成联合。
}[keyof TGroups] & string;

映射类型先遍历每个 API 分组,内层 keyof 取得该组的 model ID,最后的索引访问把各组结果合并为联合。

ModelApi 再根据 model ID 反查所属 API:

type ModelApi<
TGroups extends ModelGroups,
TModelId extends ModelId<TGroups>,
> = {
// model ID 属于当前分组就保留 API 名,否则产生 never。
[TApi in keyof TGroups]:
TModelId extends keyof TGroups[TApi] ? TApi : never;
// 再把各分支取出为联合;never 会自然消失。
}[keyof TGroups] & Api;

命中目标 model ID 的 API 被保留,其他分支变成 never 并从联合中消失。最终目录用映射类型为每个模型 ID 生成一个属性,并用 & 补上精确的 id 与 provider:

type ModelCatalog<
TGroups extends ModelGroups,
TProvider extends ProviderId,
> = {
// 为推导出的每个 model ID 生成一个目录属性。
[TModelId in ModelId<TGroups>]:
Model<ModelApi<TGroups, TModelId>> & {
// 交叉类型补上与属性键精确对应的字面量字段。
id: TModelId;
provider: TProvider;
};
};

调用入口使用 const 类型参数,保留传入目录中的模型 ID 和 provider 字面量:

function flattenModelCatalog<
// const 保留调用处的 provider 和 model ID 字面量。
const TProvider extends ProviderId,
const TGroups extends ModelGroups,
>(
provider: TProvider,
groups: TGroups,
): ModelCatalog<TGroups, TProvider> {
// 运行时只执行普通对象合并。
return Object.assign({}, ...Object.values(groups)) as ModelCatalog<
// 断言连接实现与复杂返回类型;它不会校验合并结果。
TGroups,
TProvider
>;
}

运行时的 flattenModelCatalog 只是合并对象;复杂关系都由 TypeScript 在编译期维护。

最后的 as ModelCatalog<...> 是类型断言,不是运行时转换。若目录生成逻辑本身有错,断言仍可能掩盖问题,因此它必须对应一段可以人工证明的实现。

TypeScript 的静态类型与运行时校验

Section titled “TypeScript 的静态类型与运行时校验”

TypeScript 能检查正在编译的源码,却无法提前检查模型返回的工具参数、网络 JSON、配置文件或子进程输出。这些值进入程序时,需要经过真正运行的校验代码。

下面的 interface 在运行时不存在:

// 这份声明只存在于 TypeScript 类型系统,运行时没有 ReadParams。
interface ReadParams {
path: string;
offset?: number;
}

这段断言也不会检查数据:

// JSON.parse 得到外部对象;as 只让编译器暂时相信它。
const input = JSON.parse('{"offset": 10}') as ReadParams;
// path 实际不存在,类型断言不会阻止这里抛错。
console.log(input.path.toUpperCase()); // 运行时仍然会失败

JSON.parse 得到的对象并不会因为 as ReadParams 自动增加 path。更安全的边界是先把外部值视为 unknown,然后使用明确的检查函数或 schema 验证。

TypeBox 的 Schema、静态类型与运行时校验

Section titled “TypeBox 的 Schema、静态类型与运行时校验”

Schema 是机器可读的数据结构说明,可以描述对象有哪些字段、字段类型以及哪些字段可以省略。TypeBox 使用 TypeScript API 创建运行时存在的 JSON Schema,同时从同一份 schema 推导静态类型:

// Static 是类型导入;Type 是创建 schema 的运行时值。
import { type Static, Type } from "typebox";
// Check 是真正执行数据检查的运行时函数。
import { Check } from "typebox/value";
// 这段代码创建一个运行时存在的 schema 对象。
const ReadParamsSchema = Type.Object({
path: Type.String(),
offset: Type.Optional(Type.Number()),
});
// 类型层从同一份 schema 推导源码中的参数形状。
type ReadParams = Static<typeof ReadParamsSchema>;
// { path: string; offset?: number }

类型位置的 typeof ReadParamsSchema 取得这个运行时值在 TypeScript 中的静态类型;它和 typeof value === "string" 这种运行时检查不是同一种用法。

ReadParamsSchema 是真正的 JavaScript 对象,可以交给 validator。ReadParams 只存在于类型系统,生成 JavaScript 时会被移除。

运行时还需要执行检查:

// 外部 JSON 先以 unknown 进入可信代码。
const input: unknown = JSON.parse(text);
// Check 在运行时验证;成功分支同时获得类型收窄。
if (Check(ReadParamsSchema, input)) {
// 此分支中 input 已被收窄为 ReadParams
read(input);
}
LIVE LABTypeBox 的静态类型与运行时校验
TypeScript
正在准备在线实验……
控制台等待加载
运行后在这里查看输出

【Pi】如何用 TypeBox 在执行前校验工具参数

Section titled “【Pi】如何用 TypeBox 在执行前校验工具参数”

packages/coding-agent/src/core/tools/read.ts 用 TypeBox 定义 read 参数。模型产生工具调用后,packages/agent/src/agent-loop.ts 不会直接相信参数,而是调用 packages/ai/src/utils/validation.ts:

function validateToolArguments(tool: Tool, toolCall: ToolCall) {
// 深复制,避免下面的规范化与转换改写消息历史中的原参数。
const args = structuredClone(toolCall.arguments);
// 先处理可选字段的 null,再按 schema 尝试类型转换。
normalizeOptionalNulls(args, tool.parameters);
Value.Convert(tool.parameters, args);
// validator.Check 才是允许参数进入工具 effect 的最终条件。
const validator = getValidator(tool.parameters);
if (validator.Check(args)) {
return args;
}
// 转换后仍不合规:在执行工具之前拒绝。
throw new Error(`Validation failed for tool "${toolCall.name}"`);
}

structuredClone 对工具参数做深复制,防止后面的转换过程改写消息历史中的原始参数。normalizeOptionalNulls 处理模型常见的可选字段空值,Value.Convert 按 schema 尝试转换,Check 才是最终的通过条件。

Static<typeof schema> 改善源码中的类型体验;validator 负责运行时边界。两者来自同一份 schema,但承担完全不同的工作。

迭代器描述“怎样逐个取得一组值”。流描述“数据怎样随着时间持续到达”。它们都能逐项消费数据,但不是同一个 API:Pi 自定义的 EventStream、Fetch HTTP API 返回的 ReadableStream 和 Node.js 的 Readable 各有自己的生命周期与错误规则。

三者在本文中的角色如下:

类型 主要消费方式 完成与错误 取消与流量控制
Pi EventStream<T, R> for await...of 消费事件,result() 取得最终结果 end() 或完成事件结束迭代;最终错误如何表达由该类约定 没有容量上限,也没有内置背压
Web ReadableStream getReader().read(),也可异步迭代 producer 调用 close() / error();reader 观察 done 或异常 reader 可以 cancel();内部队列用 desiredSize 向 producer 表达需求,底层数据源通常在 pull() 中继续生产
Node.js Readable data / readable 事件、pipe() 或异步迭代 end 表示读完,error 表示失败,close 表示底层资源关闭 destroy() 中断;队列受 highWaterMark 影响,push() 返回 false 时 producer 应暂停;pipe() 还会配合可写端的 write() / drain

背压是消费者跟不上生产者时,把“暂时不要继续生产”反馈给上游的机制。如果队列没有容量上限,producer 也不减速或丢弃数据,持续写入才会使内存中的待消费数据不断增长。这三类对象即使都能被 for await...of 消费,也不能据此认为它们的结束、错误和取消语义相同。

Iterable 是可以创建 iterator 的对象。同步 iterable 需要实现 [Symbol.iterator]();iterator 的 next() 每次返回一个 { value, done }:

Symbol.iterator 是 JavaScript 预定义的 well-known symbol,也就是语言约定用于查找同步迭代入口的特殊属性键。对象字面量中的 [expression] 是计算属性名语法:先求出方括号内表达式的值,再把它作为属性键。因此 [Symbol.iterator]() 声明的是一个以该 symbol 为键的方法,不是数组索引。

const numbers = {
// 这个特殊方法让 numbers 成为同步 Iterable。
*[Symbol.iterator]() {
// 每次 yield 暂停 generator,并向调用方交付一个值。
yield 1;
yield 2;
yield 3;
},
};
// for...of 内部反复调用 iterator.next(),直到 done 为 true。
for (const number of numbers) {
console.log(number);
}

function* 或方法前的 * 表示 generator。调用 generator 不会一次执行完函数,而是返回 iterator;每次 next() 让代码运行到下一个 yield。

异步 iterable 实现 [Symbol.asyncIterator](),它的 next() 返回 Promise。消费方使用 for await...of,可以等待下一项到达:

Symbol.asyncIterator 是异步迭代对应的 well-known symbol。for await...of 会先取得这个属性对应的方法,再反复等待它返回的异步迭代结果。

// async generator 的每次 next() 都返回 Promise。
async function* messages(): AsyncGenerator<string> {
yield "first";
// generator 可以在两次 yield 之间等待异步工作。
await delay(20);
yield "second";
}
// for await...of 会等待每一个异步 next()。
for await (const message of messages()) {
console.log(message);
}

async function* 同时允许 await 上游异步操作和 yield 多个下游值。它适合把“不断到达的数据”转换成逐项可读的序列。

【Pi】如何用 AsyncIterable 同时提供事件流和最终结果

Section titled “【Pi】如何用 AsyncIterable 同时提供事件流和最终结果”

packages/ai/src/utils/event-stream.ts 实现了 AsyncIterable<T>,并额外提供最终结果 Promise<R>。下面保留构造、写入、主动结束、迭代和最终结果五条关键路径:

class EventStream<T, R = T> implements AsyncIterable<T> {
// 生产快于消费时,尚未消费的事件进入 queue。
private queue: T[] = [];
// 消费快于生产时,等待下一项的 resolve 进入 waiting。
private waiting: Array<(value: IteratorResult<T>) => void> = [];
private done = false;
// 逐项事件使用 T;整条流的最终结果使用 R。
private finalResultPromise: Promise<R>;
// ! 表示构造期间会赋值;它不会在运行时自动初始化字段。
private resolveFinalResult!: (result: R) => void;
private isComplete: (event: T) => boolean;
private extractResult: (event: T) => R;
constructor(
isComplete: (event: T) => boolean,
extractResult: (event: T) => R,
) {
this.isComplete = isComplete;
this.extractResult = extractResult;
// 保存 resolve,等完成事件到达时再结束最终结果 Promise。
this.finalResultPromise = new Promise((resolve) => {
this.resolveFinalResult = resolve;
});
}
push(event: T): void {
// 完成后的额外事件不再进入流。
if (this.done) return;
if (this.isComplete(event)) {
this.done = true;
// 同一个完成事件同时决定流结束和最终 R 结果。
this.resolveFinalResult(this.extractResult(event));
}
const waiter = this.waiting.shift();
if (waiter) {
// 已有消费者等待:直接把事件交给最早的消费者。
waiter({ value: event, done: false });
} else {
// 暂无消费者:把事件排队,稍后再 yield。
this.queue.push(event);
}
}
end(result?: R): void {
// 主动结束迭代;这条路径不需要先收到完成事件。
this.done = true;
// 只有 result !== undefined 时才结束 result() 返回的 Promise。
// 无参 end() 和显式 end(undefined) 都会结束迭代,但最终结果仍 pending。
if (result !== undefined) {
this.resolveFinalResult(result);
}
// 唤醒所有正在等待下一项的消费者,通知它们迭代已结束。
while (this.waiting.length > 0) {
// while 条件保证 shift() 有值;! 只把这一事实告诉 TypeScript。
const waiter = this.waiting.shift()!;
// done: true 时 value 不会被消费,源码用 any 适配 IteratorResult<T>。
waiter({ value: undefined as any, done: true });
}
}
// 实现这个特殊方法后,实例可被 for await...of 消费。
async *[Symbol.asyncIterator](): AsyncIterator<T> {
while (true) {
if (this.queue.length > 0) {
// 队列已有数据,无需等待生产者。
yield this.queue.shift()!;
} else if (this.done) {
// 队列排空且流已完成,结束迭代。
return;
} else {
// 暂无数据:把本次 next() 的 resolve 登记给生产者。
const next = await new Promise<IteratorResult<T>>((resolve) =>
this.waiting.push(resolve),
);
if (next.done) return;
yield next.value;
}
}
}
result(): Promise<R> {
// 与逐项迭代独立地等待整条流的最终结果。
return this.finalResultPromise;
}
}

构造函数真正创建了 finalResultPromise,完成事件到达时再由保存的 resolveFinalResult 结束它。字段声明后的 ! 是前文提到的明确赋值断言:TypeScript 无法从 Promise executor 中推断字段已经赋值,但运行时代码确实会在构造期间完成赋值。

生产快于消费时,事件暂存在 queue;消费快于生产时,下一次迭代把自己的 resolve 放进 waiting。事件到来后直接唤醒最早的等待者。

AssistantMessageEventStream 用逐项事件表达文本增量、工具调用增量和结束事件,同时用 result() 交付完整助手消息。增量事件和最终消息是同一条流的两种视图。

当前 EventStream 的队列没有容量上限,也没有背压机制:如果生产速度长期高于消费速度,内存中的待消费事件会持续增加。AssistantMessageEventStream 收到错误事件时,result() 会 resolve 一条失败的 assistant message,而不是 reject;调用方需要检查最终消息的 stopReason 和 errorMessage。

LIVE LAB用 for await 消费事件流
TypeScript
正在准备在线实验……
控制台等待加载
运行后在这里查看输出

SSE 是 Server-Sent Events,一种通过 HTTP 持续发送文本事件的格式。Fetch 响应体提供 ReadableStream<Uint8Array>,每次读取只得到当前可用的一段字节,也就是 chunk。

Chunk 边界不等于字符边界、文本行边界或 SSE 事件边界。一个事件可能跨多个 chunk,一个 chunk 也可能包含多个事件。

SSE 在字节流上使用文本行表示字段,并以空行结束一条事件。Pi 当前路径主要读取 data: 字段;下面展示协议序列开头的三个合法事件,每两个事件之间都有一个空行:

data: {"type":"start"}
data: {"type":"text_start","contentIndex":0}
data: {"type":"text_delta","contentIndex":0,"delta":"Hel"}

一次网络读取不保证正好停在这个空行上,所以接收方必须自己保留未完成的文本尾部。

这里的实现面向 Pi 当前服务端约定,不是通用 SSE parser:一帧里只读取第一行 data:,而标准 SSE 允许把多行 data: 合并;流在没有空行收尾时,Pi 也会尝试解析 EOF 前残留的最后一帧。

packages/ai/src/api/pi-messages.ts 使用异步生成器和缓冲区处理这个差异:

async function* readPiMessagesEvents(
stream: ReadableStream<Uint8Array>,
): AsyncGenerator<PiMessagesEvent> {
// decoder 负责跨 chunk 保留未完成的 UTF-8 字符。
const decoder = new TextDecoder();
// reader 独占读取 response body,finally 中必须归还锁。
const reader = stream.getReader();
// buffer 保存尚未组成完整 SSE 事件的文本尾部。
let buffer = "";
try {
while (true) {
// value 只是当前 chunk;done 表示上游字节流结束。
const { done, value } = await reader.read();
// stream: true 不会把 chunk 末尾的半个 UTF-8 字符错误解码。
buffer += done
? decoder.decode()
: decoder.decode(value, { stream: true });
// 统一换行格式,后面只处理 \n\n 事件分隔符。
buffer = buffer.replace(/\r\n/g, "\n");
let split = buffer.indexOf("\n\n");
while (split !== -1) {
// 只把一个完整事件片段交给解析器。
const event = parsePiMessagesEvent(buffer.slice(0, split));
if (event) yield event;
// 删除已消费片段,继续寻找同一 chunk 中的后续事件。
buffer = buffer.slice(split + 2);
split = buffer.indexOf("\n\n");
}
if (done) break;
}
if (buffer.trim()) {
// 上游结束后,最后一条事件可能没有尾随空行。
const event = parsePiMessagesEvent(buffer);
if (event) yield event;
}
} finally {
// 归还读取权;这不等于取消 Fetch 请求。
reader.releaseLock();
}
}

TextDecoder 的流式模式会保留被 chunk 截断的 UTF-8 字符,等下一批字节到来后再完成解码。buffer 则保留尚未凑齐空行分隔符的事件尾部。

getReader() 取得 stream 的独占读取权;finally 中的 releaseLock() 归还读取权,但不等于取消网络请求。若需要中断上游 Fetch,仍要使用 AbortSignal。

进程是操作系统中的一个运行实例。Node.js 可以使用 child_process.spawn() 启动子进程,并通过 stdin、stdout 和 stderr 与它交换数据。

// node: 前缀表示 Node.js 内置的 child_process 模块。
import { spawn } from "node:child_process";
// spawn 立即返回 ChildProcess,不会等待 worker 退出。
const child = spawn("node", ["worker.js"], {
// 三个 pipe 依次对应子进程的 stdin、stdout、stderr。
stdio: ["pipe", "pipe", "pipe"],
});

三条标准流的方向以子进程为中心:

  • stdin:父进程向子进程写入输入;
  • stdout:子进程向父进程输出正常结果;
  • stderr:子进程输出诊断和错误信息。

spawn() 异步启动进程,不会等待子进程退出才返回。父进程需要监听 error、exit 等事件,并处理管道关闭、进程崩溃和未完成请求。

stdout 是一条 stream。一次 data 事件可能只有半条消息,也可能包含多条消息,不能把 chunk 直接当成业务响应。

Node.js 的 Readable 同时也是 EventEmitter:on(event, listener) 会持续监听事件,once(...) 只处理第一次事件。二进制模式下,stdout 的 data chunk 通常是 Buffer;Node.js 的 Buffer 是 Uint8Array 的子类,因此可以交给 TextDecoder。如果为 stream 设置了文本 encoding,chunk 才会直接以字符串交付。

JSONL 是 JSON Lines 的缩写,每一行保存一个完整 JSON 值。发送方序列化一条消息后追加换行,接收方持续缓冲文本,直到发现换行才解析完整消息:

// TextDecoder 处理 chunk 之间可能被截断的 UTF-8 字符。
const decoder = new TextDecoder();
// buffer 保存尚未遇到换行的半条 JSONL 消息。
let buffer = "";
stdout.on("data", (chunk) => {
// data 事件给的是任意 chunk,不保证等于一行。
buffer += decoder.decode(chunk, { stream: true });
let newline = buffer.indexOf("\n");
while (newline !== -1) {
// 只解析换行之前的一条完整 JSON 文本。
const line = buffer.slice(0, newline);
// 保留换行之后的半条或更多完整消息继续处理。
buffer = buffer.slice(newline + 1);
handleMessage(JSON.parse(line));
newline = buffer.indexOf("\n");
}
});

JSON 负责把对象表示为文本,JSONL 负责在连续文本流中标出每条消息的边界。二者解决的是不同问题。

【Pi】如何用 JSONL 和请求 ID 在 stdio 上实现 RPC

Section titled “【Pi】如何用 JSONL 和请求 ID 在 stdio 上实现 RPC”

RPC 是 Remote Procedure Call,即调用方向另一个进程发送请求,并像等待异步函数一样等待响应。Pi 的 packages/coding-agent/src/modes/rpc/rpc-client.ts 启动 pi --mode rpc:

// 启动独立的 Pi RPC 进程,而不是在当前进程调用函数。
const childProcess = spawn("node", [cliPath, "--mode", "rpc"], {
cwd: options.cwd,
// 子进程继承环境,再用调用方提供的变量覆盖。
env: { ...process.env, ...options.env },
stdio: ["pipe", "pipe", "pipe"],
});
// stdout 是 chunk 流;helper 负责恢复 JSONL 行边界。
attachJsonlLineReader(childProcess.stdout!, handleLine);

多个请求可以同时等待,因此每个请求都要分配 ID。客户端用 Map 保存该请求 Promise 的完成函数:

// key 是请求 ID,value 保存该请求 Promise 的完成能力。
private pendingRequests = new Map<
string,
{
// 收到同 ID 的成功响应时调用。
resolve: (response: RpcResponse) => void;
// 超时、进程退出或写入失败时调用。
reject: (error: Error) => void;
}
>();

发送时先登记,再写 stdin:

return new Promise((resolve, reject) => {
// 超时必须删除 pending 表项,避免请求永久悬挂。
const timeout = setTimeout(() => {
this.pendingRequests.delete(id);
reject(new Error(`Timeout waiting for ${command.type}`));
}, 30_000);
// 先登记 ID,再写 stdin,避免响应先到却找不到等待者。
this.pendingRequests.set(id, {
resolve: (response) => {
// 任一结算路径都先清理 timeout。
clearTimeout(timeout);
resolve(response);
},
reject: (error) => {
clearTimeout(timeout);
reject(error);
},
});
// JSONL serializer 会在单条 JSON 后追加换行作为消息边界。
stdin.write(serializeJsonLine({ ...command, id }));
});

收到响应后,handleLine 按 ID 找到对应表项并 resolve。上面的片段只突出 pending Map;正式实现还用 try/catch 包住 stdin.write,同步写入失败时会删除表项,并通过包装后的 reject 清理定时器。子进程退出或等待超时时也会 reject 未完成请求,避免 Promise 永久悬挂。

这里是 coding-agent 的本地 stdin/stdout JSONL RPC。它与下一章 packages/protocol 的二进制远程会话协议不是同一套协议。

文本最终也要编码成字节才能传输。JavaScript 使用 ArrayBuffer 保存一段原始二进制内存,再通过 typed array(类型化数组)或 DataView 读写其中的数据。

ArrayBuffer 只表示一段固定长度的内存,本身不规定每个字节怎样解释。Uint8Array 把这段内存视为一组 0 到 255 的无符号整数:

// Uint8Array 直接保存两个 UTF-8/ASCII 字节:0x50='P',0x69='i'。
const bytes = new Uint8Array([0x50, 0x69]);
// TextDecoder 把字节序列解释为字符串。
console.log(new TextDecoder().decode(bytes)); // Pi

Typed array 的索引适合逐个访问同一种数值。DataView 则适合读写多字节整数,并显式指定大端或小端:

// ArrayBuffer 只分配四字节原始内存,不规定解释方式。
const header = new ArrayBuffer(4);
// DataView 提供按偏移量读写多字节整数的 API。
const view = new DataView(header);
// offset=0,value=1024,false 表示按大端字节序写入。
view.setUint32(0, 1024, false); // false 表示大端
// 使用相同偏移和字节序才能读回 1024。
console.log(view.getUint32(0, false));

Uint8Array 本身是某段 ArrayBuffer 上的视图,并通过 buffer、byteOffset 和 byteLength 描述自己覆盖的内存范围。由现有 typed array 取得局部数据时,要区分共享视图与复制:

const source = new Uint8Array([1, 2, 3, 4]);
// subarray 创建共享同一底层 ArrayBuffer 的新视图。
const shared = source.subarray(1, 3);
// slice 把选中范围复制到新的底层内存。
const copied = source.slice(1, 3);
source[1] = 9;
console.log(shared[0]); // 9:观察到 source 的修改
console.log(copied[0]); // 2:副本不受影响

共享视图避免复制,但原视图的后续修改也会被观察到;需要独立所有权时才使用复制。上述 slice() 结论针对 Uint8Array;Node.js 的 Buffer.slice() 是历史兼容例外,它仍然共享底层内存,复制 Buffer 应使用 Buffer.from(...)。Pi 的分帧代码使用 subarray() 选择当前 chunk 的一段,再把它写入解码器自己管理的缓冲区。

字符串的 .length 不是 UTF-8 字节数。中文等字符编码后会占多个字节,协议长度必须根据编码后的 byteLength 计算。

网络 transport 按当前可用数据交付 chunk,协议则按自己的规则定义完整 frame。一次 chunk 可能只有半个 frame,也可能包含多个 frame。

长度前缀是一种常见分帧方式:先写固定大小的 header,header 中记录 payload 长度,后面紧跟 payload 字节。

function encodeFrame(payload: Uint8Array): Uint8Array {
// 为四字节 header 与 payload 一次性分配完整 frame。
const frame = new Uint8Array(4 + payload.byteLength);
const length = payload.byteLength;
// 从高位到低位写入四字节大端长度。
frame[0] = length >>> 24;
frame[1] = length >>> 16;
frame[2] = length >>> 8;
frame[3] = length;
// payload 从 offset 4 开始,紧跟在长度 header 后面。
frame.set(payload, 4);
return frame;
}

解码器必须保存上次尚未收完的 header 或 payload:

class FrameDecoder {
// 保存上一次 push 尚未组成完整 frame 的剩余字节。
private buffer = new Uint8Array();
push(chunk: Uint8Array): Uint8Array[] {
// 教学版先把旧剩余与新 chunk 合并成连续缓冲区。
const merged = new Uint8Array(
this.buffer.byteLength + chunk.byteLength,
);
merged.set(this.buffer);
merged.set(chunk, this.buffer.byteLength);
this.buffer = merged;
const frames: Uint8Array[] = [];
// 不足四字节时,连完整长度 header 都还没有。
while (this.buffer.byteLength >= 4) {
// 按大端顺序把四个字节还原为 payload 长度。
const payloadLength =
this.buffer[0]! * 0x1_000_000 +
this.buffer[1]! * 0x1_0000 +
this.buffer[2]! * 0x100 +
this.buffer[3]!;
const frameLength = 4 + payloadLength;
// header 已到但 payload 未收全:保留 buffer 等下一次 push。
if (this.buffer.byteLength < frameLength) break;
// 取出完整 payload,并移除已经消费的整个 frame。
frames.push(this.buffer.slice(4, frameLength));
this.buffer = this.buffer.slice(frameLength);
}
return frames;
}
}

一次 push 返回空数组,表示还没有完整 frame;返回多个元素,表示当前 chunk 补齐或包含了多条消息。真实协议还必须限制最大 frame 长度,避免恶意 header 触发无界内存分配。

【Pi】如何用长度前缀和 CBOR 传输远程会话消息

Section titled “【Pi】如何用长度前缀和 CBOR 传输远程会话消息”

packages/protocol/src/framing.ts 使用四字节大端长度前缀。正式 FrameDecoder 不会像上面的教学版本一样反复复制全部剩余数据,而是分开保存 header 和固定大小的 payload blocks,并检查最大帧长度。

Framing 只负责恢复 payload 边界。packages/protocol/src/codec.ts 还要继续完成两步:

  1. 使用 CBOR 把 payload 字节解码成 JavaScript 值;
  2. 使用 TypeBox schema 检查它是否是合法的 ClientMessage 或 ServerMessage。

CBOR 是 Concise Binary Object Representation,一种二进制序列化格式。完整接收路径是:

transport chunk
→ frame payload
→ CBOR value
→ validated ClientMessage / ServerMessage

不要把这条路径与前两章混在一起:pi-messages 模型接口使用 HTTP SSE,coding-agent 的本地 RPC 使用 stdin/stdout JSONL,packages/protocol 使用带长度前缀的 CBOR 二进制消息。三者都要处理 chunk 和缓冲,但运行时结构保证并不相同:当前 packages/protocol 会在 CBOR 解码后用 TypeBox schema 检查消息;pi-messages 与 coding-agent 的 JSONL 路径主要按预期类型解析 JSON,不等价于 schema 校验。

不需要记住所有语法。进入 Pi 的架构与 Agent 章节前,应能独立解释:

  1. import type 为什么不会形成运行时依赖,package exports 又限制了什么?
  2. 对象展开复制了什么,为什么它不等于深拷贝或不可变数据?
  3. 普通对象为什么可以满足 AgentTool,interface 又为什么不能校验模型 JSON?
  4. 判别联合怎样帮助处理 event,外部输入为什么仍需 runtime schema?
  5. Promise 的启动、完成和结果顺序为什么是三件事?
  6. AbortSignal 能传播什么,又不能撤销什么?
  7. EventStream、Web ReadableStream 和 Node Readable 为什么不能混用同一套心智模型?
  8. chunk、frame、decoded value、validated message 分别属于哪一层?

如果其中某一项解释不清,只回到对应章节和实验,不必重新通读全文。语言机制掌握后,可以进入Pi 当前架构;完成整条路线后,再用基本认知做综合验收。