工具契约与注册表

Tool.define 的契约与包装、ToolRegistry 的内置清单与过滤规则、每轮如何把工具交给模型,以及截断、无效调用和工作区边界。

作者 David更新于 第 13 篇(共 38 篇)

现役(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总是注册,但不开放给模型(见下文)
questionOPENCODE_CLIENT 为 app、cli、desktop(默认 cli),或设置 OPENCODE_ENABLE_QUESTION_TOOL
executeOPENCODE_EXPERIMENTAL_CODE_MODE
lspOPENCODE_EXPERIMENTAL_LSP_TOOL
plan_exitOPENCODE_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 的补丁格式。

本页目录