跳到主要内容

02 · 工具系统

本章讲:工具(agent 的手脚)是怎么定义、注册、被模型选中并执行的。

1. 它要解决的小问题

模型只会说"我想调用 edit,参数是 {...}"。要把这句话变成真实副作用,需要:① 一份模型能看懂的工具描述 + JSON Schema;② 把模型给的参数解码并校验;③ 真正执行并产生结果;④ 结果太长要截断,免得撑爆下一轮上下文。opencode 把这四步固化成一个统一外壳 Tool.define

2. 统一外壳 Tool.define

所有工具都长一个样:一个 id、一段 description、一个参数 schema、一个 executeTool.define(tool/tool.ts:151)把它们包起来,wrap(tool/tool.ts:99)在执行前后加上通用逻辑:

// tool/tool.ts:113 起,示意精简
toolInfo.execute = (args, ctx) =>
Effect.gen(function* () {
// ① 解码 + 校验参数,失败抛 InvalidArgumentsError(模型可读的"请重写输入")
const decoded = yield* decode(args).pipe(
Effect.mapError((error) => new InvalidArgumentsError({ tool: id, detail: ... }))
)
// ② 真正执行
const result = yield* execute(decoded, ctx)
// ③ 输出截断(按 agent 配置),太长的写到临时文件
const truncated = yield* truncate.output(result.output, {}, agent)
return { ...result, output: truncated.content,
metadata: { ...result.metadata, truncated: truncated.truncated,
...(truncated.truncated && { outputPath: truncated.outputPath }) } }
})

这三步对每个工具一视同仁,工具作者只写 execute 的真正逻辑。

InvalidArgumentsError 的巧思(tool/tool.ts:24):它是个 typed error,message getter 产出一段模型可读的话——"The X tool was called with invalid arguments: ... Please rewrite the input so it satisfies the expected schema"。这段话经 AI SDK 作为工具结果喂回模型,模型于是知道要改参数重试。错误被设计成"对模型说人话"。

3. 工具的 Context

执行时拿到的 ctx(tool/tool.ts:36)给工具几样能力:

字段给工具什么能力
sessionID / messageID / agent知道自己在哪个会话、哪条消息、哪个 agent 下跑
abort一个 AbortSignal,长任务能响应取消
messages当前会话历史(只读)
metadata(...)回写标题/元数据(比如 edit 回写 diff)
ask(...)请求权限(见 04 章)

4. 注册表:收集与过滤

ToolRegistry(tool/registry.ts:83)在实例初始化时把所有工具聚到一起:

内置工具(invalid/shell/read/glob/grep/edit/write/task/fetch/todo/search/skill/patch/...)
+
插件工具(来自已加载 plugin 的 .tool 字段)
+
自定义工具(扫描项目下 tool/*.{js,ts} 文件)

registry.all() ──按模型/agent过滤──► registry.tools(model) ──► 喂给模型

内置工具清单在 registry.ts:217-239 写死(builtin 数组),其中一些受 feature flag 控制(如 lspplan 只在特定客户端/flag 下出现)。

按模型过滤的两个真实规则(registry.ts:267-279tools):

  • websearch 只在 provider 支持时给(webSearchEnabled,registry.ts:56)。
  • edit vs apply_patch 二选一:对 GPT 系模型(gpt- 且非 oss/gpt-4)用 apply_patch 工具,其它模型用 edit/write。这是因为不同模型家族被训练成偏好不同的编辑格式。

5. 插件工具怎么桥接

插件工具用 Zod 声明参数(历史兼容),而内置工具用 Effect SchemafromPlugin(registry.ts:114)在边界上做转换:把 Zod schema 转成 JSON Schema 喂模型,并把执行结果统一成内置工具的形状。它还把 Effect 的 ask(返回 Effect)桥成插件期望的 Promise(registry.ts:138-139,经 EffectBridge)。

// registry.ts:122-126,示意精简
const allZod = entries.every((entry) => isZodType(entry[1]))
const zodParams = allZod ? z.object(args) : undefined
const jsonSchema = zodParams ? zodJsonSchema(zodParams) : legacyJsonSchema(entries)

6. 工具描述哪来

很多工具的长描述放在同名 .txt 文件里(tool/edit.txttool/read.txttool/grep.txt…),编译时被导入。这样 prompt 工程师能直接改描述而不碰逻辑。task 工具的描述还会动态拼上"当前可用的子 agent 类型"(registry.ts:252describeTask)。

7. 关键细节 / 坑

  • schema 解码闭包只建一次。 wrapSchema.decodeUnknownEffect(...) 在工具 init 时编译一次,而非每次调用都新建闭包——避免每次 LLM 工具调用的额外分配(tool/tool.ts:110-111 注释)。
  • invalid 工具是兜底。 当 AI SDK 修不好一个坏工具调用(比如工具名都不对),experimental_repairToolCall 会把它改写成调用 invalid 工具(llm.ts:296-311),把错误信息原样还给模型。
  • 截断有逃逸阀。 若工具自己已设了 metadata.truncated(比如 read 工具自管分页),外壳就不再二次截断(tool/tool.ts:131-133)。
  • read 工具的边界。 默认读 2000 行、每行最多 2000 字符、总量上限 50KB(tool/read.ts:13-16),并在读前后做 LSP 预热以便后续诊断。

8. 代码地图

主题文件路径符号名
工具统一外壳packages/opencode/src/tool/tool.tsdefine, wrap, Context, ExecuteResult
参数错误(模型可读)packages/opencode/src/tool/tool.tsInvalidArgumentsError
注册表收集/过滤packages/opencode/src/tool/registry.tsService, all, tools, fromPlugin
edit/patch 模型分流packages/opencode/src/tool/registry.tstools(usePatch 判断)
会话内工具解析 + 权限包裹packages/opencode/src/session/tools.tsresolve
read 工具packages/opencode/src/tool/read.tsReadTool, DEFAULT_READ_LIMIT
task(子 agent)工具packages/opencode/src/tool/task.tsTaskTool