🛠️ 工具系统

OC2 看到 runLoop 调 SessionTools.resolve 解析工具。本章拆解 OpenCode 的统一工具模式Tool.define 工厂函数。我们从 46 行的 todo.ts(最小范例)起步,看懂 tool.ts 的 wrap 装饰器如何统一所有工具。对应 LC6 的 @tool 和 LG8 的 ToolNode。

本章目标

  • 理解 Tool.define(id, Effect.gen(...)) 工厂模式
  • 看懂 Def 接口(id + description + parameters + execute)
  • 掌握 wrap 装饰器如何统一校验/截断/追踪
  • 理解 .ts 和 .txt 文件配对的约定(逻辑 vs 描述)
  • 对照 LC6 的 @tool,理解"生产级工具"多了什么

Tool.define:统一入口

OpenCode 所有工具都用 Tool.define 定义。先看它的签名:

📄 tool/tool.ts:151 · define 工厂函数 typescript
export function define<Parameters, Result, R, ID extends string = string>(
  id: ID,                                              // 工具唯一标识
  init: Effect.Effect<Init<Parameters, Result>, never, R>,  // 初始化逻辑(返回 Def)
): Effect.Effect<Info<Parameters, Result>, never, R | Truncate.Service | Agent.Service>
  & { id: ID } {
  return Object.assign(
    Effect.gen(function* () {
      const resolved = yield* init            // 执行初始化
      const truncate = yield* Truncate.Service  // 截断服务(输出太长时截断)
      const agents = yield* Agent.Service       // agent 服务
      return { id, init: wrap(id, resolved, truncate, agents) }  // 用 wrap 包一层
    }),
    { id },
  )
}

Def 接口:工具的四要素

和 LC6 的 @tool 一样,OpenCode 工具也有四要素,但用 Def 接口显式定义:

📄 tool/tool.ts:55 · Def 接口 typescript
export interface Def<Parameters, M extends Metadata> {
  id: string           // 工具唯一标识(如 "todowrite")
  description: string  // 给模型看的描述(对应 LC6 的 docstring)
  parameters: Parameters   // 参数 Schema(对应 LC6 的 args_schema)
  jsonSchema?: JSONSchema7 // 可选:自定义 JSON Schema
  execute(             // ★ 执行逻辑(对应 LC6 的 _run)
    args: Schema.Schema.Type<Parameters>,
    ctx: Context       // 工具上下文(sessionID/messages/ask 等,OC4 详讲)
  ): Effect.Effect<ExecuteResult<M>>
  formatValidationError?(error: unknown): string  // 可选:参数错误格式化
}
LC6 @tool 要素OC3 Tool.define 要素说明
函数名 → nameid工具标识
docstring → descriptiondescription(来自 .txt 文件)给模型看
类型注解 → args_schemaparameters: Schema.Struct参数 schema
函数体 → _runexecute(args, ctx)执行逻辑

最小范例:todo.ts(46 行)

这是理解 OpenCode 工具的最佳起点——最简单的完整工具:

📄 tool/todo.ts · 最小工具范例(注释精简版) typescript
import { Effect, Schema } from "effect"
import * as Tool from "./tool"
import DESCRIPTION_WRITE from "./todowrite.txt"   // ← 描述来自独立的 .txt 文件!
import { Todo } from "../session/todo"

// ① 参数 Schema(对应 LC6 的 args_schema)
export const Parameters = Schema.Struct({
  todos: Schema.mutable(Schema.Array(Todo.Info))
    .annotate({ description: "The updated todo list" }),
})

