Skip to content

7.3 自定义工具与斜杠命令 ​

本页分析版本earendil-works/pi@16787ad2026-09-21

本章解决什么问题:7.1 讲了扩展(Extension)文件如何被发现、加载并执行工厂函数;6.4 讲了工具(Tool)的三层类型、typebox 校验与内置工具的注册链。本章把这两条线接起来,回答一个具体问题:你在自己的文件里写的一个函数,是怎么变成模型工具清单里的一项、以及怎么变成一条能在斜杠自动补全里选中的命令的?前置知识:6.4 工具系统:定义、校验与执行、7.1 Extension 系统、2.4 泛型、class 与类型收窄。 学习目标:① 写出一个同时注册工具与斜杠命令的扩展文件;② 说清 registerTool 到「模型看得见的工具清单」之间每一环的函数名与位置,并指出扩展工具与内置工具在哪一步合流;③ 说清扩展命令与内置命令在执行与自动补全上遵循的是两套不同顺序;④ 分清没有 API Key 时哪些环节可以亲手验证、哪些不能。

建立直觉:注册台与排班表 ​

工厂函数拿到的那个 pi 对象,本质上是一张登记表的入口。工厂运行期间,pi.registerTool() 和 pi.registerCommand() 做的事情朴素得出乎意料:往当前扩展对象内部的两个 Map 里写一条记录,仅此而已。

真正让工具「被模型看见」、让命令「被斜杠菜单看见」的动作,发生在别的模块、别的时刻:

  • 工具要等 AgentSession 重建一次工具注册表,把扩展工具和内置工具合并成一张表,再挑出激活集赋给 agent.state.tools,才会随下一次请求下发给模型;
  • 命令要等交互模式重建一次自动补全提供者,才会出现在你按 / 时弹出的列表里。
📘 概念注册与生效的分离(registration vs. activation)
注册是把一条数据写进扩展对象的 Map,同步、廉价、随时可做;
生效是宿主把这些 Map 收拢起来重建运行时状态(工具注册表 / 自动补全列表 / 系统提示词)。
把两者分开的好处是:扩展作者不需要知道 AgentSession 内部长什么样,也不需要关心「现在是启动阶段还是已经跑起来了」——写 Map 永远合法。

打个比方:registerTool 像入职登记,_refreshToolRegistry 像排班表重排。登记完不一定马上上岗,但排班表每次重排都会把登记过的人算进去。

最小示例:一个文件、两种注册 ​

下面这个 dice.ts 是本章的贯穿例子:它注册一个 dice 工具(掷一个 N 面骰子)和一条 /hello 命令。它是一个完整可运行的扩展,不需要 package.json,不需要编译。

ts
// dice.ts
import { Type } from "@earendil-works/pi-ai";
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";

const diceTool = defineTool({
	name: "dice",
	label: "Dice",
	description: "Roll an N-sided die and return the rolled number.",
	promptSnippet: "dice: roll an N-sided die",
	parameters: Type.Object({
		sides: Type.Integer({ minimum: 2, maximum: 100, description: "Number of sides (2-100), e.g. 6" }),
	}),
	async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
		const value = 1 + Math.floor(Math.random() * params.sides);
		return {
			content: [{ type: "text", text: `d${params.sides} => ${value}` }],
			details: { sides: params.sides, value },
		};
	},
});

export default function diceExtension(pi: ExtensionAPI) {
	pi.registerTool(diceTool);
	pi.registerCommand("hello", {
		description: "Say hello from the dice extension",
		handler: async (args, ctx) => {
			ctx.ui.notify(`hello ${args.trim() || "world"}`, "info");
		},
	});
}

逐点对照源码:

  • Type.Object(...) 来自 typebox。6.4 说过,同一份 schema 有三个用途:给 TypeScript 推导 params 的类型、给运行时做参数校验、序列化成 JSON Schema 发给模型。自定义工具和内置工具用的是同一套写法。这里特意用 Type.Integer({ minimum: 2, maximum: 100 }),让“骰子面数必须是 2–100 的整数”在执行前就成为可验证契约;只写 Type.Number() 会放过 2.5、0 与负数,随后那行随机数公式仍会执行,得到语义上荒谬的结果。
  • defineTool 不是必需的,但值得一直用。它的实现(packages/coding-agent/src/core/extensions/types.ts:515-519)就是把参数原样返回,唯一作用是保住泛型(Generic)参数推断——注释里写得很直白:不套它而直接把对象赋给变量、或塞进 customTools 这类数组时,上下文类型会把 TParams 放宽成 unknown,execute 里的 params.sides 就丢了类型。
  • execute 有五个参数,第五个是 ExtensionContext(本例用不到,命名为 _ctx)。这是 coding-agent 层 ToolDefinition 与 agent 层 AgentTool 的关键差别之一。
  • promptSnippet 是可选的,它决定这个工具是否在默认系统提示词(System Prompt)的工具清单段(源码里叫 tools 段,渲染成 <tools>…</tools>)里占一行。不写也能被调用,只是模型少了一句提示。
  • details 要是 JSON 兼容的值。工具结果最终会变成会话里的一条 toolResult 消息,pi-ai 把这类消息的 details 限定为 JSON 可表示的值(packages/ai/src/types.ts:539-551 的条件类型 ToolResultMessage),agent 层 AgentToolResult 的 details 默认类型也是 JsonValue(packages/agent/src/types.ts:420-432)。本例的 { sides, value } 是两个数字,天然满足;塞 Map、Date、函数之类进去就不行了。
  • 命令 handler 有两个参数:args 是命令名之后的整段原始字符串(不做分词),ctx 是 ExtensionCommandContext。
