🔴 开发者章节 | 当 read / write / edit / bash 不够用时,自定义工具是第一个想到的扩展方式。本章讲怎么写一个能被 LLM 调用、并把结果安全地喂回上下文的工具。
一句话自定义工具 = TypeScript 函数 + Typebox 参数 + pi.registerTool()。
最小示例sequenceDiagram
participant L as LLM
participant P as Pi
participant T as 自定义工具
L->>P: 请求调用 greet(name)
P->>T: execute(toolCallId, params)
T->>P: 返回 ToolResult
P->>L: 结果进入上下文 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" ;import { Type } from "typebox" ;export default function (pi : ExtensionAPI ) { pi.registerTool ({ name : "greet" , label : "Greet" , description : "Greet someone by name" , 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 : {}, }; }, }); }
LLM 看到:greet(name) — 打招呼。
工具定义字段⚠️ 重要:promptGuidelines 里没有工具名前缀,所以每条规则必须显式写工具名,例如 “Use my_tool when…”。
execute 签名1 2 3 4 5 6 7 async execute ( toolCallId : string , params : Static <typeof parameters>, signal : AbortSignal | undefined , onUpdate : (result : Partial <ToolResult > ) => void , ctx : ExtensionContext , ): Promise <ToolResult >
返回值:
1 2 3 4 5 6 { content : TextContent []; details ?: object ; usage ?: ModelUsage ; terminate ?: boolean ; }
错误处理:必须 throw Error 才会标记 isError: true;普通返回不会报错。
1 2 3 if (!isValid (params)) { throw new Error (`Invalid input: ${JSON .stringify(params)} ` ); }
参数类型最佳实践1 2 3 4 5 import { StringEnum } from "@earendil-works/pi-ai" ;parameters : Type .Object ({ action : StringEnum (["list" , "add" , "delete" ] as const ), })
Google API 不支持 Type.Union(Type.Literal(...)),必须用 StringEnum。
1 2 3 Type .Object ({ text : Type .Optional (Type .String ()), })
流式更新长时间运行的工具可以发中间进度:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 async execute (toolCallId, params, _signal, onUpdate ) { onUpdate?.({ content : [{ type : "text" , text : "正在下载..." }], details : { progress : 30 }, }); onUpdate?.({ content : [{ type : "text" , text : "下载完成" }], details : { progress : 100 }, }); return { content : [{ type : "text" , text : "Done" }], details : {}, }; }
输出截断工具输出必须截断,否则可能撑爆上下文。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 import { truncateHead, truncateTail, formatSize, DEFAULT_MAX_BYTES , DEFAULT_MAX_LINES , } from "@earendil-works/pi-coding-agent" ; import { writeFile } from "node:fs/promises" ;import { tmpdir } from "node:os" ;import { join } from "node:path" ;const truncation = truncateHead (output, { maxLines : DEFAULT_MAX_LINES , maxBytes : DEFAULT_MAX_BYTES , }); let text = truncation.content ;if (truncation.truncated ) { const fullPath = join (tmpdir (), `pi-tool-output-${toolCallId} .txt` ); await writeFile (fullPath, output, "utf8" ); text += `\n\n[输出已截断:${truncation.outputLines} /${truncation.totalLines} 行(${formatSize(truncation.totalBytes)} ),完整输出见 ${fullPath} ]` ; } return { content : [{ type : "text" , text }] };
truncateHead:保留开头(适合搜索、文件内容)truncateTail:保留结尾(适合日志)默认上限:50KB 或 2000 行 文件修改工具一定要用队列如果自定义工具会改文件,务必用 withFileMutationQueue,避免和内置 edit/write 并发冲突。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import { withFileMutationQueue } from "@earendil-works/pi-coding-agent" ;import { mkdir, readFile, writeFile } from "node:fs/promises" ;import { dirname, resolve } from "node:path" ;async execute (toolCallId, params, _signal, _onUpdate, ctx ) { const absolutePath = resolve (ctx.cwd , params.path ); return withFileMutationQueue (absolutePath, async () => { await mkdir (dirname (absolutePath), { recursive : true }); const current = await readFile (absolutePath, "utf8" ); const next = current.replace (params.oldText , params.newText ); await writeFile (absolutePath, next, "utf8" ); return { content : [{ type : "text" , text : `Updated ${params.path} ` }], details : {}, }; }); }
覆盖内置工具你可以用同名工具覆盖内置的 read、bash、edit 等:
1 2 3 4 5 6 7 8 9 10 pi.registerTool ({ name : "read" , label : "Read" , description : "Read file with access log" , parameters : Type .Object ({ path : Type .String () }), async execute (toolCallId, params ) { console .log (`Reading: ${params.path} ` ); }, });
启动:
1 pi -e ./tool-override.ts
只覆盖实现不覆盖渲染:如果 renderCall/renderResult 不传,仍使用内置渲染。
也可以用 --no-builtin-tools:
1 pi --no-builtin-tools -e ./my-extension.ts
动态加载工具Pi 支持"先注册、后激活":
注册所有工具 初始只激活一部分 工具执行中用 pi.setActiveTools() 纯增加激活的工具 这样 Anthropic / OpenAI 新模型可利用原生 deferred loading,减少提示前缀变化。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 const SEARCHABLE = new Set (["lookup_weather" , "search_issues" ]);pi.registerTool ({ name : "lookup_weather" , ... }); pi.registerTool ({ name : "search_issues" , ... }); pi.registerTool ({ name : "search_tools" , description : "Search for and enable tools relevant to a task" , parameters : Type .Object ({ query : Type .String () }), async execute (toolCallId, params ) { const matches = findTools (params.query ); const active = pi.getActiveTools (); const added = matches.filter ((n ) => !active.includes (n)); pi.setActiveTools ([...new Set ([...active, ...added])]); return { content : [{ type : "text" , text : `Loaded: ${added.join(", " )} ` }], details : {} }; }, }); pi.on ("session_start" , () => { const initial = pi.getActiveTools ().filter ((n ) => !SEARCHABLE .has (n)); pi.setActiveTools ([...new Set ([...initial, "search_tools" ])]); });
状态持久化把状态写入 details,重启后从 session 恢复:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 let items : string [] = [];pi.on ("session_start" , async (_event, ctx) => { items = []; for (const entry of ctx.sessionManager .getBranch ()) { if (entry.type === "message" && entry.message .role === "toolResult" && entry.message .toolName === "my_tool" ) { items = entry.message .details ?.items ?? []; } } }); pi.registerTool ({ name : "my_tool" , async execute (toolCallId, params ) { items.push (params.text ); return { content : [{ type : "text" , text : "Added" }], details : { items : [...items] }, }; }, });
你现在应该会什么到这里你应该能:
写出带 Typebox schema + promptSnippet + promptGuidelines 的标准自定义工具 处理错误(throw 才会被标记 isError)、流式更新(onUpdate)、输出截断(truncateHead / truncateTail) 用 withFileMutationQueue 解决自定义工具和内置 edit/write 的并发写冲突 区分"覆盖内置工具"和"用 --no-builtin-tools 完全替换"的两种用法 下一步