动手第 13 / 31 节 · 预计 45 分钟
写第一个扩展:给 agent 加一件新武器
从一个真实需求出发(查内部系统、跑项目脚本),把它变成 agent 手里的工具。
学完这节你能做到
- 写出一个带参数校验与错误处理的自定义工具并装进 Pi
- 加一个斜杠命令和一个快捷键,把常用操作固化下来
- 用事件钩子在每轮开始前注入动态上下文
这一节从一个真实需求出发:团队的服务清单在一个内部 HTTP 接口里, 每次排查都要人去查一遍「这个服务的 owner 是谁、部署在哪个集群」。 把它做成 agent 手里的工具。
顺带把命令、快捷键、事件注入四件事都过一遍。
骨架
mkdir -p ~/.pi/agent/extensions/svc-lookup
cd ~/.pi/agent/extensions/svc-lookup
单文件也行(~/.pi/agent/extensions/svc-lookup.ts),但目录形式方便以后拆模块、加依赖。
// ~/.pi/agent/extensions/svc-lookup/index.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
import { Type } from "typebox"
export default function (pi: ExtensionAPI) {
// 注册从这里开始
}
可用的 import 就这几样:@earendil-works/pi-coding-agent(类型与工具函数)、
typebox(参数 schema)、@earendil-works/pi-ai(比如 StringEnum)、
@earendil-works/pi-tui(渲染),加上 Node 内置模块和你自己装的依赖。
第一步:注册工具
pi.registerTool({
name: "svc_lookup",
label: "Service Lookup",
description:
"按服务名查内部服务台账,返回 owner、代码仓库、部署集群与命名空间。" +
"只支持精确服务名;不确定名字时先用模糊搜索参数 fuzzy=true 拿候选列表。",
promptGuidelines: [
"Use svc_lookup before asking the user who owns a service.",
"Use svc_lookup with fuzzy=true when the exact service name is unknown.",
],
parameters: Type.Object({
name: Type.String({ description: "服务名,例如 order-api" }),
fuzzy: Type.Optional(Type.Boolean({ description: "模糊搜索,返回候选列表" })),
}),
async execute(_toolCallId, params, signal) {
const url = new URL("https://svc.internal/api/lookup")
url.searchParams.set("name", params.name)
if (params.fuzzy) url.searchParams.set("fuzzy", "1")
// signal 一定要传下去,否则 Escape 停不掉这个请求
const res = await fetch(url, { signal })
if (res.status === 404) {
return {
content: [{
type: "text",
text: `找不到服务 ${params.name}。台账里没有这个精确名字,` +
`可以带 fuzzy=true 重试拿候选列表。`,
}],
isError: true,
}
}
if (!res.ok) {
return {
content: [{ type: "text", text: `台账接口返回 ${res.status},稍后重试或人工确认。` }],
isError: true,
}
}
const data = await res.json()
// content 给模型:只留决策需要的字段
// details 给界面:完整原始数据,不占上下文
return {
content: [{ type: "text", text: format(data) }],
details: { raw: data },
}
},
})
三个细节值得停一下:
signal传给了fetch—— 漏了它,Escape 之后请求还在跑。- 错误返回写成了三段式,并且明确给出「下一步能试什么」。
content精简、details放全量 —— 富信息不该挤模型的窗口。
✓description 里写清「不支持什么」
「只支持精确服务名」这半句,能省掉三四轮试错。模型不怕限制,怕的是不知道限制。
第二步:命令与快捷键
工具是给模型用的,命令是给人用的。同一份逻辑两个入口:
pi.registerCommand("svc", {
description: "查服务台账(不消耗模型调用)",
handler: async (args, ctx) => {
if (!args.trim()) return ctx.ui.notify("用法:/svc <服务名>", "warn")
const info = await lookup(args.trim())
ctx.ui.notify(info ? format(info) : `找不到 ${args}`, info ? "info" : "warn")
},
getArgumentCompletions: (prefix) =>
CACHED_NAMES.filter((n) => n.startsWith(prefix)).map((n) => ({ value: n, label: n })),
})
pi.registerShortcut("ctrl+shift+s", {
description: "查当前目录对应的服务",
handler: async (ctx) => {
const guess = ctx.cwd.split("/").pop()!
ctx.ui.setStatus("svc", `查询 ${guess}...`)
const info = await lookup(guess)
ctx.ui.setStatus("svc", info ? `${guess} → ${info.owner}` : "")
},
})
人查一次不需要花模型的钱,这是命令存在的意义。
第三步:事件注入动态上下文
每轮开始前,把「当前仓库对应哪个服务」这条事实塞进去,省得模型每次都去查:
pi.on("before_agent_start", async (_event, ctx) => {
const info = await lookupCached(ctx.cwd)
if (!info) return
return {
message: `[环境] 当前目录对应服务 ${info.name},owner ${info.owner},` +
`部署在 ${info.cluster}/${info.namespace}。`,
}
})
!注入进消息,别注入进系统提示
放消息里,缓存前缀不动;写进系统提示,每轮变一次就等于缓存全废(L0 算过这笔账)。 另外记得缓存查询结果 —— 每轮一个 HTTP 请求会把延迟顶上去。
第四步:把状态挂在会话上
如果你的扩展有状态(比如「本次会话里已经查过哪些服务」),不能只放内存里 —— 用户 fork 到另一条分支后,内存状态就和会话分叉了。Pi 给的做法是从会话重建:
let queried: string[] = []
pi.on("session_start", async (_event, ctx) => {
queried = []
for (const entry of ctx.sessionManager.getBranch()) {
if (entry.type === "message" && entry.message.role === "toolResult"
&& entry.message.toolName === "svc_lookup") {
const raw = (entry.message as any).details?.raw
if (raw?.name) queried.push(raw.name)
}
}
})
规律是:状态写进工具结果的 details,启动时从当前分支重算。
调试
pi -e ~/.pi/agent/extensions/svc-lookup # 临时加载,快速试
/reload # 改完热重载(放在发现目录里才行)
pi --mode json -p "order-api 的 owner 是谁" # 看事件流:工具有没有被调、返回了什么
三个高频问题:
| 现象 | 原因 |
|---|---|
| 模型压根不调这个工具 | description 没说清「什么时候用」;或 promptGuidelines 写了「this tool」 |
| 调了但参数总是错 | schema 的字段 description 缺失,或没有用枚举约束取值 |
| Escape 之后请求还在跑 | signal 没往下传 |
你的自定义工具写完了,模型偶尔会调它、但经常宁愿去问用户。最可能的原因是?
交作业
- 把这个扩展改造成查你自己团队真实用得上的东西(工单、告警、值班表都行)。
- 加一条只读的
list动作,并给它加分页参数与「还有 N 条」提示。 - 用
--mode json确认:一次典型问答里,这个工具被调了几次、每次返回多少 token。