跳到主内容
创建时间:最后更新:作者:白川

给智能体扩展工具和命令

让模型和用户共用同一个只读检查函数,区分参数校验、工具结果和命令反馈。

给智能体扩展工具和命令

“让模型检查文件”和“我手动检查文件”需要同一份业务实现,却有不同入口。工具由模型按参数调用,命令由用户主动输入。把文件读取复制到两个 handler,后续大小限制或错误修复就容易只改一处。

这篇把能力拆成纯业务函数、工具适配和命令适配。例子只读取两个教学文件,找出命名函数声明;它不尝试成为完整的 TypeScript 分析器。

本文示例固定使用 @earendil-works/pi-coding-agent@1.5.0,源码位于仓库 columns/coding-agent/examples/extension-development/。示例是独立包,先在该目录安装依赖;不需要修改专栏站点依赖。下面的命令均从这个示例目录运行。

先约束真正执行的函数

src/inspect.ts 接受工作目录、相对路径和取消信号。校验必须落在这里,因为命令直接调用它,不一定经过模型工具事件。

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 把业务函数接入宿主:

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 合法代替文件可访问。

结果字段使用方本例内容
content
模型可读结果
简短 JSON 文本
details
宿主或渲染器
原始报告对象
structuredContent
需要结构化数据的调用链
可序列化报告
outputSchema
结构化结果契约
字段与类型

数据来自同一份报告,避免终端写“成功”而结构化字段仍保存旧结果。输出契约约束形状,报告内容是否真实仍取决于读取实现。

单独加载工具样例

入口 extensions/tools.ts 只调用注册函数:

ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
import { registerInspection } from "../src/register-inspection.js"

export default function (pi: ExtensionAPI) {
  registerInspection(pi)
}
bash
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 包导出、类型声明和加载器为准。在线主分支会继续变化,遇到接口差异时先核对版本。