# Tool 调用系统

> 本章以 OpenCode 源码版本 `eec0843ce422` 为证据基线。我们追踪一条典型源码路径：本轮 agent 获得 `read` 工具，模型提交参数后，OpenCode 怎样校验、申请权限、执行并返回结果？这条路径由源码结构证明，不代表模型一定会在某次真实会话中选择 `read`。

## 0. 本章学习目标

学完这一章，你应该能够：

1. 画出 `Tool.define -> ToolRegistry -> SessionTools.resolve -> model execute` 的位置图。
2. 区分工具定义、registry 中的工具、本轮暴露给模型的工具与一次 tool call。
3. 沿源码解释参数校验、schema 转换、权限、metadata、插件 hook 和输出截断的位置。
4. 说明内置工具、项目工具、plugin tool 与 MCP tool 怎样汇入同一调用表。
5. 判断模型选择工具、权限允许工具和工具成功执行为何是三件不同的事。
6. 为自己的 mini agent 设计一个可扩展、可审计的工具边界。

## 1. 一句话讲明白

OpenCode 的 Tool 系统是**能力注册表加安全执行适配器**：registry 决定“这一轮有哪些能力”，`SessionTools.resolve` 决定“模型怎样调用它们”，具体工具只实现受 Schema 与 Context 约束的动作。

本章的中心问题是：

> **模型输出 `{ tool: "read", args: ... }` 后，为什么不会直接变成一次不受控的函数调用？**

因为模型只提出调用；OpenCode 仍要经过工具筛选、provider schema 转换、运行时参数解码、权限判断、执行 hook、输出截断和附件归属补全。核心合同见 `packages/opencode/src/tool/tool.ts:16-45`、`packages/opencode/src/session/tools.ts:24-116`。

## 2. 为什么工具不是“给模型一组函数”这么简单

一个本地函数只需要参数和返回值；一个 coding-agent tool 还必须回答：

- 它怎样向模型描述自己？
- 模型给的 JSON 参数若不合法，谁拦住？
- 当前 agent/session 是否允许这次动作？
- 如何响应取消？
- 工具执行进度怎样让 UI 看见？
- 超长输出怎样避免挤爆上下文？
- 插件与 MCP 工具怎样接入而不改 agent loop？

所以 Tool 不是裸函数，而是一条能力边界。生活类比可以是工具房：目录告诉你有哪些工具，领用窗口核对身份与用途，真正的器械执行动作，结果再登记回工单。类比只用于看形状；真实标识符是 `Tool.Def`、`ToolRegistry.Service` 与 `SessionTools.resolve`。

## 3. 先画地图：从定义到一次执行

```text
内置 Tool.define            项目 tool/*.ts            plugin tool
      |                           |                         |
      +------------- ToolRegistry.State ------------------+
                             |
                     按 model / flags 筛选
                             |
                             v
                   SessionTools.resolve
                             |
            +----------------+----------------+
            |                                 |
      registry tools                       MCP tools
            |                                 |
            +------ schema + execute ----------+
                             |
                             v
                    Record<string, AITool>
                             |
                             v
                        LLM runtime
                             |
                       model tool call
                             |
                             v
              Context -> validate -> ask -> execute
                             |
                             v
                  result / attachments / error
```

| 层 | 它回答的问题 | 它不负责什么 |
| --- | --- | --- |
| `Tool.define` / `Tool.Def` | 单个能力怎样描述、校验、执行 | 不决定本轮是否可见 |
| `ToolRegistry` | 系统有哪些工具，当前 model/flag 下留下哪些 | 不绑定本次 session/call id |
| `SessionTools.resolve` | 怎样绑定 session、permission、processor 与 AI SDK | 不决定模型选择哪一个 |
| 具体 tool | 怎样执行 read/edit/shell 等动作 | 不维护整个 agent loop |
| processor/loop | 怎样记录结果并决定下一轮 | 不属于本章的工具注册内核 |

通用 Agent 最少需要 definition、registry、runtime adapter 三层；OpenCode 又加入 Effect Schema、插件、MCP、agent 权限、输出文件化截断和 provider schema 兼容。

## 4. 最小机制：先看 16 行能力边界

