Skip to content

7.3 自定义工具与斜杠命令

本页分析版本earendil-works/pi@c13ffe12026-07-30

本章解决什么问题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.Number({ description: "Number of sides, 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 发给模型。自定义工具和七个内置工具用的是同一套写法。
  • defineTool 不是必需的,但值得一直用。它的实现(packages/coding-agent/src/core/extensions/types.ts:509-513)就是把参数原样返回,唯一作用是保住泛型(Generic)参数推断——注释里写得很直白:不套它而直接把对象赋给变量、或塞进 customTools 这类数组时,上下文类型会把 TParams 放宽成 unknownexecute 里的 params.sides 就丢了类型。
  • execute 有五个参数,第五个是 ExtensionContext(本例用不到,命名为 _ctx)。这是 coding-agent 层 ToolDefinition 与 agent 层 AgentTool 的关键差别之一。
  • promptSnippet 是可选的,它决定这个工具是否在默认系统提示词(System Prompt)的 Available tools 段落里占一行。不写也能被调用,只是模型少了一句提示。
  • 命令 handler 有两个参数args 是命令名之后的整段原始字符串(不做分词),ctxExtensionCommandContext
earendil-works/pi@c13ffe1第 449–498 行在 GitHub 查看 ↗
自定义工具的完整形状:name / label / description、可选的 promptSnippet 与 promptGuidelines、typebox 的 parameters、五参数的 execute,以及 TUI 用的 renderCall / renderResult。

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

text
extension: dice.ts
  tools   : [ 'dice' ]
  commands: [ 'hello' ]
  schema  : {"type":"object","required":["sides"],"properties":{"sides":{"type":"number","description":"Number of sides, 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:232),registerTool 的实现只有六行:

earendil-works/pi@c13ffe1第 247–254 行在 GitHub 查看 ↗
registerTool:先确认扩展仍然有效,把 definition 连同来源信息写进 extension.tools,然后触发一次 refreshTools。
ts
// packages/coding-agent/src/core/extensions/loader.ts:247-254
registerTool(tool: ToolDefinition): void {
	runtime.assertActive();
	extension.tools.set(tool.name, {
		definition: tool,
		sourceInfo: extension.sourceInfo,
	});
	runtime.refreshTools();
},

三个细节值得停一下:

  1. assertActive():会话(Session)被替换或 /reload 之后,旧的 pi 对象一律作废,任何调用都抛错(loader.ts:203-207)。这是防止旧扩展闭包偷偷往新会话里塞东西的失效保护。
  2. key 是 tool.name:同一个扩展里注册两次同名工具,后者覆盖前者。跨扩展的同名冲突是另一套规则,见下一节。
  3. runtime.refreshTools():这是「注册」通向「生效」的唯一开关。它在扩展加载阶段是一个空函数(loader.ts:193-194),等 AgentSession._bindExtensionCore 把真实实现装上之后(agent-session.ts:2396),它才指向 _refreshToolRegistry
🌱 初学者提示为什么加载阶段的 refreshTools 是空函数
扩展工厂在 AgentSession 完全构造好之前就会被执行,此时还没有工具注册表可刷新,所以先装一个 no-op。等 AgentSession 建好,它会主动做一次完整刷新,把工厂阶段注册的所有工具一并纳入——因此「工厂里注册」和「运行时注册」最终走的是同一条路,只是前者少了一次即时刷新。官方文档也明确说明:pi.registerTool() 在加载期与启动后都可用,新工具会「立即在同一会话中刷新」、无需 /reload(来源文件 packages/coding-agent/docs/extensions.md:1341)。

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

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

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

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

earendil-works/pi@c13ffe1第 2455–2521 行在 GitHub 查看 ↗
重建工具注册表:收集扩展工具与 SDK 工具 → 以内置定义打底、用自定义定义覆盖同名项 → 重算 promptSnippet / promptGuidelines → 统一包装成 AgentTool → 生成最终 _toolRegistry。
ts
// packages/coding-agent/src/core/agent-session.ts:2463-2488(有省略)
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:2022-2024);仓库里的 packages/coding-agent/examples/extensions/tool-override.ts 用它把内置 read 换成了带审计日志和路径黑名单的版本。

接着是 prompt 元数据与包装:

  • 2489-2504:把每个 definition 的 promptSnippet / promptGuidelines 收进两张 Map,之后 _rebuildSystemPromptagent-session.ts:1021-1055)只对当前激活的工具取用(:1022 先按注册表过滤一遍名字),拼进系统提示词。没写 promptSnippet 的自定义工具不会出现在 Available 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")固化了这条行为。
  • 2506wrapRegisteredTools 把五参数的 ToolDefinition 适配成 agent core 认识的四参数 AgentTool
earendil-works/pi@c13ffe1第 17–37 行在 GitHub 查看 ↗
扩展工具的外层包装:内层用 wrapToolDefinition 补上第五个参数 ExtensionContext;外层在 execute 前后对比激活工具列表,把执行期间新增的工具名写进结果的 addedToolNames。

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

最后两步:

ts
// packages/coding-agent/src/core/agent-session.ts:2517-2521
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;

以及 2537-2543 的一段:如果调用方没有显式指定激活集,注册表里新出现的名字会被自动加进激活集。这解释了为什么你注册一个工具之后不用手动 setActiveTools 就能用。收尾是 setActiveToolsByNameagent-session.ts:926-941),它把选中的 AgentTool 数组赋给 this.agent.state.tools 并重建系统提示词——到这一步,dice 才真正进入下一次请求的 payload。

至此,完整链路是:

pi.registerToolloader.ts:247)→ runtime.refreshToolsloader.ts:253)→ AgentSession._refreshToolRegistryagent-session.ts:2455)→ ExtensionRunner.getAllRegisteredToolsrunner.ts:450)→ wrapRegisteredToolswrapper.ts:43)→ wrapToolDefinitiontool-definition-wrapper.ts:5)→ setActiveToolsByNameagent-session.ts:926)→ agent.state.tools

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

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

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