earendil-works/pi@16787ad第 455–504 行在 GitHub 查看 ↗
自定义工具的完整形状:name / label / description、可选的 promptSnippet 与 promptGuidelines、typebox 的 parameters、可选的 constrainedSampling、五参数的 execute,以及 TUI 用的 renderCall / renderResult。

不启动 Agent 也能确认这个文件注册了什么。下面这段是直接调用扩展加载器、打印扩展对象里两张 Map 的真实运行输出(作者本机;完整脚本与复现方式见本章实践任务):

text
extension: dice.ts
  tools   : [ 'dice' ]
  commands: [ 'hello' ]
  schema  : {"type":"object","required":["sides"],"properties":{"sides":{"type":"integer","minimum":2,"maximum":100,"description":"Number of sides (2-100), e.g. 6"}}}
errors  : []

注意最后那行 schema:Type.Object 产出的东西打印出来就是一份普通 JSON Schema。它之所以能直接塞进 Anthropic 请求体的 input_schema,正是因为 typebox 的 schema 本身就是合法 JSON Schema(6.4 已详述)。

回到 Pi 源码(一):registerTool 只写了一张 Map ​

先看注册这一端。pi 对象由 createExtensionAPI 构造(loader.ts:228),registerTool 的实现只有十来行:

earendil-works/pi@16787ad第 273–285 行在 GitHub 查看 ↗
registerTool:先确认扩展仍然有效,再检查参数 schema 必须是一个对象,然后把 definition 连同来源信息写进 extension.tools,最后触发一次 refreshTools。
ts
// packages/coding-agent/src/core/extensions/loader.ts:273-285
registerTool(tool: ToolDefinition): void {
	assertActive();
	if (typeof tool.parameters !== "object" || tool.parameters === null || Array.isArray(tool.parameters)) {
		throw new Error(
			`Tool "${tool.name}" registered by extension "${extension.path}" must define an object parameter schema.`,
		);
	}
	extension.tools.set(tool.name, {
		definition: tool,
		sourceInfo: extension.sourceInfo,
	});
	runtime.refreshTools();
},

四个细节值得停一下:

  1. assertActive():会话(Session)被替换或 /reload 之后,旧的 pi 对象一律作废,任何调用都抛错(loader.ts:238-243,失效时写入的文案见 loader.ts:185-192)。这是防止旧扩展闭包偷偷往新会话里塞东西的失效保护。
  2. 没有参数 schema 的工具在注册时就被拒绝。parameters 不是对象(漏写、写成 null 或数组)会当场抛错;如果这发生在工厂函数里,整个扩展按「工厂抛错」处理、加载失败。测试 packages/coding-agent/test/extensions-runner.test.ts:402(rejects extension tools without a parameter schema)钉住了这条。把错误挡在注册期,是为了不让一个坏工具声明混进后面每一次 provider 请求里。
  3. key 是 tool.name:同一个扩展里注册两次同名工具,后者覆盖前者。跨扩展的同名冲突是另一套规则,见下一节。
  4. runtime.refreshTools():这是「注册」通向「生效」的唯一开关。它在扩展加载阶段是一个空函数(loader.ts:175-176),等 AgentSession._bindExtensionCore 把真实实现装上之后(agent-session.ts:3085),它才指向 _refreshToolRegistry。
🌱 初学者提示为什么加载阶段的 refreshTools 是空函数
扩展工厂在 AgentSession 完全构造好之前就会被执行,此时还没有工具注册表可刷新,所以先装一个 no-op。等 AgentSession 建好,它会主动做一次完整刷新,把工厂阶段注册的所有工具一并纳入——因此「工厂里注册」和「运行时注册」最终走的是同一条路,只是前者少了一次即时刷新。官方文档也明确说明:pi.registerTool() 在加载期与启动后都可用,新工具会「立即在同一会话中刷新」、无需 /reload(来源文件 packages/coding-agent/docs/extensions.md:1472)。

回到 Pi 源码(二):从两张 Map 到模型的工具清单 ​

刷新的第一步是把所有扩展的工具收拢成一个数组:

earendil-works/pi@16787ad第 586–597 行在 GitHub 查看 ↗
按扩展加载顺序遍历,同名工具「先注册者胜」——这与后面 AgentSession 里「扩展工具覆盖内置工具」的方向恰好相反,要分清是哪一层的冲突。

然后是本章最核心的一个函数,它一口气干了五件事:

earendil-works/pi@16787ad第 3144–3210 行在 GitHub 查看 ↗
重建工具注册表:收集扩展工具与 SDK 工具 → 以内置定义打底、用自定义定义覆盖同名项 → 重算 promptSnippet / promptGuidelines → 统一包装成 AgentTool → 生成最终 _toolRegistry。
ts
// packages/coding-agent/src/core/agent-session.ts:3152-3177(有省略)
const registeredTools = this._extensionRunner.getAllRegisteredTools();
const allCustomTools = [ /* …(省略:并入 SDK 传入的 customTools 并按白/黑名单过滤)… */ ];
const definitionRegistry = new Map<string, ToolDefinitionEntry>(
	/* …(省略:以八个内置工具的 definition 打底)… */
);
for (const tool of allCustomTools) {
	definitionRegistry.set(tool.definition.name, {
		definition: tool.definition,
		sourceInfo: tool.sourceInfo,
	});
}
this._toolDefinitions = definitionRegistry;