```ts
type ToolDef<A> = {
  name: string
  description: string
  schema: RuntimeSchema<A>
  execute(args: A, ctx: ToolContext): Promise<ToolResult>
}

function toolsForRound(registry: ToolDef<unknown>[], round: RoundContext) {
  return Object.fromEntries(registry.map((def) => [
    def.name,
    llmTool({
      description: def.description,
      inputSchema: adaptSchema(def.schema, round.model),
      async execute(raw, call) {
        const args = decode(def.schema, raw)
        const ctx = bindContext(round, call)
        return def.execute(args, ctx)
      },
    }),
  ]))
}
```

这不是源码逐行翻译。OpenCode 的 wrapper 还负责 tracing 与截断，session adapter 还负责 permission、metadata、plugin hook 和附件 ID。

## 5. 读源码前，只分清四种对象

### 5.1 `Tool.Info`：可延迟初始化的注册信息

```ts
export interface Info<P, M> {
  id: string
  init: () => Effect.Effect<DefWithoutID<P, M>>
}
```

来源：`packages/opencode/src/tool/tool.ts:51-57`

内置工具模块通常先暴露这种 info；依赖齐备后，`Tool.init(info)` 才得到完整 `Tool.Def`。

### 5.2 `Tool.Def`：可执行的内部工具

`Tool.Def` 包含 id、description、运行时 parameters、可选 jsonSchema、execute 与可选验证错误格式化函数。来源：`packages/opencode/src/tool/tool.ts:35-45`。

### 5.3 AI SDK `Tool`：发给模型 runtime 的适配形状

`SessionTools.resolve` 把内部 `Tool.Def` 包装成 AI SDK 的 `{ description, inputSchema, execute }`。它已经绑定本轮 session、assistant message、agent 和 permission。

### 5.4 tool call：模型提出的一次动作

tool call 带 tool name、call id 与 args。它不是定义，也不是授权。一次定义可以被调用多次，每次都有不同 call id、abort signal 与权限判断。

## 6. 追一条典型源码旅程：`read` 怎样成为本轮能力

本章不进入 `read.ts` 的路径解析细节；只追它如何进入 registry、暴露给模型并执行统一 wrapper。文件读写的内部行为由下一章展开。

### 6.1 第一站：工具合同把执行环境显式化

`Tool.Context` 包含：

```ts
{
  sessionID,
  messageID,
  agent,
  abort,
  callID?,
  extra?,
  messages,
  metadata(...),
  ask(...),
}
```

来源：`packages/opencode/src/tool/tool.ts:16-26`

<!-- source-ref path="packages/opencode/src/tool/tool.ts" lines="16-45" title="工具执行合同" note="参数与返回值只是半个合同；session、取消、进度和权限也属于执行边界。" -->

`ExecuteResult` 统一返回 title、metadata、output 与可选 attachments。来源：`packages/opencode/src/tool/tool.ts:28-33`。

为什么 context 不使用全局变量？同一进程可能同时处理不同 session/call；显式 context 才能正确归属状态、权限和取消。

### 6.2 第二站：`Tool.define` 装上统一 wrapper

`Tool.define(id, init)` 取得 Truncate 与 Agent service，返回带静态 `id` 的 Effect；之后 `Tool.init` 执行延迟 init 并补回 id。来源：`packages/opencode/src/tool/tool.ts:132-161`。

真正的公共护栏在 `wrap`：

1. 预编译 `Schema.decodeUnknownEffect(parameters)`；
2. 每次调用先 decode 模型传入的未知 JSON；
3. 调具体 `execute(decoded, ctx)`；
4. 若工具未自行声明截断信息，按 agent 规则统一截断；
5. 写入 tracing span 属性。

来源：`packages/opencode/src/tool/tool.ts:79-130`。

<!-- source-ref path="packages/opencode/src/tool/tool.ts" lines="79-130" title="内置工具的参数校验与输出截断 wrapper" note="decode 在 execute 之前；truncate 在具体工具返回之后。" -->

这回答了第一个安全问题：模型参数是“不可信的 unknown”，不能因为 TypeScript 标了类型就跳过运行时校验。

### 6.3 第三站：registry 初始化内置工具

`ToolRegistry.layer` 先取得各工具的 `Info`，再用 `Tool.init` 并发初始化 invalid、shell、read、glob、grep、edit、write、task 等工具。来源：`packages/opencode/src/tool/registry.ts:120-139`、`packages/opencode/src/tool/registry.ts:229-249`。

最终 builtin 数组还受 runtime flags 影响，例如 question、background task status、experimental scout/LSP/plan。来源：`packages/opencode/src/tool/registry.ts:251-275`。

