工具契约与注册表
Tool.define 的契约与包装、ToolRegistry 的内置清单与过滤规则、每轮如何把工具交给模型,以及截断、无效调用和工作区边界。
现役(V1)工具都在 packages/opencode/src/tool/ 下。一个工具从定义到被模型调用,要依次经过契约、注册表、逐轮组装和请求准备四层。
契约:Def 与 Context
Tool.define(id, init) 接收一个 Effect,初始化后产出 description(多半来自同目录的 .txt)、parameters(Effect Schema)、可选的 jsonSchema、execute 与 formatValidationError。执行时拿到的上下文是:
export type Context<M extends Metadata = Metadata> = {
sessionID: SessionID
messageID: MessageID
agent: string
abort: AbortSignal
callID?: string
extra?: { [key: string]: unknown }
messages: SessionV1.WithParts[]
metadata(input: { title?: string; metadata?: M }): Effect.Effect<void>
ask(input: Omit<PermissionV1.Request, "id" | "sessionID" | "tool">): Effect.Effect<void>
}(packages/opencode/src/tool/tool.ts:36)
两个回调最关键:metadata() 把标题和元数据写回正在运行的工具部件,TUI 据此实时刷新,Shell 的滚动输出就靠它;ask() 发起权限询问,工具只填 permission、patterns、always、metadata,会话与调用定位由上下文补齐,详见权限系统。
extra 是个杂物袋:会话层放进 model、bypassAgentCheck 和给 task 用的 promptOps;用户附加文件时,会话层直接调用 read 并带上 bypassCwdCheck。返回值只有四项:title、metadata、output(给模型的文本)与可选的 attachments。
wrap:白送的校验与截断
define 返回的并不是原始 execute,而是包了一层(删减后):
const decode = Schema.decodeUnknownEffect(toolInfo.parameters)
const execute = toolInfo.execute
toolInfo.execute = (args, ctx) => {
// ...
return Effect.gen(function* () {
const decoded = yield* decode(args).pipe(
Effect.mapError(
(error) =>
new InvalidArgumentsError({
tool: id,
detail: toolInfo.formatValidationError ? toolInfo.formatValidationError(error) : String(error),
}),
),
)
const result = yield* execute(decoded as Schema.Schema.Type<Parameters>, ctx)
if (result.metadata.truncated !== undefined) {
return result
}
const agent = yield* agents.get(ctx.agent)
const truncated = yield* truncate.output(result.output, {}, agent)
// ...
}).pipe(Effect.orDie, Effect.withSpan("Tool.execute", { attributes: attrs }))
}(packages/opencode/src/tool/tool.ts:111)
一是参数解码失败变成 InvalidArgumentsError,它的 message 是写给模型的“请按 schema 重写输入”;二是工具没表态(metadata.truncated 未设置)时,输出统一过一遍截断。read、bash 这类自带分页或截断的工具会显式设置该字段,避免被截两次。最后的 Effect.orDie 把失败变成异常交给 AI SDK,由流处理器记为工具部件的 error 状态。
截断:超长输出落盘
truncate.ts 默认在 2000 行或 50 KB 处截断,配置 tool_output.max_lines / max_bytes 可覆盖。超限时全文写入数据目录下的 tool-output,返回预览与提示;若当前 agent 能用 task,提示会建议把大文件外包给子代理(节选自 packages/opencode/src/tool/truncate.ts:130):
Use the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.落盘文件保留 7 天,每小时清理一次。所有 agent 的 external_directory 都默认放行这个目录(packages/opencode/src/agent/agent.ts:296),模型事后用 read、grep 翻看不会触发询问。
注册表:内置清单与自定义来源
| 工具 ID | 注册条件 |
|---|---|
bash、read、glob、grep、edit、write、task、webfetch、todowrite、websearch、skill、apply_patch | 总是注册 |
invalid | 总是注册,但不开放给模型(见下文) |
question | OPENCODE_CLIENT 为 app、cli、desktop(默认 cli),或设置 OPENCODE_ENABLE_QUESTION_TOOL |
execute | OPENCODE_EXPERIMENTAL_CODE_MODE |
lsp | OPENCODE_EXPERIMENTAL_LSP_TOOL |
plan_exit | OPENCODE_EXPERIMENTAL_PLAN_MODE 且客户端为 cli |
后三个开关也会被总开关 OPENCODE_EXPERIMENTAL 一并打开。自定义工具有两个来源:配置目录下的 {tool,tools}/*.{js,ts}(默认导出以文件名为 ID,具名导出为 文件名_导出名),以及插件的 tool 字段。插件工具对外仍用 Zod 描述参数,注册表在边界上做兼容:
Plugin tools still expose Zod args publicly; keep that compatibility boxed at the registry boundary and give the LLM the original JSON Schema.
(packages/opencode/src/tool/registry.ts:126)
插件 API 见插件系统。
按模型过滤
registry.tools() 每轮根据模型再筛一遍:
const usePatch =
input.modelID.includes("gpt-") && !input.modelID.includes("oss") && !input.modelID.includes("gpt-4")
if (tool.id === ApplyPatchTool.id) return usePatch
if (tool.id === EditTool.id || tool.id === WriteTool.id) return !usePatch(packages/opencode/src/tool/registry.ts:297)
GPT 系列(gpt-4 与 oss 除外)拿 apply_patch,其余模型拿 edit + write,两套编辑工具互斥。websearch 只在使用 opencode 自家提供方或开启 Exa、Parallel 时出现;execute 在没有可见 MCP 工具时隐藏。之后每个工具还要过插件钩子 tool.definition,允许改写说明和参数;task 的说明末尾会追加当前 agent 可调用的子代理清单。
每轮组装:SessionTools.resolve
packages/opencode/src/session/tools.ts 把 Tool.Def 逐个包成 AI SDK 的 tool():参数先经 ToolJsonSchema.fromTool 转成 JSON Schema 并规整(去掉可选字段的 null 分支、内联 $ref、给整数补安全范围等),再经 ProviderTransform.schema 按提供方修补(见提供方适配层);execute 前后触发 tool.execute.before / tool.execute.after 插件钩子,并给附件分配部件 ID。
MCP 工具也在这里并入(详见 MCP 集成):
- 键名是
服务器名_工具名(非法字符换成下划线),权限名就是这个键,模式固定为*; - 任一服务器支持 resources 时,额外注入
list_mcp_resources、list_mcp_resource_templates、read_mcp_resource,按read权限询问; - 开启 Code Mode 时在这之前直接返回,MCP 工具改由
execute调度(见联网工具与 Code Mode)。
真正发请求前,packages/opencode/src/session/llm/request.ts:208 的 resolveTools 还会剔除被权限整体拒绝的工具,并把工具表按名字排序。
无效调用:invalid 工具
模型会拼错工具名,也会给出通不过校验的参数。AI SDK 此时回调 experimental_repairToolCall(删减后):
async experimental_repairToolCall(failed) {
const lower = failed.toolCall.toolName.toLowerCase()
if (lower !== failed.toolCall.toolName && prepared.tools[lower]) {
// ...
}
return {
...failed.toolCall,
input: JSON.stringify({
tool: failed.toolCall.toolName,
error: failed.error.message,
}),
toolName: "invalid",
}
},(packages/opencode/src/session/llm.ts:296)
只是大小写不对就改成小写的工具名,其他情况一律改道到 invalid 工具,它只回一句 The arguments provided to the tool are invalid: ...。invalid 不在 activeTools 里,模型看不到它;坏调用由此变成一条普通的工具结果,循环照常继续。
external_directory:工作区边界
external-directory.ts 是文件类工具共用的边界检查:目标在实例目录或 git worktree 内就放行(非 git 项目不看 worktree);否则以 目录/* 为模式询问 external_directory 权限,文件取父目录。read、write、edit、apply_patch、glob、grep、lsp 都调用它,Shell 则自己解析命令找路径(见 Shell 工具)。
V2 内核把这套契约重做成带输入输出编解码器的 Tool.make,截断也收归注册表统一处理,见 V2 工具与权限。
下一篇:文件工具:读、写、改——read 的分页与嗅探、edit 的九级匹配级联,以及 apply_patch 的补丁格式。