给智能体扩展工具和命令
让模型和用户共用同一个只读检查函数,区分参数校验、工具结果和命令反馈。
给智能体扩展工具和命令
“让模型检查文件”和“我手动检查文件”需要同一份业务实现,却有不同入口。工具由模型按参数调用,命令由用户主动输入。把文件读取复制到两个 handler,后续大小限制或错误修复就容易只改一处。
这篇把能力拆成纯业务函数、工具适配和命令适配。例子只读取两个教学文件,找出命名函数声明;它不尝试成为完整的 TypeScript 分析器。
本文示例固定使用 @earendil-works/pi-coding-agent@1.5.0,源码位于仓库 columns/coding-agent/examples/extension-development/。示例是独立包,先在该目录安装依赖;不需要修改专栏站点依赖。下面的命令均从这个示例目录运行。
先约束真正执行的函数
src/inspect.ts 接受工作目录、相对路径和取消信号。校验必须落在这里,因为命令直接调用它,不一定经过模型工具事件。
import { readFile, realpath, stat } from "node:fs/promises"
import { isAbsolute, relative, resolve } from "node:path"
export const allowedFiles = ["fixtures/sample.ts", "fixtures/other.ts"] as const
export interface Report {
file: string
bytes: number
names: string[]
}
export async function inspect(cwd: string, file: string, signal?: AbortSignal): Promise<Report> {
signal?.throwIfAborted()
if (!allowedFiles.some((allowed) => allowed === file)) {
throw new Error("只接受 fixtures/sample.ts 或 fixtures/other.ts")
}
const root = await realpath(cwd)
const target = await realpath(resolve(root, file))
const path = relative(root, target)
if (path === ".." || path.startsWith("../") || isAbsolute(path)) {
throw new Error("文件真实路径超出项目目录")
}
const info = await stat(target)
if (!info.isFile() || info.size > 65536) {
throw new Error("只读取不超过 64 KiB 的普通文件")
}
const data = await readFile(target, { signal })
if (data.byteLength > 65536) {
throw new Error("读取结果超过 64 KiB")
}
signal?.throwIfAborted()
return {
file,
bytes: data.byteLength,
names: [...data.toString("utf8").matchAll(/\bfunction\s+([A-Za-z_$][\w$]*)\s*\(/g)].map(
(match) => match[1],
),
}
}白名单控制教学输入,真实路径检查拒绝指向项目外的符号链接,读取前后检查大小。这里只演示普通项目里的只读文件检查:检查和读取之间仍有文件被替换的竞态,也不能防御同权限恶意进程。需要强隔离时应由宿主和运行环境提供。
正则只找 function name(...)。注释或字符串也可能匹配,箭头函数、重载语义和导出关系不在能力范围内。结果应称为“匹配到的声明名”,不能进一步声称“所有函数都安全”。
工具声明告诉模型什么
src/register-inspection.ts 把业务函数接入宿主:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
import { arr, num, obj, str } from "@earendil-works/pi-schema"
import { inspect, type Report } from "./inspect.js"
export function registerInspection(
pi: ExtensionAPI,
options: { defaultActive?: boolean; save?: (report: Report) => void } = {},
) {
pi.registerTool({
name: "lesson_inspect",
label: "检查教学文件",
description: "读取 fixtures/sample.ts 或 fixtures/other.ts,返回命名函数声明。只做文本扫描。",
parameters: obj({ file: str() }),
outputSchema: obj({ file: str(), bytes: num(), names: arr(str()) }),
defaultActive: options.defaultActive ?? true,
async execute(_id, params, signal, _update, ctx) {
if (
typeof params !== "object" ||
params === null ||
!("file" in params) ||
typeof params.file !== "string"
) {
throw new Error("file 必须是字符串")
}
const report = await inspect(ctx.cwd, params.file, signal)
options.save?.(report)
return {
content: [{ type: "text", text: JSON.stringify(report) }],
details: report,
structuredContent: { ...report },
}
},
})
pi.registerCommand("lesson-check", {
description: "检查指定的教学文件,默认 fixtures/sample.ts",
handler: async (args, ctx) => {
const report = await inspect(ctx.cwd, args.trim() || "fixtures/sample.ts")
options.save?.(report)
if (ctx.hasUI) {
ctx.ui.notify(JSON.stringify(report), "info")
} else {
pi.appendEntry("lesson.report", report)
}
},
})
}参数 Schema 用于声明接口,执行入口仍处理未知值并验证字符串。文件是否允许、是否存在、是否过大,由业务函数判断。三层检查各自回答不同问题,不能用 Schema 合法代替文件可访问。
数据来自同一份报告,避免终端写“成功”而结构化字段仍保存旧结果。输出契约约束形状,报告内容是否真实仍取决于读取实现。
单独加载工具样例
入口 extensions/tools.ts 只调用注册函数:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
import { registerInspection } from "../src/register-inspection.js"
export default function (pi: ExtensionAPI) {
registerInspection(pi)
}npx pi --extension ./extensions/tools.ts输入 /lesson-check fixtures/sample.ts 应得到 greet。把路径换成 fixtures/missing.ts 会先被白名单拒绝;允许路径但实际文件缺失时,文件系统错误会传播为失败。工具抛错后由宿主转成工具错误,不能 catch 后伪造空的成功报告。
命令在有 UI 时通知用户,在无 UI 时追加会话记录,不向 stdout 随意输出,避免破坏 JSON/RPC 协议。模型是否主动选择 lesson_inspect,还取决于模型、当前工具集合和任务提示;加载器列出了工具不代表已经验证了模型调用。
取消和结果回填
工具调用的 signal 传给 readFile,执行前后也检查取消。自定义长任务同样需要把信号继续传给网络、子进程或队列;只把信号放在函数签名里不能停止工作。
本例只有一次小文件读取,不伪造进度。真正需要增量展示时才调用更新回调,并在最终返回完整结果。重试只能重做允许重做的任务;这个只读检查较容易重试,写文件或发请求的工具还需要幂等设计。
依据与版本
代码以已发布的 1.5.0 包导出、类型声明和加载器为准。在线主分支会继续变化,遇到接口差异时先核对版本。