// ② 定义工具
export const TodoWriteTool = Tool.define(
  "todowrite",                          // id
  Effect.gen(function* () {
    const todo = yield* Todo.Service    // 注入依赖(Effect 的 DI)

    return {
      description: DESCRIPTION_WRITE,   // 从 .txt 导入描述
      parameters: Parameters,
      // ③ 执行逻辑
      execute: (params, ctx) => Effect.gen(function* () {
        // ★ 权限检查(OC4 详讲)——每次写操作都要问
        yield* ctx.ask({
          permission: "todowrite",
          patterns: ["*"], always: ["*"],
        })
        // 实际逻辑
        yield* todo.update({ sessionID: ctx.sessionID, todos: params.todos })
        // 返回结果(title + output + metadata)
        return {
          title: `${params.todos.filter(x => x.status !== "completed").length} todos`,
          output: JSON.stringify(params.todos, null, 2),
          metadata: { todos: params.todos },
        }
      }),
    }
  }),
)
💡 读 todo.ts 学到三件事
  1. .txt 文件分离描述——import DESCRIPTION from "./todowrite.txt"。OpenCode 把工具描述放独立文件,便于维护和迭代(不像 @tool 塞在 docstring 里)。
  2. ctx.ask 权限检查——执行前先问权限(OC4 详讲)。这是生产级工具的关键:危险操作要拦截。
  3. Effect.gen 注入依赖——yield* Todo.Service 拿到 Todo 服务。Effect 的依赖注入让工具可测试、可替换。

.ts 与 .txt 的配对约定

OpenCode 每个工具都是两个文件:.ts 是逻辑,.txt 是描述:

工具逻辑文件描述文件
读文件read.ts (386行)read.txt (14行)
编辑文件edit.ts (737行)edit.txt (10行)
写文件write.ts (104行)write.txt
执行命令shell.ts (645行)shell.txt
子任务task.ts (346行)task.txt (19行)
待办todo.ts (46行)todowrite.txt
⚠️ 为什么描述单独放文件

工具描述(告诉模型"何时用这个工具")需要反复打磨,是影响 Agent 效能的关键。把它放独立 .txt:① 不污染代码逻辑;② 产品/PM 也能改;③ 可以做 A/B 测试;④ 支持多语言/多版本。这是生产级工具管理的最佳实践,比 @tool 的 docstring 更专业。

wrap 装饰器:统一增强

Tool.define 内部用 wrap 给每个工具的 execute 包一层通用逻辑

📄 tool/tool.ts:99-149 · wrap 装饰器(精简还原) typescript
function wrap(id, def, truncate, agents) {
  return async (args, ctx) => {
    // ① 参数 Schema 校验(失败抛 InvalidArgumentsError → 回传给模型重试)
    try {
      args = Schema.decodeSync(def.parameters)(args)
    } catch (error) {
      throw new InvalidArgumentsError({
        tool: id,
        message: def.formatValidationError?.(error) ?? "Invalid arguments",
      })
    }

    // ② 调用真正的 execute
    let result
    try {
      result = await def.execute(args, ctx).pipe(...)
    } catch (error) {
      // 错误处理(转成 ToolInvocationError 或回传)
    }

    // ③ 输出截断(防止超长输出撑爆 context,对应 F2 的记忆管理)
    result.output = await truncate.process(result.output)

    // ④ OpenTelemetry span(追踪每个工具调用)
    return result
  }
}
LLM 调工具 args wrap 装饰器 ① 校验参数 ② 真正 execute ③ 截断输出 ④ OTel span 返回结果 ExecuteResult processor 写为 ToolPart
图 OC3.1 · wrap 在真实 execute 外包了校验/截断/追踪三层通用逻辑
🧭 这是装饰器/中间件模式

wrap 是经典的装饰器模式——在不修改原 execute 的前提下,统一添加横切关注点(校验、截断、追踪)。LC6 的 @tool 内部也做了类似的事(args_schema 校验)。OpenCode 把它做得更显式、更完整(加了 OTel 可观测)。生产代码的通用做法。

registry:工具注册表

所有工具在 registry.ts 注册,runLoop 通过它取工具:

