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,才会随下一次请求下发给模型; - 命令要等交互模式重建一次自动补全提供者,才会出现在你按
/时弹出的列表里。
生效是宿主把这些 Map 收拢起来重建运行时状态(工具注册表 / 自动补全列表 / 系统提示词)。
把两者分开的好处是:扩展作者不需要知道 AgentSession 内部长什么样,也不需要关心「现在是启动阶段还是已经跑起来了」——写 Map 永远合法。
打个比方:registerTool 像入职登记,_refreshToolRegistry 像排班表重排。登记完不一定马上上岗,但排班表每次重排都会把登记过的人算进去。
最小示例:一个文件、两种注册
下面这个 dice.ts 是本章的贯穿例子:它注册一个 dice 工具(掷一个 N 面骰子)和一条 /hello 命令。它是一个完整可运行的扩展,不需要 package.json,不需要编译。
// 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放宽成unknown,execute里的params.sides就丢了类型。execute有五个参数,第五个是ExtensionContext(本例用不到,命名为_ctx)。这是 coding-agent 层ToolDefinition与 agent 层AgentTool的关键差别之一。promptSnippet是可选的,它决定这个工具是否在默认系统提示词(System Prompt)的 Available tools 段落里占一行。不写也能被调用,只是模型少了一句提示。- 命令 handler 有两个参数:
args是命令名之后的整段原始字符串(不做分词),ctx是ExtensionCommandContext。
ToolDefinition不启动 Agent 也能确认这个文件注册了什么。下面这段是直接调用扩展加载器、打印扩展对象里两张 Map 的真实运行输出(作者本机;完整脚本与复现方式见本章实践任务):
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 的实现只有六行:
registerTool// 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();
},三个细节值得停一下:
assertActive():会话(Session)被替换或/reload之后,旧的pi对象一律作废,任何调用都抛错(loader.ts:203-207)。这是防止旧扩展闭包偷偷往新会话里塞东西的失效保护。- key 是
tool.name:同一个扩展里注册两次同名工具,后者覆盖前者。跨扩展的同名冲突是另一套规则,见下一节。 runtime.refreshTools():这是「注册」通向「生效」的唯一开关。它在扩展加载阶段是一个空函数(loader.ts:193-194),等AgentSession._bindExtensionCore把真实实现装上之后(agent-session.ts:2396),它才指向_refreshToolRegistry。
pi.registerTool() 在加载期与启动后都可用,新工具会「立即在同一会话中刷新」、无需 /reload(来源文件 packages/coding-agent/docs/extensions.md:1341)。 回到 Pi 源码(二):从两张 Map 到模型的工具清单
刷新的第一步是把所有扩展的工具收拢成一个数组:
getAllRegisteredTools然后是本章最核心的一个函数,它一口气干了五件事:
_refreshToolRegistry// 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,之后_rebuildSystemPrompt(agent-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")固化了这条行为。2506:wrapRegisteredTools把五参数的ToolDefinition适配成 agent core 认识的四参数AgentTool。
wrapRegisteredToolwrapToolDefinition 的实现在 packages/coding-agent/src/core/tools/tool-definition-wrapper.ts:5-20,6.4 已经拆过:它靠一个 ctxFactory 闭包在调用时才创建 ExtensionContext,因此上下文(Context)永远是「此刻的会话」,而不是注册时的快照。
最后两步:
// 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 就能用。收尾是 setActiveToolsByName(agent-session.ts:926-941),它把选中的 AgentTool 数组赋给 this.agent.state.tools 并重建系统提示词——到这一步,dice 才真正进入下一次请求的 payload。
至此,完整链路是:
pi.registerTool(loader.ts:247)→ runtime.refreshTools(loader.ts:253)→ AgentSession._refreshToolRegistry(agent-session.ts:2455)→ ExtensionRunner.getAllRegisteredTools(runner.ts:450)→ wrapRegisteredTools(wrapper.ts:43)→ wrapToolDefinition(tool-definition-wrapper.ts:5)→ setActiveToolsByName(agent-session.ts:926)→ agent.state.tools。
回到 Pi 源码(三):进了清单之后,就是同等待遇
自定义工具与内置工具的差别,到 _toolRegistry 那一步就结束了。往后每一步都不区分来源,原因很朴素——后面的代码根本不知道「来源」这个概念。
查找与校验发生在 Agent Loop(Agent 循环)里,它拿到的只是一个 AgentTool 数组:
prepareToolCall// 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 自动获得三样东西:
- 参数校验:
validateToolArguments(packages/ai/src/utils/validation.ts:278-310)先Value.Convert做类型强转,再编译 schema 检查。模型若把sides传成字符串"6",会被强转成数字;传成"六"则校验失败,错误消息作为isError的工具结果(Tool Result)回给模型,模型可以据此自我修正。这套机制对内置工具和dice完全一致。 - 拦截钩子:
AgentSession._installAgentToolHooks(agent-session.ts:468-518)把 loop 的beforeToolCall/afterToolCall接到扩展事件tool_call/tool_result上。任何扩展都能拦截dice,也能改写它的结果——包括注册dice的那个扩展自己。 - 执行观察事件:
tool_execution_start/update/end由_emitExtensionEvent(agent-session.ts:712-793)统一映射,同样不分来源。
唯一的差别在类型层,不在运行时:七个内置工具各有专用的事件类型(BashToolCallEvent、ReadToolCallEvent 等,core/extensions/types.ts:858-891),input 字段是精确类型;其他工具一律落到 CustomToolCallEvent(types.ts:893-896),input 是 Record<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:官方的最小注册例子
// 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 的 2489-2496 与 _rebuildSystemPrompt 的取用逻辑,这意味着 hello 工具照样在 _toolRegistry 里、照样会被激活、照样能被模型调用,但不会出现在系统提示词的 Available tools 段落里——模型只能从工具声明本身(name/description/schema)知道它的存在。这正是测试 packages/coding-agent/test/agent-session-dynamic-tools.test.ts:211 固化的那条行为。
② dynamic-tools.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 里先用一组正则(.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:2043)。
值得单独记住的是官方文档补充的两条规则(来源文件 packages/coding-agent/docs/extensions.md:2039 与 :2041):渲染槽按 renderCall / renderResult 分别继承——覆盖工具不写渲染函数就自动沿用内置渲染器,所以给内置工具加一层日志不必重写 TUI;但 promptSnippet / promptGuidelines 不会被继承,覆盖时想保留那几句提示必须自己再写一遍。
回到 Pi 源码(四):斜杠命令的四个来源、两套顺序
斜杠命令比工具更容易让人误解,因为「内置命令」和「扩展命令」实际上是两套不同的东西。
内置命令只是一张纯数据清单,共 22 条:
BUILTIN_SLASH_COMMANDS同一个文件里的 SlashCommandInfo.source 只有三个取值:"extension" | "prompt" | "skill"(slash-commands.ts:4)。内置命令不在这个体系里——这是理解后面所有差异的关键。
扩展命令则是真正带 handler 的注册项:
registerCommand于是同一条 /xxx 输入,在两个不同场合遵循两套不同顺序。
顺序一:执行时,扩展命令最先被拦截
用户按下回车后,AgentSession.prompt() 做的第一件事就是查扩展命令:
_tryExecuteExtensionCommand调用点在 agent-session.ts:1122-1129,位置非常靠前——在 input 事件之前,也在 skill 展开与 prompt 模板展开之前。命中扩展命令就直接 return,既不发给模型,也不触发后面任何一环。
顺序二:自动补全时,内置命令优先且扩展同名项被跳过
自动补全列表由交互模式重建,把四类来源拼在一起:
createBaseAutocompleteProvider// 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:1、name:2 这样的调用名(resolveRegisteredCommands,runner.ts:598-632),查找严格按调用名进行(getCommand,runner.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 驱动。请关注两点:一是这两条路径查的是同一份数据(getRegisteredCommands 与 getCommand 都基于 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。
实践任务
目标:亲手走通「注册 → 可见」这一段,并划清「哪些环节没有 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:
// 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.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:150)。界面起来后输入 /hel。
预期现象:启动横幅的 [Extensions] 一节列出 dice.ts;顶部出现 Warning: No models available(正常,因为没有 Key);输入 /hel 后补全列表里出现 hello。作者在伪终端里做的真实采集见 research/cli-captures/pi-tui-custom-command.txt,关键两段是:
[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-629 与 agent-session.ts:1122-1129)。
常见错误:
- 忘了
--tsconfig tsconfig.json:tsx解析不到工作区路径映射,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.registerTool→runtime.refreshTools→_refreshToolRegistry→getAllRegisteredTools(跨扩展同名先注册者胜)→ 内置定义打底、自定义定义后写入覆盖同名 →wrapRegisteredTools/wrapToolDefinition→setActiveToolsByName→agent.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,也暴露了「覆盖后要为全部行为负责」的代价)。 - 关键术语:ToolDefinition、defineTool、promptSnippet、工具注册表(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-263;core/extensions/runner.ts:449-460、:598-632、:647-649;core/extensions/wrapper.ts:17-45;core/tools/tool-definition-wrapper.ts:5-20;core/agent-session.ts:1122-1129、:1270-1294、:2455-2546;core/slash-commands.ts:4-42;modes/interactive/interactive-mode.ts:556-569、:571-655;packages/agent/src/agent-loop.ts:600-664;示例packages/coding-agent/examples/extensions/hello.ts、dynamic-tools.ts、tool-override.ts。测试:test/extensions-runner.test.ts:370、:394、:477,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 的范围。