所以“OpenCode 有哪些内置工具”不是一个只由 import 列表决定的常量；客户端类型和实验开关会影响实际集合。

### 6.4 第四站：项目工具与 plugin tool 进入同一 registry

registry 扫描配置目录中的 `{tool,tools}/*.{js,ts}`，动态 import，并把合法 export 转为内部 `Tool.Def`；随后再收集 `plugin.list()` 中的 `p.tool`。来源：`packages/opencode/src/tool/registry.ts:203-224`。

plugin 公共 API 很轻：

```ts
tool({
  description,
  args,
  async execute(args, context) { /* ... */ },
})
```

来源：`packages/plugin/src/tool.ts:45-52`

Plugin ToolContext 还暴露 directory、worktree、abort、metadata 与 Promise 形式的 ask。来源：`packages/plugin/src/tool.ts:3-27`。

registry 的 `fromPlugin` 是兼容盒：把 Zod args 转为 JSON Schema，把 Promise context 与 Effect context 桥接，规范化字符串/对象结果，并为插件输出截断。来源：`packages/opencode/src/tool/registry.ts:145-200`。

<!-- source-ref path="packages/opencode/src/tool/registry.ts" lines="145-200" title="plugin tool 适配成内部 Tool.Def" note="公共插件 API 保持 Promise/Zod 形状，Effect 与截断细节留在宿主边界。" -->

### 6.5 第五站：registry 按本轮 model 与 flags 过滤

`ToolRegistry.tools(...)` 从所有 builtin/custom 工具中筛选：

- web search 是否可用由 provider 与 runtime flags 决定；
- 部分 GPT 模型使用 patch，其他模型保留 edit/write；
- task/skill description 会按当前 agent 动态补充可用 subagent/skill；
- `tool.definition` hook 可以修改 description、parameters/jsonSchema。

来源：`packages/opencode/src/tool/registry.ts:288-367`。

<!-- source-ref path="packages/opencode/src/tool/registry.ts" lines="322-367" title="本轮 registry 工具筛选与描述扩展" note="registry 管可见能力；它还没有绑定 session 或真正执行。" -->

这意味着 registry 不是静态 `Map<String, Tool>`，而是一个按 model、agent 与运行标志求值的能力目录。

### 6.6 第六站：`SessionTools.resolve` 绑定本次调用环境

Agent loop 把 agent、model、session、processor、messages 与 promptOps 交给 `SessionTools.resolve`。它为每个工具构造本轮 `Tool.Context`：

```ts
const context = (args, options): Tool.Context => ({
  sessionID: input.session.id,
  messageID: input.processor.message.id,
  callID: options.toolCallId,
  abort: options.abortSignal!,
  agent: input.agent.name,
  messages: input.messages,
  metadata: /* 更新当前 tool call 状态 */,
  ask: /* 合并权限后询问 */,
})
```

节选自 `packages/opencode/src/session/tools.ts:42-73`。

`metadata(...)` 调 processor 的 `updateToolCall`，把 pending/running part 更新为 running，并记录 title、metadata、input 与开始时间。`ask(...)` 合并 agent permission 与 session permission，再交给 Permission service。来源同上。

<!-- source-ref path="packages/opencode/src/session/tools.ts" lines="42-73" title="每次 tool call 的 session 与权限上下文" note="同一个 Tool.Def 因调用轮次不同而获得不同 messageID、callID 与 ruleset。" -->

### 6.7 第七站：Schema 先适配 provider，再交给模型

对 registry 中每个工具，代码先取得 JSON Schema，并调用 `ProviderTransform.schema(input.model, ...)`，再包装为 AI SDK tool：

```ts
const schema = ProviderTransform.schema(
  input.model,
  ToolJsonSchema.fromTool(item),
)

tools[item.id] = tool({
  description: item.description,
  inputSchema: jsonSchema(schema),
  execute(args, options) { /* ... */ },
})
```

来源：`packages/opencode/src/session/tools.ts:75-115`

为什么内部 parameters 已经能校验，还要转换 JSON Schema？两者面向不同边界：Effect Schema 保护本地执行，JSON Schema 告诉当前 provider/model 应怎样生成参数；某些 provider 对 JSON Schema 子集还有额外限制。

### 6.8 第八站：模型调用 `read` 后，执行仍经过 hook 与 wrapper

AI SDK 调用该 `execute` 时，OpenCode：

