Tool Use

循环保持稳定,能力注册进一张分发表。

一个工具一个函数,注册表统一分发。

60 行5 个工具

问题

s01 里模型只能跑 bash。于是出现了这些丑陋的对话:

  • 读文件:cat src/main.ts —— 长文件会撑爆上下文;
  • 写文件:echo '...' > file —— 引号转义噩梦;
  • 找文件:find . -name "*.ts" —— 慢且啰嗦。

更糟的是,工具逻辑和循环逻辑混在一起:每加一个工具,都要改 execute 分支。循环应该保持稳定,能力增量式地注册进来

解决方案

把工具定义成"名字 + schema + 执行函数"的三元组,注册进一张表;循环只认这张表,不再关心具体工具是什么。

const registry = new Map();
const define = (name, schema, run) => registry.set(name, { schema, run });
const execute = (name, args) => registry.get(name)?.run(args) ?? `unknown tool: ${name}`;

循环代码与 s01 完全一致——它只把 registry 展开成 OpenAI 的 tools 数组,并按名字分发调用。新增能力 = 新增一行 define(...)

工作原理

第 1 步:定义 5 个常用工具(文件、目录、搜索 + bash 兜底)。

define("read_file",   { type: "object", properties: { path: { type: "string" } }, required: ["path"] },
  ({ path }) => readFile(path));
define("write_file",  { type: "object", properties: { path: { type: "string" }, content: { type: "string" } }, required: ["path", "content"] },
  ({ path, content }) => writeFile(path, content));
define("list_dir",    { type: "object", properties: { path: { type: "string" } }, required: [] },
  ({ path = "." }) => listDir(path));
define("grep",        { type: "object", properties: { pattern: { type: "string" }, path: { type: "string" } }, required: ["pattern"] },
  ({ pattern, path = "." }) => grep(pattern, path));
define("bash",        { type: "object", properties: { command: { type: "string" } }, required: ["command"] },
  ({ command }) => runBash(command));

第 2 步:把注册表展开为模型可见的 tools 数组。

const TOOLS = [...registry.entries()].map(([name, { schema }]) => ({
  type: "function",
  function: { name, description: describe(name), parameters: schema },
}));

第 3 步:循环执行调用时按名字查表。分发逻辑永远不变。

const output = execute(call.function.name, JSON.parse(call.function.arguments));

注意 s01 的"模型一次调用多个工具"在这里自然展开:循环逐个执行,每个结果作为一个 tool 消息回传,模型在下一轮看到全部结果。工具是纯函数式的——对模型而言,它只关心输入 JSON 和输出字符串。

试一下

运行 s02,试试这些 prompt:

  • "读取 package.json 并告诉我 scripts 有哪些"(read_file,而不是 cat)
  • "把 s02_tool_use.mjs 里所有 TODO 找出来"(grep)
  • "列出 src 目录下所有文件"(list_dir)

观察重点:模型在多个工具之间切换是否自然?它会不会把多个工具调用塞进一轮?如果你的 execute 需要并发执行多个工具,会踩到什么坑(共享状态、顺序依赖)?

接下来

工具分发很优雅,但有个现实问题:模型拿到 bash 工具就能删库。s03 Permission → 危险动作在 shell 执行前需要一道 Harness 的决策关卡。

以下内容基于 packages/core/tools 的核查。DSH 的工具系统不是一个 Map,而是一条带守卫的执行管道:

tool/call* → tools/pre-execute(可扩展的允许/拒绝闸门)
          → 单调守卫
          → tools/execute(环绕分发:超时/重试/指标插件挂这里)
          → tools/post-execute(检查/替换结果、附加上下文)
          → finalizeContent(定义所属的收尾)
          → tools/result(只读通知)

一、注册与作用域

  • ctx.tools.register(definition) 注册工具,返回注销函数——注册是可逆的 effect,插件卸载时自动撤销。
  • 注册有作用域:普通插件上下文注册全局工具;agent.ctx 注册的只属于那个 Agent,同名时遮蔽全局工具。这正是 s18 预设组合的底座。

二、模型如何看到工具

  • tools.mode 配置选择呈现方式:native(原生函数调用,默认)、code(Code Mode:保留的 run_code 传输通道 + 工具 SDK)、both
  • 工具 schema 在每次 step 组装时汇入系统提示词(s10 会讲组装机制)。

三、你的 Map 和它的注册表差在哪

你的最小实现 DSH 的真实实现
Map<string, {schema, run}> 带作用域的注册表 + ToolDefinition(含强制 output 声明与 UI 呈现意图)
直接 run(args) 五段守卫管道,超时/重试/审计/审批都是管道上的插件
工具数量一多就靠描述文本 工具顺序(toolOrder)显式配置,防止注册顺序污染模型所见

一句话:你的分发表是 DSH 工具管道的最小核。管道本身不新增工具——它只是让"策略"可以环绕工具,而不必写进工具。