earendil-works/pi@c13ffe1第 600–618 行在 GitHub 查看 ↗
按名字在 context.tools 里 find,找不到就直接回一个 isError 的结果;找到则先跑可选的 prepareArguments,再做 typebox 校验。这里没有任何「内置 / 扩展」的分支。
ts
// packages/agent/src/agent-loop.ts:607-618(有省略)
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 检查,619-650 行)

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

  1. 参数校验validateToolArgumentspackages/ai/src/utils/validation.ts:278-310)先 Value.Convert 做类型强转,再编译 schema 检查。模型若把 sides 传成字符串 "6",会被强转成数字;传成 "六" 则校验失败,错误消息作为 isError 的工具结果(Tool Result)回给模型,模型可以据此自我修正。这套机制对内置工具和 dice 完全一致。
  2. 拦截钩子AgentSession._installAgentToolHooksagent-session.ts:468-518)把 loop 的 beforeToolCall / afterToolCall 接到扩展事件 tool_call / tool_result 上。任何扩展都能拦截 dice,也能改写它的结果——包括注册 dice 的那个扩展自己。
  3. 执行观察事件tool_execution_start / update / end_emitExtensionEventagent-session.ts:712-793)统一映射,同样不分来源。

唯一的差别在类型层,不在运行时:七个内置工具各有专用的事件类型(BashToolCallEventReadToolCallEvent 等,core/extensions/types.ts:858-891),input 字段是精确类型;其他工具一律落到 CustomToolCallEventtypes.ts:893-896),inputRecord<string, unknown>。也就是说,写 tool_call handler 处理 dice 时,参数类型要你自己收窄。

图加载中…

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

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

本章的 dice.ts 是为教学裁剪出来的。仓库 packages/coding-agent/examples/extensions/ 下有 69 个顶层 .ts 扩展示例(另有 9 个子目录形态的示例与一个 README,源码事实,本机 ls 计数),其中 16 个调用了 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。按前面 _refreshToolRegistry2489-2496_rebuildSystemPrompt 的取用逻辑,这意味着 hello 工具照样在 _toolRegistry 里、照样会被激活、照样能被模型调用,但不会出现在系统提示词的 Available 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:2537-2543 那个「注册表里新出现的名字自动激活」分支),无需 /reload

它还示范了 promptGuidelines:这些条目会平铺进系统提示词的 Guidelines 段落。官方文档特意提醒,因为拼接时不加工具名前缀,每条 guideline 必须自己点名工具,不能写 "Use this tool when…"(来源文件 packages/coding-agent/docs/extensions.md:1347)。这也是为什么例子里写的是 "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 里先用一组正则(.envsecrets.*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:2043)。

值得单独记住的是官方文档补充的两条规则(来源文件 packages/coding-agent/docs/extensions.md:2039:2041):渲染槽按 renderCall / renderResult 分别继承——覆盖工具不写渲染函数就自动沿用内置渲染器,所以给内置工具加一层日志不必重写 TUI;但 promptSnippet / promptGuidelines 不会被继承,覆盖时想保留那几句提示必须自己再写一遍。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

earendil-works/pi@c13ffe1第 571–655 行在 GitHub 查看 ↗
自动补全的四路合并:22 条内置命令 → prompt 模板 → 扩展命令(先过滤掉与内置同名者)→ skill 命令(形如 skill:name)。最后交给 CombinedAutocompleteProvider。
ts
// packages/coding-agent/src/modes/interactive/interactive-mode.ts:626-634
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:556-569,文案 "conflicts with built-in interactive command. Skipping in autocomplete.")。

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

多个扩展注册同名命令

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

图加载中…

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

一个数量上的旁证