1. 用 args/options 创建本轮 context；
2. 触发 `tool.execute.before`，允许插件修改 args；
3. 调用 `item.execute(args, ctx)`；
4. 为返回附件补 `PartID`、sessionID 与 assistant messageID；
5. 触发 `tool.execute.after`；
6. 把 output 返回给 LLM runtime。

来源：`packages/opencode/src/session/tools.ts:84-115`。

`item.execute` 对内置工具已经被 `Tool.wrap` 包装，所以真实顺序可以重新组装为：

```text
provider 生成 args
 -> before hook
 -> Schema decode
 -> 具体 read.execute
 -> output truncate
 -> 附件补归属 ID
 -> after hook
 -> 返回 runtime
```

<!-- source-ref path="packages/opencode/src/session/tools.ts" lines="75-115" title="内部工具到 AI SDK execute 的桥" note="这一段把 registry 能力变成模型真的可以调用的函数。" -->

本章证据到“返回 runtime”为止。正常结果怎样经 LLM event 被 processor 持久化成 completed ToolPart，是上一章和 Agent 核心循环的职责；不要误称 `SessionTools.resolve` 自己完成了所有落库。

### 6.9 第九站：MCP 工具并行汇入同一个表

`SessionTools.resolve` 还遍历 `mcp.tools()`：

- 把 MCP input schema 转为 JSON Schema，并做 provider transform；
- 执行前统一 `ctx.ask({ permission: key, patterns: ["*"] ... })`；
- 触发相同 before/after hooks；
- 把 MCP text/resource/image content 归一为 output 与 attachments；
- 用相同 truncate service 处理文本输出。

来源：`packages/opencode/src/session/tools.ts:118-203`。

MCP 工具没有先变成 registry `Tool.Def`，但在本轮最终都进入同一个 `Record<string, AITool>`。这是一种“汇合在 runtime adapter”而非“强迫所有来源共享定义类型”的设计。

## 7. 五个决定安全与可维护性的分支

### 7.1 模型选择不等于权限允许

模型只能看到暴露的工具并产生调用；具体工具在需要受保护动作时调用 `ctx.ask(...)`。ruleset 来自 agent 与 session permission 合并。来源：`packages/opencode/src/session/tools.ts:64-72`。

可见性和授权是两道门，不能用“没暴露某工具”替代所有执行时检查。

### 7.2 TypeScript 类型不等于运行时可信

模型输出来自进程外，编译期泛型无法保证 JSON 合法。`Schema.decodeUnknownEffect` 在工具 wrapper 中做真正的运行时解码。来源：`packages/opencode/src/tool/tool.ts:87-111`。

### 7.3 输出过长也属于工具边界

内置工具在 `Tool.wrap` 统一截断；plugin adapter 和 MCP adapter 也分别调用 Truncate。截断 metadata 记录 `truncated` 与可选 `outputPath`。来源：`packages/opencode/src/tool/tool.ts:111-125`、`packages/opencode/src/tool/registry.ts:174-188`、`packages/opencode/src/session/tools.ts:177-193`。

这样做保护模型上下文，但调用者必须知道展示内容可能不是完整结果。

### 7.4 取消后仍需要尽量闭合工具状态

context 把 AI SDK `abortSignal` 传给具体工具。若返回时 signal 已 aborted，adapter 会调用 processor 的 `completeToolCall` 作为收口路径。来源：`packages/opencode/src/session/tools.ts:42-49`、`packages/opencode/src/session/tools.ts:108-110`。

源码能证明这条 abort 分支；普通成功的最终状态仍由 LLM event/processor 主链处理。

### 7.5 Tool ID 是协议的一部分

registry 用 id 作为 tools 对象 key，插件 default export 又会从文件名派生 namespace。来源：`packages/opencode/src/tool/registry.ts:203-216`、`packages/opencode/src/session/tools.ts:75-81`。改名不仅是重构函数名，也可能改变模型提示、权限规则与历史 tool call 的协议标识。

## 8. OpenCode 的选择：替代方案与代价

| 设计问题 | OpenCode 的选择 | 可选做法 | 收益与代价 |
| --- | --- | --- | --- |
| 工具怎样组织 | definition -> registry -> per-session adapter | loop 内 `switch(toolName)` | 易扩展、职责清晰；调用链更长 |
| 参数怎样保护 | 编译期类型 + 运行时 Schema decode | 只信 TypeScript 类型 | 外部 JSON 更安全；schema 要维护 |
| 权限放哪里 | Context 中按调用询问 | 注册时一次性决定 | 能结合具体路径/命令；每个工具必须正确调用 ask |
| provider schema 差异放哪里 | 暴露本轮工具时 transform | 每个工具写 provider 分支 | 工具实现干净；adapter 更复杂 |
| 多种工具来源怎样汇合 | registry 与 MCP 在 SessionTools 汇合 | 强制所有来源使用同一 SDK | 保留各自协议；存在两套适配逻辑 |