📄 tool/registry.ts · 工具注册(精简) typescript
// registry 内部实例化所有内置工具(每个都是 Tool.define Effect)
const builtInTools = Effect.all([
  ReadTool, WriteTool, EditTool, BashTool, GlobTool, GrepTool,
  TaskTool, TodoWriteTool, WebFetchTool, WebSearchTool, ...
])

// tools(model):按模型过滤返回可用工具
// - GPT 系用 apply_patch(合并编辑),其他用 edit/write 分开
// - websearch 按 provider 可用性过滤
// - 还会注入 MCP server 工具、插件工具

// describeTask:动态生成 task 工具描述(列出可用子 agent)

复杂工具示例:edit.ts

对比 todo.ts 的简单,看一个复杂工具——edit.ts(737 行)体现生产工具的工程细节:

📄 tool/edit.ts 的工程细节(节选) typescript
// edit 工具:基于 oldString/newString 的精确替换
export const EditTool = Tool.define("edit", Effect.gen(function* () {
  return {
    description: DESCRIPTION,  // edit.txt:告诉模型如何用
    parameters: Schema.Struct({
      filePath: Schema.String,
      oldString: Schema.String,   // 要替换的原文(必须精确匹配且唯一)
      newString: Schema.String,   // 替换为的新文本
    }),
    execute: (params, ctx) => Effect.gen(function* () {
      // ① 权限检查(OC4)
      yield* ctx.ask({ permission: "edit", patterns: [params.filePath] })

      // ② 每文件加 Semaphore 锁(防止并发编辑同一文件冲突)
      const sem = fileLocks.getOrCreate(params.filePath)
      yield* sem.take(1)
      // ... 执行替换(引用 cline/gemini-cli 的 diff 算法)
      yield* sem.release(1)

      // ③ 返回 diff 让模型看到改动
      return { title: "已编辑", output: diff, ... }
    }),
  }
}))

生产工具比教学工具多的工程细节:

从 Tool.Def 到 AI SDK tool

runLoop 调用的 SessionTools.resolve 做的事——把 OpenCode 的 Tool.Def 包成 AI SDK 的 tool()

📄 session/tools.ts:39 · resolve 把 Tool.Def 转 AI SDK tool(精简) typescript
export function resolve({ agent, session, model, processor, messages }) {
  return Effect.gen(function* () {
    const tools = yield* registry.tools(model)  // 取所有工具

    const result = {}
    for (const [id, def] of Object.entries(tools)) {
      result[id] = tool({                        // AI SDK 的 tool()
        description: def.description,
        inputSchema: def.parameters,             // Schema 转 JSON Schema
        execute: async (args, options) => {
          // 构建 Tool.Context(sessionID/messages/ask/metadata)
          const ctx = buildContext(args, options, agent, session)
          // 调用真正的 execute(经 wrap 包装的)
          const result = await def.execute(args, ctx)
          // processor 处理结果(流式写为 ToolPart)
          return result.output
        },
      })
    }
    return result   // 返回 { read: tool(...), edit: tool(...), ... }
  })
}

与框架对照

OpenCode 概念LangChain/LangGraph 对应
Tool.define(id, Effect.gen)@tool 装饰器(LC6)
Def 接口(id+desc+params+execute)BaseTool(name+description+args_schema+_run)
wrap 装饰器(校验/截断/追踪)@tool 内部的 schema 校验
.txt 描述文件docstring
registry.tools(model)LG8 ToolNode(tools)
SessionTools.resolvebind_tools 把工具包给模型(LC3)
ctx.ask 权限LG6 interrupt(HITL)
truncate 截断输出RemoveMessage 裁剪(LG2)

小结

下一章

工具里有 ctx.ask 权限检查。下一章 OC4 · 权限系统:拆解 OpenCode 的三层权限合并(默认/配置/用户)和 ctx.ask 人在回路机制——这是 LG6 interrupt 的生产落地,也是真实 Agent 的安全基石。