02 · 工具系统
本章讲:工具(agent 的手脚)是怎么定义、注册、被模型选中并执行的。
1. 它要解决的小问题
模型只会说"我想调用 edit,参数是 {...}"。要把这句话变成真实副作用,需要:① 一份模型能看懂的工具描述 + JSON Schema;② 把模型给的参数解码并校验;③ 真正执行并产生结果;④ 结果太长要截断,免得撑爆下一轮上下文。opencode 把这四步固化成一个统一外壳 Tool.define。
2. 统一外壳 Tool.define
所有工具都长一个样:一个 id、一段 description、一个参数 schema、一个 execute。Tool.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 控制(如 lsp、plan 只在特定客户端/flag 下出现)。
按模型过滤的两个真实规则(registry.ts:267-279 的 tools):
websearch只在 provider 支持时给(webSearchEnabled,registry.ts:56)。- edit vs apply_patch 二选一:对 GPT 系模型(
gpt-且非 oss/gpt-4)用apply_patch工具,其它模型用edit/write。这是因为不同模型家族被训练成偏好不同的编辑格式。
5. 插件工具怎么桥接
插件工具用 Zod 声明参数(历史兼容),而内置工具用 Effect Schema。fromPlugin(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.txt、tool/read.txt、tool/grep.txt…),编译时被导入。这样 prompt 工程师能直接改描述而不碰逻辑。task 工具的描述还会动态拼上"当前可用的子 agent 类型"(registry.ts:252 的 describeTask)。
7. 关键细节 / 坑
- schema 解码闭包只建一次。
wrap里Schema.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.ts | define, wrap, Context, ExecuteResult |
| 参数错误(模型可读) | packages/opencode/src/tool/tool.ts | InvalidArgumentsError |
| 注册表收集/过滤 | packages/opencode/src/tool/registry.ts | Service, all, tools, fromPlugin |
| edit/patch 模型分流 | packages/opencode/src/tool/registry.ts | tools(usePatch 判断) |
| 会话内工具解析 + 权限包裹 | packages/opencode/src/session/tools.ts | resolve |
| read 工具 | packages/opencode/src/tool/read.ts | ReadTool, DEFAULT_READ_LIMIT |
| task(子 agent)工具 | packages/opencode/src/tool/task.ts | TaskTool |