opencode/src/tool 目录可以分成 4 类文件:
bash.ts、read.ts、edit.ts、write.ts、apply_patch.ts、glob.ts、grep.ts、webfetch.ts、websearch.ts、codesearch.ts、task.ts、todo.ts、question.ts、skill.ts、lsp.ts、plan.ts、invalid.ts
- 工具描述:与实现文件同名的
*.txt,作为给模型的工具说明文案 - 工具框架:
tool.ts、registry.ts、schema.ts - 工具辅助:
external-directory.ts、truncate.ts、truncation-dir.ts、mcp-exa.ts
推荐从下面几个文件理解整体设计:
registry.tstool.tsschema.tsexternal-directory.ts
2. 当前目录结构
核心框架
tool.ts:定义 Tool.Def、Tool.Context、Tool.define()、Tool.init()registry.ts:构造内置工具集合,加载插件工具和本地自定义工具schema.ts:定义工具 ID 的 schema 和品牌类型
已注册的内置工具
当前 ToolRegistry 默认注册的内置工具包括:
invalidquestion,仅在客户端类型或开关允许时启用bashreadglobgrepeditwritetaskwebfetchtodowritewebsearchcodesearchskillapply_patchlsp,仅在OPENCODE_EXPERIMENTAL_LSP_TOOL 打开时启用
plan_exit,仅在OPENCODE_EXPERIMENTAL_PLAN_MODE
目录中存在但当前未在 ToolRegistry 注册的工具
ls.ts,工具 ID 为 listmultiedit.ts ,工具 ID 为 multiedit
这两个实现文件说明目录中曾经或仍然保留了扩展能力,但默认内置工具集合目前不包含它们。写文档、排查工具缺失、或新增能力时要特别注意“文件存在”不等于“已经暴露给模型”。
3. 工具统一规范
3.1 定义方式
所有工具都通过 Tool.define(id, init) 定义,最终产出一个 Tool.Info:
export const ReadTool = Tool.define( "read", Effect.gen(function* () { return { description: DESCRIPTION, parameters, execute: (params, ctx) => ... } }),)
一个标准工具需要具备:
id:工具唯一标识description:给模型看的能力说明parameters:基于 zod 的参数 schemaexecute(args, ctx):工具执行逻辑formatValidationError(error):可选,自定义参数校验错误格式
3.2 参数规范
Tool.wrap()会在实际执行前调用parameters.parse(args)
- 工具说明里的参数描述要尽量明确边界,例如是否必须是绝对路径、是否允许省略、默认值是什么
3.3 上下文规范
Tool.Context 会为每次调用注入运行时上下文,常用字段包括:
sessionID:当前会话 IDmessageID:当前消息 IDagent:当前执行 agentabort:取消信号callID:本次工具调用 IDmessages:当前上下文消息metadata():更新工具运行中的标题和元数据ask():发起权限申请
3.4 输出规范
工具返回值统一为:
{ title: string metadata: Record<string, unknown> output: string attachments?: [...]}
约束如下:
title 用于 UI 或日志展示metadata 用于记录 diff、诊断、统计等结构化信息output 是提供给模型继续推理的主要文本attachments 用于图片、PDF 等文件内容回传
3.5 截断规范
大多数工具会被 Tool.wrap() 自动包裹:
- 若工具没有自行声明
metadata.truncated,则走 Truncate.output() 自动截断 - 截断后会在
metadata 中补充 truncated 与 outputPath
这意味着工具实现通常只需要关注“正确产出结果”,超长输出的控制默认由框架兜底。
4. 注册机制
4.1 内置工具注册
registry.ts 是工具系统入口。初始化阶段会先实例化内置工具:
InvalidToolTaskToolReadToolQuestionToolTodoWriteToolLspToolPlanExitToolWebFetchToolWebSearchToolBashToolCodeSearchToolGlobToolWriteToolEditToolGrepToolApplyPatchToolSkillTool
随后通过 Tool.init() 变成真正可供模型调用的 Tool.Def,再组装为 builtin 列表。
4.2 动态工具注册
除了内置工具,ToolRegistry 还会注册两类自定义工具:
本地目录扫描
会扫描配置目录下的:
tool/*.jstool/*.tstools/*.jstools/*.ts
扫描到后动态 import(),再把模块导出的 ToolDefinition 包装成平台工具。
插件注入
plugin.list() 返回的插件如果定义了 tool 字段,也会被注册进来。
4.3 自定义工具类型说明
当前可以扩展的工具来源一共有 4 类:
- 内置工具:直接写在
packages/opencode/src/tool 中,由仓库源码维护 - 本地自定义工具:放到配置目录下的
tool/ 或 tools/,由 ToolRegistry 自动扫描 - MCP 工具:由 MCP 服务端提供,在运行时并入模型可用工具集
可以简单理解为:
- 想做长期维护、和核心产品一起发布的能力,用内置工具
4.4 自定义工具接入流程
方式一:新增内置工具
适合需要进入主仓库、长期维护、和内置能力同级的工具。
流程:
- 用
Tool.define("foo", ...) 实现工具 - 在
registry.ts 顶部引入 FooTool - 如有需要,在
ToolRegistry.tools() 中补充可见性过滤逻辑
方式二:添加本地自定义工具
适合不想改主仓库,只想在某个环境或项目里注入额外工具。
流程:
- 默认导出或命名导出一个或多个
ToolDefinition - 启动时由
ToolRegistry 自动扫描并注册
命名规则:
- 如果模块导出名是
default,工具 ID 就是文件名 - 如果模块导出名不是
default,工具 ID 会变成 ${文件名}_${导出名}
例如:
tool/git.tstool/git.ts 里导出 status,工具 ID 会是 git_status
方式三:通过插件添加工具
适合需要和插件一起发布、安装、配置的工具。
流程:
- 启动后
plugin.list() 会把插件工具并入工具注册表
插件工具接口来自 @opencode-ai/plugin,核心结构是:
tool({
description: string,
args: zod shape,
execute(args, context): Promise<string>
})
和内置工具相比,插件工具更轻量,但能力也更受限:
- 返回值默认是字符串,不是完整的
Tool.ExecuteResult - 仍然可以通过
context.ask() 走权限申请
方式四:通过 MCP 暴露工具
适合把外部系统、远程服务、第三方平台能力接入到模型上下文。
流程:
- 服务端暴露 MCP tool schema 和 execute 能力
- 在
SessionPrompt.resolveTools() 中并入模型工具列表
MCP 工具不走 ToolRegistry 的内置/自定义扫描逻辑,但最终在调用体验上和其他工具类似。
4.5 面向模型的过滤与改写
ToolRegistry.tools() 在真正暴露给模型前,还会做一层过滤:
websearch、codesearch 仅在 opencode provider 或启用 OPENCODE_ENABLE_EXA 时开放apply_patch 与 edit/write 会根据模型类型切换,部分 GPT 模型优先用 apply_patchlsp、plan_exit 受实验开关控制task、skill 的描述文本会在返回前动态拼接“当前可用子 agent”或“当前可用 skill”列表
另外,plugin.trigger("tool.definition", ...) 允许插件在工具暴露前修改说明和参数 schema。
4.6 自定义工具最小示例
内置工具示例
import z from "zod"
import { Effect } from "effect"
import { Tool } from "./tool"
const Parameters = z.object({
input: z.string().describe("输入内容"),
})
export const FooTool = Tool.define(
"foo",
Effect.succeed({
description: "处理一段输入文本",
parameters: Parameters,
execute: (params, ctx) =>
Effect.gen(function* () {
yield* ctx.ask({
permission: "foo",
patterns: ["*"],
always: ["*"],
metadata: {},
})
return {
title: "foo",
output: `handled: ${params.input}`,
metadata: {},
}
}),
}),
)
本地/插件工具示例
import { tool } from "@opencode-ai/plugin"
import { z } from "zod"
export default tool({
description: "打印一段输入内容",
args: {
input: z.string().describe("输入内容"),
},
async execute(args, ctx) {
await ctx.ask({
permission: "foo",
patterns: ["*"],
always: ["*"],
metadata: {},
})
return `handled: ${args.input}`
},
})
两类示例的关键差异:
- 本地自定义工具和插件工具使用
@opencode-ai/plugin 提供的 tool() - 插件风格工具通常只返回字符串,由平台再包装成标准结果
5. 运行方法
5.1 调用链
一次工具调用的主链路如下:
SessionPrompt.resolveTools() 向 ToolRegistry 请求当前模型可用的工具列表- 每个工具被转换成 AI SDK 可调用的 function tool
- 模型发起 tool call 后,
session/processor.ts 创建并更新对应的 tool part 状态 SessionPromptplugin.trigger("tool.execute.before", ...)- 工具
execute(args, ctx) 真正执行 plugin.trigger("tool.execute.after", ...)
对自定义工具来说,运行链路也基本一致,只是注册入口不同:
- 内置工具先进入
ToolRegistry.builtin - 本地自定义工具和插件工具先被包装成
Tool.Def - MCP 工具在
SessionPrompt.resolveTools() 中单独并入
5.2 权限申请
工具本身不直接默认拥有权限,而是通过 ctx.ask() 申请。例如:
- 访问工作区外目录时额外申请
external_directory - 访问网络时申请
webfetch 或 websearch
这使得“工具能力”和“权限策略”是分离的,便于做按 agent、按会话、按工作区的控制。
5.3 工作区边界
涉及文件路径的工具通常会先调用 assertExternalDirectoryEffect():
- 如果目标路径位于工作区外,会自动申请
external_directory
这是一条非常关键的安全边界,避免模型在默认情况下越界访问任意目录。
5.4 元数据与诊断
工具执行时可以不断写入 ctx.metadata(),常见用途有:
像 edit、write、apply_patch 在写文件后还会主动触发格式化、文件变更事件和 LSP 诊断收集。
6. 各工具作用说明
下表以“当前注册状态”为准。
工具 ID | 文件 | 状态 | 作用 | 主要风险 |
|---|
invalid
| invalid.ts
| 已注册 | 兜底错误工具,提示本次调用参数无效 | 风险低,主要是误用后影响流程 |
question
| question.ts
| 条件注册 | 向用户发起结构化问题并等待回答 | 可能打断流程,或被滥用于频繁确认 |
bash
| bash.ts
| 已注册 | 执行本地 shell/PowerShell 命令 | 命令执行、删除文件、联网、泄露环境变量 |
read
| read.ts
| 已注册 | 读取文件或目录,支持图片/PDF 附件 | 敏感文件读取、越界访问、二进制内容泄露 |
glob
| glob.ts
| 已注册 | 按 glob 规则查找文件 | 大范围扫描导致信息暴露或性能问题 |
grep
| grep.ts
| 已注册 | 用 ripgrep 搜索文件内容 | 敏感内容批量检索、结果过大 |
edit
| edit.ts
| 已注册 | 基于 old/new 字符串做精确替换 | 误改代码、重复替换、破坏格式或逻辑 |
write
| write.ts
| 已注册 | 整体写入文件内容,不存在则创建 | 覆盖原文件、丢失内容、写入恶意内容 |
task
| task.ts
| 已注册 | 把任务委托给其他子 agent 执行 | 权限扩散、任务链失控、上下文复杂化 |
webfetch
| webfetch.ts
| 已注册 | 拉取指定 URL 内容并转为文本/Markdown/HTML | 外部站点 prompt injection、隐私泄露、带毒内容 |
todowrite
| todo.ts
| 已注册 | 更新当前会话的 todo 列表 | 风险低,主要是状态污染或误导执行节奏 |
websearch
| websearch.ts
| 已注册 | 调用 Exa/MCP 做互联网搜索 | 外部内容污染、事实过时、隐私外发 |
codesearch
| codesearch.ts
| 已注册 | 搜索外部 API/SDK/框架文档与代码上下文 | 引入错误示例、外部代码质量不可控 |
skill
| skill.ts
| 已注册 | 加载 skill 文本和附带资源到上下文 | 指令注入、上下文膨胀、错误匹配技能 |
apply_patch
| apply_patch.ts
| 已注册 | 按结构化补丁新增/修改/删除/移动文件 | 批量破坏文件、删除文件、补丁误配 |
lsp
| lsp.ts
| 实验开关 | 调用语言服务器做定义跳转、引用查询等 | 结果依赖 LSP 状态,可能泄露符号关系 |
plan_exit
| plan.ts
| 实验开关 | 计划完成后请求用户确认并切换到 build agent | 可能导致错误切换 agent 或流程过早进入实现 |
list
| ls.ts
| 未注册 | 目录树列举,带默认忽略规则 | 若重新启用,风险类似 read/glob |
multiedit
| multiedit.ts
| 未注册 | 顺序执行多个 edit 操作 | 若重新启用,风险高于单次 edit |
7. 重点工具补充说明
bash
特点:
风险重点:
- 需要重点关注命令拼接、通配符、重定向、环境变量展开
read
特点:
风险重点:
edit / write / apply_patch
特点:
风险重点:
task / skill
特点:
风险重点:
8. 安全风险总览
按风险级别可以粗分为:
高风险
basheditwriteapply_patchtask
原因:
中风险
readglobgrepwebfetchwebsearchcodesearchskilllsp
原因:
- 主要风险是信息泄露、外部内容污染、越界扫描、上下文注入
低风险
invalidtodowritequestionplan_exit
原因:
9. 建议
- 新增工具时,优先复用
Tool.define(),不要绕开统一包装层 - 如果只是项目级定制,优先考虑本地自定义工具或插件工具,而不是直接改内置工具
- 所有路径型工具都应接入
assertExternalDirectoryEffect() - 参数说明要写清绝对路径、默认值、输出限制、危险操作边界
- 尽量在
metadata 中保留可审计信息,如 diff、匹配数、stdout 预览、诊断信息 - 对可能返回超长内容的工具,确认是否需要依赖统一截断,或自行显式控制
- 如果一个工具只在实验阶段开放,应在
registry.ts 里明确受 flag 控制 - 更新工具能力后,同时更新对应的
*.txt 描述文件,避免“实现变了,提示没变” - 插件工具和本地自定义工具虽然更灵活,但同样要做权限申请和安全边界控制
10. 总结
src/tool 的核心职责可以概括为:用 Tool.define() 统一定义工具,用 ToolRegistry 统一注册和过滤工具,用 SessionPrompt 统一把工具暴露给模型,再通过 ctx.ask()、工作区边界检查、输出截断和元数据回写把执行过程纳入可控范围。