表中选择由源码直接证明；收益与代价是设计解释。

## 9. TypeScript / Effect：只补四个阅读障碍

### 9.1 泛型 Schema 与参数类型

`Def<Parameters extends Schema.Decoder<unknown>>` 让参数类型从 Schema 推导；Schema 同时提供运行时 decode。这比 Java 泛型多了一层运行时合同。

### 9.2 `Object.assign(effect, { id })`

`Tool.define` 返回一个 Effect，同时给它挂上静态 `id` 属性。这样注册代码可在初始化前引用工具 ID。它是函数/对象可组合的 TypeScript 写法，Java 通常会用显式类表示。

### 9.3 `Record<string, AITool>`

它是普通对象形式的名字到工具映射。模型看到的是 key、description 和 schema；真正执行函数留在 runtime 本地。

### 9.4 Effect 与 Promise bridge

OpenCode 内部工具返回 Effect，AI SDK/plugin API 使用 Promise。`EffectBridge.make()` 在调用边界把 Effect 安全地运行成 Promise，同时保留当前服务上下文。来源：`packages/opencode/src/session/tools.ts:34-35`、`packages/opencode/src/tool/registry.ts:163-174`。

## 10. 可以带走的方法

### 方法一：把工具拆成“定义、发现、绑定、执行”

定义只描述能力；registry 负责发现；每轮 adapter 绑定 session/permission；执行处理具体动作。

验证问题：同一个 read tool 能否被两个 session 同时调用而不串 messageID？

### 方法二：在最靠近副作用的位置授权

工具可见性先缩小攻击面，`ctx.ask` 再根据具体参数做执行时授权。

验证问题：同一个 shell 工具能否允许安全命令、询问敏感命令，而不是只能全开或全关？

### 方法三：让失败与截断成为显式协议

参数错误要返回可理解信息，超长输出要标记 truncated，附件要补全归属 ID。

验证问题：模型和 UI 能否区分“完整输出”“被截断输出”和“工具失败”？

## 11. 费曼复述与练习阶梯

### 11.1 60 秒复述

不要看上文，补全：

> `Tool.Def` 描述 ______；`ToolRegistry` 决定 ______；`SessionTools.resolve` 绑定 ______。模型给出的 args 先经过 ______，受保护动作通过 ______ 申请权限。结果过长时由 ______ 处理，最后回到 ______，而不是工具层自己决定 agent 是否继续。

如果你把 ToolRegistry 与 SessionTools 说成同一个“工具列表”，请回看 `packages/opencode/src/tool/registry.ts:322-367` 和 `packages/opencode/src/session/tools.ts:24-116`。

### 11.2 读源码练习

1. 入门：列出 `Tool.Context` 与 `ExecuteResult` 的字段。
2. 进阶：从 registry 的 `read` 初始化追到 AI SDK tools 对象中的 `read` key。
3. 辨析：解释内部 parameters、jsonSchema 与 provider-transformed inputSchema 的区别。
4. 失败路径：画出非法参数、权限拒绝、执行异常、输出截断四条分支。

### 11.3 小实现

实现一个 mini tool runtime：支持两个内置工具和一个 plugin tool；所有 raw args 必须运行时校验；每次调用绑定 sessionID/callID；副作用前询问 permission；输出超过上限时保存完整内容并返回摘要与路径。

## 12. 最后复盘：工具已经接通，接下来进入具体副作用

本章主链是：

```text
Tool.define
  -> ToolRegistry 发现并筛选
  -> SessionTools.resolve 绑定本轮上下文
  -> provider schema + AI SDK execute
  -> validate / permission / hook / execute / truncate
  -> result 返回 LLM runtime
```

你现在应该能回答中心问题：模型只提出工具调用；OpenCode runtime 才拥有定义、授权和执行能力，并把每次动作绑定到明确的 session/message/call。

下一章要把抽象能力落到最典型的副作用：`read`、`edit`、`write` 怎样处理路径、权限、diff、格式化与诊断？这就是“文件读写与代码修改”。