注意 for 循环里的 set:内置定义先进 Map,自定义定义后进。同名时后写入的赢,这就是「扩展可以覆盖内置工具」的全部实现——仅仅是一个 Map 写入顺序。官方文档专门讲了这个能力,并说明交互模式会为此打一条警告(来源文件 packages/coding-agent/docs/extensions.md:2185);仓库里的 packages/coding-agent/examples/extensions/tool-override.ts 用它把内置 read 换成了带审计日志和路径黑名单的版本。

接着是 prompt 元数据与包装:

  • 3178-3193:把每个 definition 的 promptSnippet / promptGuidelines 收进两张 Map,之后 _rebuildSystemPrompt(agent-session.ts:1371-1395)把它们连同激活工具名(:1372 先按注册表过滤一遍名字)交给 normalizeBuildSystemPromptOptions,得到结构化的提示词选项;真正拼段落的是 buildSystemPromptSections,它只为激活且带 snippet 的工具写一行(packages/coding-agent/src/core/system-prompt.ts:148-150)。没写 promptSnippet 的自定义工具不会出现在 tools 段里——测试 packages/coding-agent/test/agent-session-dynamic-tools.test.ts:211("keeps custom tools active but omits them from available tools when promptSnippet is not provided")固化了这条行为。
  • 3195:wrapRegisteredTools 把五参数的 ToolDefinition 适配成 agent core 认识的四参数 AgentTool。
earendil-works/pi@16787ad第 17–19 行在 GitHub 查看 ↗
扩展工具的包装只剩一行:交给 wrapToolDefinition,并把 runner.createContext 作为补第五个参数 ExtensionContext 的工厂函数传进去。文件头注释写明,tool_call / tool_result 的拦截不在这里,而在 AgentSession 通过 agent-core 钩子完成。

wrapToolDefinition 的实现在 packages/coding-agent/src/core/tools/tool-definition-wrapper.ts:5-20,6.4 已经拆过:它靠一个 ctxFactory 闭包在调用时才创建 ExtensionContext,因此上下文(Context)永远是「此刻的会话」,而不是注册时的快照。

最后两步:

ts
// packages/coding-agent/src/core/agent-session.ts:3206-3210
const toolRegistry = new Map(wrappedBuiltInTools.map((tool) => [tool.name, tool]));
for (const tool of wrappedExtensionTools as AgentTool[]) {
	toolRegistry.set(tool.name, tool);
}
this._toolRegistry = toolRegistry;

以及 3226-3232 的一段:如果调用方没有显式指定激活集,注册表里新出现的名字会被自动加进激活集。这解释了为什么你注册一个工具之后不用手动 setActiveTools 就能用。收尾是 setActiveToolsByName(agent-session.ts:1279-1291),它把选中的 AgentTool 数组赋给 this.agent.state.tools 并更新提示词选项。