内置命令是 22 条(源码事实,slash-commands.ts:19-42)。而本书的真实采集 research/cli-captures/pi-tui-slash.txt 里,按下 / 后补全列表的计数器显示 (1/33);同一份采集的启动横幅列出了 5 个 prompt 模板与 3 个 skill。据此推算(尚未在源码中直接证实,也未逐条核对该环境里 4 个扩展文件各注册了几条命令),该次运行还有 3 条来自扩展注册的命令。这个数字关系正好对应上面那次四路合并:22 内置 + 5 模板 + 3 skill + 3 扩展命令 = 33

实践任务

🛠 实践任务写一个 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":"number","description":"Number of sides, e.g. 6"}}}
errors  : []

这一步证明了三件事:工厂函数被执行了;两次注册分别落进了 extension.toolsextension.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:150)。界面起来后输入 /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:437),既不属于 user 作用域也不属于 project 作用域;标签由作用域首字母生成(interactive-mode.ts:528:user→u、project→p、其余→t,函数 getAutocompleteSourceTag:523-546)。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:626-629agent-session.ts:1122-1129)。

常见错误

  • 忘了 --tsconfig tsconfig.jsontsx 解析不到工作区路径映射,import 直接失败。
  • <LAB> 目录里跑 tsx:那里没有 node_modules,找不到 tsx 与 typebox。命令必须在 Pi 仓库根目录执行。
  • 扩展文件忘了 export default,或默认导出的不是函数:加载器判定为无效扩展并报 "Extension does not export a valid factory function"(loader.ts:424-432:471-473),在 errors 数组里能看到。
  • dice.ts 放进项目的 .pi/extensions/ 却发现没加载:项目本地扩展受信任门控,未信任的项目不会加载它(packages/coding-agent/src/core/package-manager.ts:2374-2382)。用 -e 显式指定可以绕开这个问题。
  • 用管道或重定向启动(如 pi -e ... | cat):标准输出不是 TTY 会切到 print 模式,根本没有补全界面(见 [4.4](/pi-overview/where-to-start) 的 resolveAppMode)。

对应源码位置packages/coding-agent/src/core/extensions/loader.ts:247-254(registerTool)、:256-263(registerCommand)、:548-555(loadExtensions);packages/coding-agent/src/core/agent-session.ts:2455-2546(工具注册表重建)与 :1270-1294(命令执行);packages/coding-agent/src/modes/interactive/interactive-mode.ts:571-655(补全列表合并)。相关测试: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:247:256),真正接入运行时的是 AgentSession._refreshToolRegistry(工具)与交互模式的 createBaseAutocompleteProvider(命令可见性)。
  • 自定义工具的完整链路:pi.registerToolruntime.refreshTools_refreshToolRegistrygetAllRegisteredTools(跨扩展同名先注册者胜)→ 内置定义打底、自定义定义后写入覆盖同名wrapRegisteredTools / wrapToolDefinitionsetActiveToolsByNameagent.state.tools
  • 进入注册表之后就是同等待遇:查找、typebox 校验(agent-loop.ts:607-618)、tool_call / tool_result 拦截、执行观察事件,全部不区分来源。唯一差别在类型层——内置七工具有专用事件类型,其余落到 CustomToolCallEvent
  • 斜杠命令有两套顺序:执行时扩展命令最先被拦截(agent-session.ts:1122),显示时内置命令优先、同名扩展命令被跳过并产出警告诊断(interactive-mode.ts:626-629:556-569)。跨扩展重名则消歧为 name:1 / name:2
  • 22 条内置命令只是一张纯数据清单,没有 handler;真实采集里补全列表的 33 条,正是「内置 + prompt 模板 + skill + 扩展命令」四路合并的结果。
  • 仓库示例把三种用法摆得很清楚:hello.ts(最小注册,且故意不写 promptSnippet)、dynamic-tools.ts(在 session_start 与命令 handler 里动态注册,验证「刷新即生效」)、tool-override.ts(同名覆盖内置 read,也暴露了「覆盖后要为全部行为负责」的代价)。
  • 关键术语:ToolDefinitiondefineToolpromptSnippet工具注册表(tool registry)invocationName(调用名)四路合并的自动补全
  • 关键源码索引:core/extensions/types.ts:449-498(ToolDefinition)、:509-513(defineTool)、:1237-1240(registerTool 声明)、:1246-1247(registerCommand 声明)、:893-896(CustomToolCallEvent);core/extensions/loader.ts:247-263core/extensions/runner.ts:449-460:598-632:647-649core/extensions/wrapper.ts:17-45core/tools/tool-definition-wrapper.ts:5-20core/agent-session.ts:1122-1129:1270-1294:2455-2546core/slash-commands.ts:4-42modes/interactive/interactive-mode.ts:556-569:571-655packages/agent/src/agent-loop.ts:600-664;示例 packages/coding-agent/examples/extensions/hello.tsdynamic-tools.tstool-override.ts。测试:test/extensions-runner.test.ts:370:394:477test/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 的范围。

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