不过 agent.state.tools 只是「运行时能执行什么」。模型看得见哪些工具,是由会话记录里的系统消息声明的:agent-core 在每次向 provider 发请求之前,把可执行工具集与会话里已经声明过的工具做一次差分,差出来的部分写成一条带 toolsAdded / toolsRemoved 的系统消息插进上下文(declareToolChanges,packages/agent/src/agent-loop.ts:332-362;注释原话是 “context.tools is what the runtime can execute; the transcript's system messages declare what the model may call”)。provider 再从这些系统消息里读出当前工具清单。到这一步,dice 才真正进入下一次请求的 payload。这套「工具声明写进会话记录」的设计,好处是恢复会话、切换分支之后工具状态也能按记录重放出来,细节见 6.1 pi-ai 与 6.3 pi-agent-core。

至此,完整链路是:

pi.registerTool(loader.ts:273)→ runtime.refreshTools(loader.ts:284)→ AgentSession._refreshToolRegistry(agent-session.ts:3144)→ ExtensionRunner.getAllRegisteredTools(runner.ts:587)→ wrapRegisteredTools(wrapper.ts:25)→ wrapToolDefinition(tool-definition-wrapper.ts:5)→ setActiveToolsByName(agent-session.ts:1279)→ agent.state.tools →(下一次请求前)declareToolChanges(agent-loop.ts:332)。

回到 Pi 源码(三):进了清单之后,就是同等待遇 ​

自定义工具与内置工具的差别,到 _toolRegistry 那一步就结束了。往后每一步都不区分来源,原因很朴素——后面的代码根本不知道「来源」这个概念。

查找与校验发生在 Agent Loop(Agent 循环)里,它拿到的只是一个 AgentTool 数组:

earendil-works/pi@16787ad第 703–721 行在 GitHub 查看 ↗
按名字在 context.tools 里 find,找不到就直接回一个 isError 的结果;找到则先跑可选的 prepareArguments,再做 typebox 校验。这里没有任何「内置 / 扩展」的分支。
ts
// packages/agent/src/agent-loop.ts:710-721(有省略)
const tool = currentContext.tools?.find((t) => t.name === toolCall.name);
if (!tool) {
	return { kind: "immediate", result: createErrorToolResult(`Tool ${toolCall.name} not found`), isError: true };
}
try {
	const preparedToolCall = prepareToolCallArguments(tool, toolCall);
	const validatedArgs = validateToolArguments(tool, preparedToolCall);
	// …(省略:beforeToolCall hook 与 abort 检查,722-756 行)

于是 dice 自动获得三样东西:

  1. 参数校验:validateToolArguments(packages/ai/src/utils/validation.ts:317-350)先把非必填字段上的 null 删掉(normalizeOptionalNulls,validation.ts:240-269),再 Value.Convert 做类型强转,最后编译 schema 检查。模型若把 sides 传成字符串 "6",会被强转成数字;传成 "六" 则校验失败,错误消息作为 isError 的工具结果(Tool Result)回给模型,模型可以据此自我修正。这套机制对内置工具和 dice 完全一致。
  2. 拦截钩子:AgentSession._installAgentToolHooks(agent-session.ts:529-585)把 loop 的 beforeToolCall / afterToolCall 接到扩展事件 tool_call / tool_result 上。任何扩展都能拦截 dice,也能改写它的结果——包括注册 dice 的那个扩展自己。拦截时返回 { block: true, reason, terminate: true },还能在「这一批工具调用全都被这样拦下」时让 Agent 直接收尾、不再把结果送回模型(机制见 7.1 的 tool_call 一节);工具自己在 execute 的返回值里写 terminate: true 也是同一个开关(packages/agent/src/types.ts:427-431)。
  3. 执行观察事件:tool_execution_start / update / end 由 _emitExtensionEvent(agent-session.ts:1062-1139)统一映射,同样不分来源。
🌱 初学者提示约束采样:在生成阶段就把参数管住
上面的校验发生在模型**已经**生成参数之后。有些 provider 还支持「约束采样(Constrained Sampling)」:请求里把工具 schema 标成严格模式,模型在逐 token 生成参数时就只能产出符合 schema 的 JSON。ToolDefinition 用可选字段 constrainedSampling 请求这种能力(core/extensions/types.ts:468-469),它一路经 wrapToolDefinition(tool-definition-wrapper.ts:14)和 toToolDeclaration(packages/ai/src/utils/transcript.ts:123-130)带进工具声明。内置的 read、bash、edit、write 都声明了 { type: "json_schema", strict: "prefer" }(如 core/tools/read.ts:80、core/tools/bash.ts:243)。
"prefer" 的意思是「能用就用」:只有模型声明支持严格模式、而且 schema 能改写成严格子集时才启用,否则静默退回普通请求;"require" 则在做不到时直接报错(packages/ai/src/api/constrained-sampling.ts:208-228)。严格子集要求每个字段都列进 required,于是原本可选的字段会被改写成「原类型或 null」(constrained-sampling.ts:106-113)——这正是前面 normalizeOptionalNulls 要先把可选字段上的 null 删掉的原因。
本章的 dice 没写这个字段,等同于不请求约束采样;写成 false 也是同样效果。

如果“类型强转 → Schema 校验 → 工具执行 → 错误回填”这几步还混在一起,可以在下面直接改一份模型参数。尤其对比“错误类型”和“越界路径”:前者被结构契约拦住,后者必须由工具自己的语义安全规则拦住;defineTool 的类型推断不能替代任何一关。

参数校验沙箱已就绪。

Tool Call 参数校验沙箱编辑模型生成的 arguments,观察失败在哪一关、如何回填

演示契约:read_lines(path: string, maxLines: integer 1–200, includeLineNumbers?: boolean),并禁止额外字段。

0 / 6 关完成自动播放仅推进一次;系统偏好减少动态效果时会立即完成。

当前关卡:文本变成运行时值

最终 Tool Result尚未运行
尚未生成 Tool Result。

唯一的差别在类型层,不在运行时:八个内置工具各有专用的事件类型(BashToolCallEvent、PowerShellToolCallEvent、ReadToolCallEvent 等,core/extensions/types.ts:978-1016),input 字段是精确类型;其他工具一律落到 CustomToolCallEvent(types.ts:1018-1021),input 是 Record<string, unknown>。也就是说,写 tool_call handler 处理 dice 时,参数类型要你自己收窄。

图 7.3-1 自定义工具从注册到被模型调用
从上往下读。上半段是「注册 → 生效」,下半段是「模型请求 → 本地执行」。请重点关注 F 与 K 两个节点:F 是扩展工具与内置工具合流的位置,此后每一步都不再区分来源;K 及其之后的所有节点,内置工具走的是同一条路径。图中每个节点都标注了真实源码位置。

走读仓库里的三个真实例子 ​

本章的 dice.ts 是为教学裁剪出来的。仓库 packages/coding-agent/examples/extensions/ 下有 68 个顶层 .ts 扩展示例(另有 9 个子目录形态的示例与一个 README,源码事实,本机 ls 计数),其中 15 个调用了 registerTool。挑三个,正好覆盖上面这条链路的三种典型用法。

① hello.ts:官方的最小注册例子 ​

ts
// packages/coding-agent/examples/extensions/hello.ts:8-26
const helloTool = defineTool({
	name: "hello",
	label: "Hello",
	description: "A simple greeting tool",
	parameters: Type.Object({
		name: Type.String({ description: "Name to greet" }),
	}),

	async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
		return {
			content: [{ type: "text", text: `Hello, ${params.name}!` }],
			details: { greeted: params.name },
		};
	},
});

export default function (pi: ExtensionAPI) {
	pi.registerTool(helloTool);
}

它和本章的 dice.ts 结构几乎一致,唯一的实质差别是它没有写 promptSnippet。按前面 _refreshToolRegistry 的 3178-3185 与 buildSystemPromptSections 的取用逻辑,这意味着 hello 工具照样在 _toolRegistry 里、照样会被激活、照样能被模型调用,但不会出现在系统提示词的 tools 段里——模型只能从工具声明本身(name/description/schema)知道它的存在。这正是测试 packages/coding-agent/test/agent-session-dynamic-tools.test.ts:211 固化的那条行为。

② dynamic-tools.ts:证明「注册随时可做」 ​

ts
// packages/coding-agent/examples/extensions/dynamic-tools.ts:27-54(有省略)
const registerEchoTool = (name: string, label: string, prefix: string): boolean => {
	if (registeredToolNames.has(name)) {
		return false;
	}
	registeredToolNames.add(name);
	pi.registerTool({
		name,
		label,
		description: `Echo a message with prefix: ${prefix}`,
		promptSnippet: `Echo back user-provided text with ${prefix.trim()} prefix`,
		promptGuidelines: ["Use echo_session when the user asks for exact echo output."],
		parameters: ECHO_PARAMS,
		// …(省略:execute 返回 content 与 details,40-45 行)
	});
	return true;
};

pi.on("session_start", (_event, ctx) => {
	registerEchoTool("echo_session", "Echo Session", "[session] ");
	ctx.ui.notify("Registered dynamic tool: echo_session", "info");
});

这个例子的注册不在工厂函数体里:一次在 session_start 事件 handler 里(:51-54),一次在 /add-echo-tool 命令 handler 里(:56-73)。两处都发生在 AgentSession 已经建好之后,因此 runtime.refreshTools() 不再是空函数,每次注册都会立刻触发一整轮注册表重建——新工具当场进入激活集(走的正是 agent-session.ts:3226-3232 那个「注册表里新出现的名字自动激活」分支),无需 /reload。

它还示范了 promptGuidelines:这些条目会平铺进系统提示词的规则段(官方文档叫它 Guidelines 段,源码里是 rules 段,由 buildRules 生成,system-prompt.ts:81-118)。官方文档特意提醒,因为拼接时不加工具名前缀,每条 guideline 必须自己点名工具,不能写 "Use this tool when…"(来源文件 packages/coding-agent/docs/extensions.md:1478)。这也是为什么例子里写的是 "Use echo_session when…"。另外,它的 Type 直接 import { Type } from "typebox",而 hello.ts 从 @earendil-works/pi-ai 取——两种写法都能跑,原因在 7.1 讲过的 jiti 虚拟模块表。

③ tool-override.ts:用同名工具替换内置 read ​

这个例子把「后写入者赢」用到了极致:它注册的工具名就叫 read(:70 的行内注释写着 "Same name as built-in - this will override it"),label 改成 read (audited)。execute 里先用一组正则(.env、secrets.*、credentials.*、.ssh/、.aws/、.gnupg/)判断是否属于敏感路径,命中就写一条 BLOCKED 日志并直接返回拒绝文本(:81-92),放行的路径则写 ALLOWED 日志后继续(:95)。文件头注释把用途列得很清楚:审计、访问控制、路由到远程系统(例如 SSH)。

有一处值得留意的文档与实现不一致:文件头注释说放行后会「委托给原始 read 实现」,但代码实际是自己用 readFile 重写了一个简化版(:97-113,行内注释 "Perform the actual read (simplified implementation)",只做了 offset/limit 与 50KB 截断,没有图片分支、没有 truncateHead 的续读提示)。这提醒我们:覆盖内置工具意味着你要为这个名字的全部行为负责。官方文档把底线说得很硬:「你的实现必须匹配完全一致的结果形状,包括 details 的类型」,因为界面与会话逻辑都依赖这些形状做渲染与状态跟踪(来源文件 packages/coding-agent/docs/extensions.md:2204)。

值得单独记住的是官方文档补充的两条规则(来源文件 packages/coding-agent/docs/extensions.md:2200 与 :2202):渲染槽按 renderCall / renderResult 分别继承——覆盖工具不写渲染函数就自动沿用内置渲染器,所以给内置工具加一层日志不必重写 TUI;但 promptSnippet / promptGuidelines 不会被继承,覆盖时想保留那几句提示必须自己再写一遍。同样不会被继承的还有 constrainedSampling:注册表里同名项是整条 definition 被替换(agent-session.ts:3171-3176),而工具声明只取覆盖者自己的字段(tool-definition-wrapper.ts:14)。tool-override.ts 没写这个字段,所以换上它之后,read 就不再请求约束采样了(从源码结构看;官方 CHANGELOG 也提到,想关掉内置工具的约束采样,可以重新注册一份带 constrainedSampling: false 的定义)。

回到 Pi 源码(四):斜杠命令的四个来源、两套顺序 ​

斜杠命令比工具更容易让人误解,因为「内置命令」和「扩展命令」实际上是两套不同的东西。

内置命令只是一张纯数据清单,共 24 条:

earendil-works/pi@16787ad第 19–44 行在 GitHub 查看 ↗
24 条内置命令,每条只有 name / description / 可选的 argumentHint 三个字段——没有 handler。它们的行为由交互模式自己实现,不走扩展命令那套注册机制。

同一个文件里的 SlashCommandInfo.source 只有三个取值:"extension" | "prompt" | "skill"(slash-commands.ts:4)。内置命令不在这个体系里——这是理解后面所有差异的关键。

扩展命令则是真正带 handler 的注册项:

earendil-works/pi@16787ad第 287–294 行在 GitHub 查看 ↗
registerCommand:把 name、来源信息与调用方给的 options(description / getArgumentCompletions / handler)合并成一条记录写进 extension.commands。注意它不调用 refreshTools——命令的生效走另一条路。

于是同一条 /xxx 输入,在两个不同场合遵循两套不同顺序。

顺序一:执行时,扩展命令最先被拦截 ​

用户按下回车后,AgentSession.prompt() 做的第一件事就是查扩展命令:

earendil-works/pi@16787ad第 1766–1790 行在 GitHub 查看 ↗
以第一个空格切出命令名与参数,查 runner.getCommand,命中就用 createCommandContext 构造带会话控制能力的 ctx 并执行;handler 抛错也算「已处理」,只是额外转成一条扩展错误。

调用点在 agent-session.ts:1617-1624,位置非常靠前——在 input 事件之前,也在 skill 展开与 prompt 模板展开之前。命中扩展命令就直接 return,既不发给模型,也不触发后面任何一环。

顺序二:自动补全时,内置命令优先且扩展同名项被跳过 ​

自动补全列表由交互模式重建,把四类来源拼在一起:

earendil-works/pi@16787ad第 679–777 行在 GitHub 查看 ↗
自动补全的四路合并:24 条内置命令 → prompt 模板 → 扩展命令(先过滤掉与内置同名者)→ skill 命令(形如 skill:name)。最后交给 CombinedAutocompleteProvider。
ts
// packages/coding-agent/src/modes/interactive/interactive-mode.ts:748-756
const builtinCommandNames = new Set(slashCommands.map((c) => c.name));
const extensionCommands: SlashCommand[] = this.session.extensionRunner
	.getRegisteredCommands()
	.filter((cmd) => !builtinCommandNames.has(cmd.name))
	.map((cmd) => ({
		name: cmd.invocationName,
		description: this.prefixAutocompleteDescription(cmd.description, cmd.sourceInfo),
		getArgumentCompletions: cmd.getArgumentCompletions,
	}));

这里有一处容易踩的不一致:如果扩展注册了一条名叫 model 的命令,执行时它会抢在内置 /model 之前被执行(顺序一),但在自动补全里它被过滤掉、看不见(顺序二)。交互模式为此专门产出一条警告诊断(interactive-mode.ts:664-677,文案 "conflicts with built-in interactive command. Skipping in autocomplete.")。

⚠️ 常见误解以为扩展命令和内置命令共用一套优先级
它们共用的只是「以 / 开头」这个语法。执行优先级由 AgentSession 决定(扩展命令最先),可见性由交互模式决定(内置命令优先、扩展同名项被跳过)。一种看法是:这样保证了内置命令的名字不会被扩展悄悄劫持后还伪装成原样;代价是同名扩展命令会进入一个「能执行但看不见」的尴尬状态,只能靠诊断信息提醒作者改名。

多个扩展注册同名命令 ​

跨扩展重名不会互相覆盖,而是按加载顺序生成 name:1、name:2 这样的调用名(resolveRegisteredCommands,runner.ts:739-773),查找严格按调用名进行(getCommand,runner.ts:788-790)。官方文档给的例子是 /review:1 与 /review:2(来源文件 packages/coding-agent/docs/extensions.md:1632)。测试 packages/coding-agent/test/extensions-runner.test.ts:510("suffixes duplicate extension commands in insertion order")固化了这一行为。

图 7.3-2 斜杠命令的两条路径:显示与执行
上半部分是「显示」路径,由交互模式驱动;下半部分是「执行」路径,由 AgentSession 驱动。请关注两点:一是这两条路径查的是同一份数据(getRegisteredCommands 与 getCommand 都基于 resolveRegisteredCommands),但过滤规则不同;二是执行路径的最后一步直接返回,命令不会变成一条发给模型的消息。

一个数量上的旁证 ​

本书的真实采集 research/cli-captures/pi-tui-slash.txt(v0.87.0,工作目录为 Pi 仓库根)里,按下 / 后补全列表的计数器显示 (1/38);同一次运行的启动横幅(pi-tui-main.txt)列出了 6 个 prompt 模板与 5 个 skill。本书把这个列表逐条翻完(真实采集,research/cli-captures/pi-tui-slash-all.txt):前 24 条是内置命令(源码事实,slash-commands.ts:19-44),接着 6 条 prompt 模板(/cl … /wr),然后 3 条扩展命令,最后 5 条 skill: 命令。3 条扩展命令里,/ir 与 /tui 来自仓库 .pi/extensions/ 下的 import-repro.ts 与 redraws.ts;/llama 则来自 Pi 自带的一个隐藏扩展——builtInExtensions 里只有它一个(packages/coding-agent/src/extensions/index.ts:4,hidden: true,所以不出现在启动横幅的 [Extensions] 里)。这个数字关系正好对应上面那次四路合并:24 内置 + 6 模板 + 3 扩展命令 + 5 skill = 38。

实践任务 ​

🛠 实践任务写一个 dice 扩展,让 /hello 出现在斜杠补全里

目标:亲手走通「注册 → 可见」这一段,并划清「哪些环节没有 API Key 也能验证」的边界。全程只读 Pi 源码,不要修改 _sources/pi 里的任何文件。

前提:已按 4.1 把 Pi 源码放在 _sources/pi 并执行过 npm install。下文用 <PI> 代表该目录的绝对路径,用 <LAB> 代表你新建的实验目录(放在 Pi 仓库之外)。

步骤 1:建实验目录并写扩展。把本章「最小示例」那段 dice.ts 原样保存为 <LAB>/dice.ts。

mkdir -p <LAB>
# 然后把 dice.ts 写进这个目录

步骤 2(不需要终端交互,最容易验证):只加载、不启动。在 <LAB> 新建 probe.ts:

text
// probe.ts —— 不启动 Agent,只加载一个扩展并打印它注册了什么
import { basename } from "node:path";
import { loadExtensions } from "<PI>/packages/coding-agent/src/core/extensions/loader.ts";

const result = await loadExtensions([process.argv[2]], process.cwd());
for (const ext of result.extensions) {
	console.log("extension:", basename(ext.path));
	console.log("  tools   :", [...ext.tools.keys()]);
	console.log("  commands:", [...ext.commands.keys()]);
	const dice = ext.tools.get("dice");
	if (dice) console.log("  schema  :", JSON.stringify(dice.definition.parameters));
}
console.log("errors  :", result.errors);

在 Pi 仓库根目录运行(必须带 --tsconfig,否则解析不到工作区路径映射):

cd <PI>
node_modules/.bin/tsx --tsconfig tsconfig.json <LAB>/probe.ts <LAB>/dice.ts

预期现象(作者本机真实运行输出):

extension: dice.ts
  tools   : [ 'dice' ]
  commands: [ 'hello' ]
  schema  : {"type":"object","required":["sides"],"properties":{"sides":{"type":"integer","minimum":2,"maximum":100,"description":"Number of sides (2-100), e.g. 6"}}}
errors  : []

这一步证明了三件事:工厂函数被执行了;两次注册分别落进了 extension.tools 与 extension.commands;typebox schema 就是一份普通 JSON Schema。

步骤 3:启动界面,在斜杠补全里找到它。这一步需要一个真实终端(TTY)。在 <LAB> 目录里执行:

<PI>/pi-test.sh --no-env -e <LAB>/dice.ts

--no-env 会清空所有 API Key 环境变量(见 pi-test.sh 脚本本身),确保你验证的确实是「无 Key 也能看到」。已经全局安装了 pi 的读者可以直接用 pi -e <LAB>/dice.ts(-e 的定义在 packages/coding-agent/src/cli/args.ts:166)。界面起来后输入 /hel。

预期现象:启动横幅的 [Extensions] 一节列出 dice.ts;顶部出现 Warning: No models available(正常,因为没有 Key);输入 /hel 后补全列表里出现 hello。作者在伪终端里做的真实采集见 research/cli-captures/pi-tui-custom-command.txt,关键两段是:

text
[Extensions]
  dice.ts

→ hello       [t] Say hello from the dice extension
  changelog   Show changelog entries

[t] 是来源标签,t = temporary:用 -e 加载的扩展被显式标成 { source: "cli", scope: "temporary" }(packages/coding-agent/src/core/resource-loader.ts:438),既不属于 user 作用域也不属于 project 作用域;标签由作用域首字母生成(interactive-mode.ts:636:user→u、project→p、其余→t,函数 getAutocompleteSourceTag 见 :631-654)。changelog 一起出现,是因为模糊匹配也命中了 hel。选中 hello 回车,会看到 ctx.ui.notify 弹出的提示。

边界(务必读):没有 API Key 时,可以验证「工具与命令被注册」「命令出现在补全里」「命令 handler 能执行」;无法验证「模型真的调用了 dice」——那需要一次真实的模型请求,而请求需要 Key。对照图 7.3-1:上半段(注册 → 生效)你能完整跑通,下半段(模型返回 toolCall → 本地执行)必须有 Key 才能观察。

如何判断成功:① 步骤 2 打印出 tools: [ 'dice' ] 与 commands: [ 'hello' ],且 errors: [];② 步骤 3 在补全里看到 hello;③ 你能回答:把命令名从 hello 改成 model 后,为什么补全列表里找不到它、但它仍然会被执行(答案在 interactive-mode.ts:748-751 与 agent-session.ts:1617-1624)。

常见错误:

  • 忘了 --tsconfig tsconfig.json:tsx 解析不到工作区路径映射,import 直接失败。
  • tsx 报 Top-level await is currently not supported with the "cjs" output format:probe.ts 用了顶层 await,而 <LAB> 及其上级目录里没有声明 ES 模块的 package.json,tsx 就按 CommonJS 转译它。在 <LAB> 放一个内容为 {"type":"module"} 的 package.json 即可(本书复核时在 Node 26 上实测遇到,按此解决)。
  • 在 <LAB> 目录里跑 tsx:那里没有 node_modules,找不到 tsx 与 typebox。命令必须在 Pi 仓库根目录执行。
  • 扩展文件忘了 export default,或默认导出的不是函数:加载器判定为无效扩展并报 "Extension does not export a valid factory function"(loader.ts:501-505、:569-571),在 errors 数组里能看到。
  • 把 dice.ts 放进项目的 .pi/extensions/ 却发现没加载:项目本地扩展受信任门控,未信任的项目不会加载它(packages/coding-agent/src/core/package-manager.ts:2417-2425)。用 -e 显式指定可以绕开这个问题。
  • 用管道或重定向启动(如 pi -e ... | cat):标准输出不是 TTY 会切到 print 模式,根本没有补全界面(见 [4.4](/pi-overview/where-to-start) 的 resolveAppMode)。

对应源码位置:packages/coding-agent/src/core/extensions/loader.ts:273-285(registerTool)、:287-294(registerCommand)、:639-646(loadExtensions);packages/coding-agent/src/core/agent-session.ts:3144-3235(工具注册表重建)与 :1766-1790(命令执行);packages/coding-agent/src/modes/interactive/interactive-mode.ts:679-777(补全列表合并)。相关测试:packages/coding-agent/test/agent-session-dynamic-tools.test.ts(作者本机真实运行结果为 Test Files 1 passed (1) / Tests 4 passed (4)),可用 cd <PI>/packages/coding-agent && ../../node_modules/.bin/vitest run test/agent-session-dynamic-tools.test.ts 复现。

本章小结 ​

  • 注册与生效是分离的:registerTool / registerCommand 只往扩展对象的 Map 里写一条记录(loader.ts:273、:287),真正接入运行时的是 AgentSession._refreshToolRegistry(工具)与交互模式的 createBaseAutocompleteProvider(命令可见性)。
  • 自定义工具的完整链路:pi.registerTool → runtime.refreshTools → _refreshToolRegistry → getAllRegisteredTools(跨扩展同名先注册者胜)→ 内置定义打底、自定义定义后写入覆盖同名 → wrapRegisteredTools / wrapToolDefinition → setActiveToolsByName → agent.state.tools → 请求前由 declareToolChanges 写成系统消息里的工具声明。
  • 进入注册表之后就是同等待遇:查找、typebox 校验(agent-loop.ts:710-721)、tool_call / tool_result 拦截、执行观察事件,全部不区分来源。唯一差别在类型层——八个内置工具有专用事件类型,其余落到 CustomToolCallEvent。
  • 斜杠命令有两套顺序:执行时扩展命令最先被拦截(agent-session.ts:1617),显示时内置命令优先、同名扩展命令被跳过并产出警告诊断(interactive-mode.ts:748-751、:664-677)。跨扩展重名则消歧为 name:1 / name:2。
  • 24 条内置命令只是一张纯数据清单,没有 handler;真实采集里补全列表的 38 条,正是「内置 + prompt 模板 + skill + 扩展命令」四路合并的结果。
  • 仓库示例把三种用法摆得很清楚:hello.ts(最小注册,且故意不写 promptSnippet)、dynamic-tools.ts(在 session_start 与命令 handler 里动态注册,验证「刷新即生效」)、tool-override.ts(同名覆盖内置 read,也暴露了「覆盖后要为全部行为负责」的代价)。
  • 关键术语:ToolDefinition、defineTool、promptSnippet、工具注册表(tool registry)、invocationName(调用名)、四路合并的自动补全。
  • 关键源码索引:core/extensions/types.ts:455-504(ToolDefinition)、:508-519(defineTool)、:1425-1428(registerTool 声明)、:1434-1435(registerCommand 声明)、:1018-1021(CustomToolCallEvent);core/extensions/loader.ts:273-294;core/extensions/runner.ts:586-597、:739-773、:788-790;core/extensions/wrapper.ts:17-27;core/tools/tool-definition-wrapper.ts:5-20;core/agent-session.ts:1617-1624、:1766-1790、:3144-3235;core/slash-commands.ts:4-44;modes/interactive/interactive-mode.ts:664-677、:679-777;packages/agent/src/agent-loop.ts:332-362、:703-771;packages/ai/src/api/constrained-sampling.ts:208-228;示例 packages/coding-agent/examples/extensions/hello.ts、dynamic-tools.ts、tool-override.ts。测试:test/extensions-runner.test.ts:377、:402、:427、:510,test/agent-session-dynamic-tools.test.ts:99、:211。
  • 自测问题:① 两个扩展都注册了名为 search 的工具,最终生效的是哪一个?如果其中一个改成注册名为 read 的工具呢(提示:这是两条方向相反的规则)。② 扩展注册了一条 /compact 命令,用户输入 /compact 会发生什么?他在补全列表里能看到它吗?③ 一个自定义工具不写 promptSnippet,模型还能调用它吗?少了什么?
  • 下一章:7.4 自定义 Provider——把「扩展能改的东西」从工具与命令推进到模型服务提供方本身。
  • 尚未展开的内容:renderCall / renderResult 的自定义 TUI 渲染(涉及 pi-tui 的组件模型,见 6.9);executionMode: "sequential" 对并行批次的影响(6.4 已讲机制,扩展工具同样适用);--no-builtin-tools 与工具白/黑名单在 _refreshToolRegistry 里的过滤分支;以及命令 handler 独有的 ExtensionCommandContext 会话控制能力(newSession / fork / navigateTree),它们属于 7.1 的范围。
✅ 自测问题参考答案先自己回答,再点开对照
  1. 两个扩展都注册 search:先注册者胜——getAllRegisteredTools 在跨扩展同名时保留第一个,后来的被丢掉(顺序由扩展加载顺序决定)。但如果其中一个注册的名字是 read,规则方向相反:内置定义先打底,自定义定义后写入覆盖同名,所以扩展的 read 会盖掉内置的 read(仓库示例 tool-override.ts 演示的正是这条,也顺带暴露了「覆盖之后你要为这个工具的全部行为负责」的代价)。一句话:扩展之间先到先得,扩展对内置则后来居上。
  2. 用户输入 /compact 会执行扩展那条——执行路径上扩展命令最先被拦截(agent-session.ts:1617-1624),内置的 /compact 根本轮不到。但他在补全列表里看不到扩展这条:显示路径反过来,内置命令优先,同名的扩展命令被跳过并产出一条警告诊断(interactive-mode.ts:748-751、:664-677)。这个「能执行却看不见」的错位正是本章实践任务第 3 步要你亲眼确认的现象。
  3. 能调用。模型认工具靠的是随请求下发的 name / description / parameters,跟 promptSnippet 没关系。少掉的是系统提示词里那份人类可读的工具清单——buildSystemPromptSections 只收带 snippet 的工具(system-prompt.ts:148)。实际影响是模型少了一句「什么时候该用它」的提示,命中率会低一些。仓库示例 hello.ts 故意不写 snippet,就是为了让这个差别可被观察。

本书分析的 Pi 版本:earendil-works/pi@16787ad(2026-09-21)