首页
/ opencode 工具层 Effect 迁移指南:Tool.define 目标形态、Effect 原生服务与测试对齐

opencode 工具层 Effect 迁移指南:Tool.define 目标形态、Effect 原生服务与测试对齐

2026-09-06 12:19:37作者:邓越浪Henry

本文基于 opencode 仓库中的迁移规格文档 tools.md 展开,梳理 packages/opencode 内建工具(tool)迁移到 Effect 的当前状态:Tool.define / Info.init() / execute 的 Effect 化目标形态、16 个已导出工具的盘点与剩余清理项,以及如何用仓库自带的 Effect 测试助手(testEffect / it.live)让工具测试与生产服务图保持一致。读完本文,你可以独立判断一个工具是否已完成 Effect 迁移、识别工具体内残留的 Promise / 平台桥接,并按仓库既有规范编写对齐服务图的工具测试。

一、迁移状态:不再是“是否要迁到 Effect”

tools.md 开篇即明确了当前的基线:在本分支上,Tool.Def.executeTool.Info.init 已经返回 Effect,内建工具的整体形态已经落在目标形状上。src/tool 下当前导出的工具全部使用 Tool.define(...) 做 Effect 化初始化,且几乎都使用 Effect.gen(...)Effect.fn(...) 构建工具主体。

因此剩余工作已经收窄为三件事(直接继承自规格文档):

  1. 移除各工具体内的 Promise 与裸平台桥接(raw platform bridges);
  2. 把工具内部实现替换为 Effect 原生服务FSUtilHttpClientChildProcessSpawner 等;
  3. 让测试与调用方对齐 yield* info.init() 以及真实的服务图(service graph)。

这个判断可以从当前源码中得到印证:tool.ts 中的类型定义已经完全是 Effect 化的。

二、目标形态:Tool.define 的 Effect 原生结构

2.1 Info / Def 类型:init 与 execute 都是 Effect

tool.ts 中定义了工具的核心契约:

export interface Def<
  Parameters extends Schema.Decoder<unknown> = Schema.Decoder<unknown>,
  M extends Metadata = Metadata,
> {
  id: string
  description: string
  parameters: Parameters
  jsonSchema?: JSONSchema7
  execute(args: Schema.Schema.Type<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>>
  formatValidationError?(error: unknown): string
}

export interface Info<
  Parameters extends Schema.Decoder<unknown> = Schema.Decoder<unknown>,
  M extends Metadata = Metadata,
> {
  id: string
  init: () => Effect.Effect<DefWithoutID<Parameters, M>>
}

关键点:

  • Info.init() 返回 Effect.Effect<DefWithoutID<...>>——工具初始化本身就是一个 Effect,依赖(文件系统、HTTP 客户端、LSP、Agent 服务等)通过 yield* 从服务图中解析;
  • Def.execute 返回 Effect.Effect<ExecuteResult<M>>——执行路径全程 Effect,参数解码、权限询问、截断都发生在 Effect 组合内;
  • ExecuteResult 统一产出 title / metadata / output / attachments?,其中 attachments 用于图片、PDF 等二进制附件。

规格文档特别强调:Tool.define(...) 已经是 Effect 原生的 helper,不需要再引入单独的 Tool.defineEffect(...) 辅助函数才算完成迁移。判断标准是“init 与 execute 路径保持 Effect 原生”,即使某些内部细节仍桥接到 Promise 或裸 API。

2.2 define 的包装逻辑:参数解码、错误归一与输出截断

tool.ts 中的 wrap(...)define(...) 展示了每个工具在注册后获得的统一行为:

  • 参数解码一次编译Schema.decodeUnknownEffect(toolInfo.parameters) 在每次工具 init 时编译一次,避免每次 LLM 调用都重新闭包,这是注释里明确写出的性能考量(#L108-L111);
  • 无效参数归一为类型化错误:解码失败会被映射为 InvalidArgumentsErrortool.ts),其 message getter 生成面向模型的改写提示——“The {tool} tool was called with invalid arguments: …Please rewrite the input so it satisfies the expected schema.”。这是一个 Schema.TaggedErrorClass,上游可以精确匹配;
  • 输出统一截断execute 返回后,若工具未自行标记 metadata.truncated,包装层会用 Truncate.Service 与当前 Agent 配置截断输出,并把 outputPath 写入 metadata(#L130-L144);
  • 可观测性:每次执行都被 Effect.withSpan("Tool.execute", ...) 包裹,携带 tool.namesession.idmessage.idtool.call_id 属性(#L113-L119, #L145)。

也就是说,完成迁移的工具不仅自身是 Effect,还自动继承了参数校验、截断与 tracing 这套横切逻辑——这正是“迁移收益”所在。

2.3 注册表:所有内建工具走 Tool.init

registry.ts 中的 ToolRegistry 服务展示了迁移后统一的服务图接入方式:

const tool = yield* Effect.all({
  invalid: Tool.init(invalid),
  shell: Tool.init(shell),
  read: Tool.init(read),
  glob: Tool.init(globtool),
  grep: Tool.init(greptool),
  edit: Tool.init(edit),
  write: Tool.init(writetool),
  task: Tool.init(task),
  fetch: Tool.init(webfetch),
  todo: Tool.init(todo),
  search: Tool.init(websearch),
  skill: Tool.init(skilltool),
  patch: Tool.init(patchtool),
  question: Tool.init(question),
  lsp: Tool.init(lsptool),
  plan: Tool.init(plan),
  ...(codeModeTool ? { execute: Tool.init(codeModeTool) : {}),
})

每个内建工具先 yield* ReadTool 之类的 Info(Effect),再 Tool.init(...) 得到 Def。从当前源码结构看,还有两个值得注意的点:

  • 插件工具仍保留 Promise 桥接fromPlugin 通过 EffectBridge.make() 把宿主 Effect 侧的 ask 桥成 Promise 返回的函数,再用 Effect.promise 包装插件的 def.executeregistry.ts)。这是迁移边界上刻意保留的兼容层,对应规格文档里“移除工具体内的 Promise 桥接”这一清理方向;
  • 模型相关的工具选择策略tools() 中当 modelID 包含 gpt-(且非 oss / gpt-4)时启用 apply_patch 并隐藏 edit / writewebsearch 仅在 opencode 官方 provider 或启用 exa/parallel 时开放(registry.ts)。code-mode 则通过 flags.experimentalCodeMode 实验开关动态加载。

三、已导出工具清单与模块边界

规格文档列出当前 src/tool 下使用 Tool.define(...) 的导出工具(全部已勾选完成):

工具文件 状态
apply_patch.ts [x] 已迁移
bash.ts(shell 工具) [x] 已迁移
edit.ts [x] 已迁移
glob.ts [x] 已迁移
grep.ts [x] 已迁移
invalid.ts [x] 已迁移
lsp.ts [x] 已迁移
plan.ts [x] 已迁移
question.ts [x] 已迁移
read.ts [x] 已迁移
skill.ts [x] 已迁移
task.ts [x] 已迁移
todo.ts [x] 已迁移
webfetch.ts [x] 已迁移
websearch.ts [x] 已迁移
write.ts [x] 已迁移

规格文档同时划定了模块边界,避免把“工具”误判为“工具支撑模块”:

  • 当前分支没有 ls.ts 工具文件(目录列举能力内嵌在 read 工具中,见下文 4.1 的目录分支);
  • truncate.ts 是工具使用的 Effect 服务,不是工具定义本身;
  • mcp-exa.tsexternal-directory.tsschema.ts 是支撑模块,不是独立工具定义。

用当前仓库交叉验证:src/tool 下确实不存在 ls.tsTool.define 调用点覆盖 read.tsedit.tswrite.tsgrep.tsglob.tsapply_patch.tswebfetch.tswebsearch.tstodo.tsquestion.tsplan.tsskill.tstask.tsinvalid.tslsp.ts 与 shell 工具(当前文件为 shell.ts,导出 ShellTool);此外还有一个实验性的 code-mode.tsCodeModeTool)同样使用 Tool.define,由实验开关控制是否注册。

四、剩余清理项逐项拆解

规格文档的 “Follow-up cleanup” 指出:多数导出工具已经在目标形态上,剩余清理比旧清单暗示的要窄。下面按当前源码逐项展开。

4.1 read.ts —— 已完成:FSUtil 流式读取替换 legacy Node stream

规格文档标记该项为已完成:read.ts 现在通过 FSUtil.Service.stream 配合 Stream.splitLines 流式读取,legacy 的 Node stream / readline helper 已被移除。

read.tsReadTool.lines 展示了完整实现:

const lines = Effect.fn("ReadTool.lines")(function* (filepath, opts) {
  const start = opts.offset - 1
  const raw: string[] = []
  const flags = { bytes: 0, count: 0, cut: false, more: false, done: false }

  // 手动 TextDecoder 而非 Stream.decodeText:源流未 flush 结束时
  // decodeText 会丢弃最后一个未终止行
  const decoder = new TextDecoder("utf-8")
  yield* fs.stream(filepath).pipe(
    Stream.map((bytes) => decoder.decode(bytes, { stream: true })),
    Stream.splitLines,
    Stream.runForEach((text) =>
      Effect.gen(function* () {
        if (flags.done) return yield* new ReadStop()
        // ... 跳过 offset 前的行、按 limit 截断、按 MAX_BYTES 截断
      }),
    ),
    Effect.catchTag("ReadStop", () => Effect.void),
  )
  return { raw, count: flags.count, cut: flags.cut, more: flags.more, offset: opts.offset }
})

这里有两个 Effect 化后特有的工程细节,值得复用:

  1. 用标签化错误(tagged error)ReadStop 提前终止上游文件流:达到字节上限时 yield* new ReadStop(),再由 Effect.catchTag 兜底,避免读完整个文件;
  2. 刻意回避 Stream.decodeTextStream.runForEachWhile:源码注释说明二者会吞掉上游 splitLines 管道中最后一个未终止行,因此改用手动 TextDecoder(#L142-L146)。

常量边界(read.ts):默认读取 2000 行、单行最长 2000 字符、输出硬上限 MAX_BYTES = 50 * 1024(50 KB,超过则提示 Use offset=N to continue)。

此外,ReadTool 的 init 签名本身就是 Effect 依赖解析的范例(read.ts):

export const ReadTool = Tool.define<
  typeof Parameters,
  Metadata,
  FSUtil.Service | Instruction.Service | LSP.Service | Scope.Scope
>(
  "read",
  Effect.gen(function* () {
    const fs = yield* FSUtil.Service
    const instruction = yield* Instruction.Service
    const lsp = yield* LSP.Service
    const scope = yield* Scope.Scope
    // ...

执行路径还包含:外部目录断言(assertExternalDirectoryEffect)、ctx.ask 权限询问、文件未找到时的“Did you mean”候选提示(ReadTool.miss,基于 fs.readDirectory)、二进制文件嗅探(采样 4096 字节 + 不可打印字符占比 >30% 判定)、以及目录参数时的目录列举分支(无 ls 工具的替代)。

4.2 bash/shell 工具 —— 已是 Effect 子进程原语,仅余平台桥接跟踪

规格文档标记为“部分完成”:已使用 Effect 子进程原语,只需持续跟踪 shell 相关的平台桥接与解析器加载细节。

当前 shell.ts 印证了这一点。init 中注入的服务包括(#L338-L347):

export const ShellTool = Tool.define(
  ShellID.ToolID,
  Effect.gen(function* () {
    const config = yield* Config.Service
    const spawner = yield* ChildProcessSpawner
    const fs = yield* FSUtil.Service
    const trunc = yield* Truncate.Service
    const plugin = yield* Plugin.Service
    const flags = yield* RuntimeFlags.Service
    const defaultTimeoutMs = flags.bashDefaultTimeoutMs ?? 2 * 60 * 1000

进程创建使用 Effect 的 ChildProcess.make(...)(#L303-L309),路径解析使用 Effect.fn 化的 ShellTool.resolvePath(Windows 下经 cygpath 子进程归一化,#L349-L367)。而规格文档所说的“parser/loading 细节”在源码中对应 tree-sitter 解析器的懒加载(#L311-L336):

const parser = lazy(async () => {
  const { Parser } = await import("web-tree-sitter")
  const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm", { with: { type: "wasm" } })
  // ... 加载 bash / powershell wasm 语法
  const [bashLanguage, psLanguage] = await Promise.all([Language.load(bashPath), Language.load(psPath)])
  // ...
})

这里的 Promise.all + wasm import 就是规格文档中“still tracking”的典型残留:它被包在 lazy 单例里以隔离影响面,属于待清理的 Promise 桥接点。

4.3 webfetch.ts —— 已用 HttpClient,剩余工作集中在边界 helper

规格文档标记为“部分完成”:已使用 HttpClient,剩余工作限于 HTML 文本提取等较小的边界 helper。

webfetch.ts 的 init 直接从服务图取 HttpClient 并派生 filterStatusOk

export const WebFetchTool = Tool.define(
  "webfetch",
  Effect.gen(function* () {
    const http = yield* HttpClient.HttpClient
    const httpOk = HttpClient.filterStatusOk(http)
    return {
      description: DESCRIPTION,
      parameters: Parameters,
      execute: (params, ctx) => Effect.gen(function* () { /* ... */ }),
    }
  }),
)

请求细节(可直接引用的参数事实):

  • 参数 schemaurl(必须 http:// / https://)、formattext | markdown | html,默认 markdown,通过 Schema.withDecodingDefault 注入默认值)、timeout(秒,可选,上限 120);
  • 超时与体积上限:默认 30 秒、硬上限 120 秒(DEFAULT_TIMEOUT / MAX_TIMEOUT),响应上限 5MB(MAX_RESPONSE_SIZE,同时检查 content-length 头与实际 arrayBuffer.byteLength);
  • Cloudflare 拦截重试:命中 403 + cf-mitigated: challenge 时改用真实 UA opencode 重试一次(#L78-L92);
  • Accept 头按 format 分级降级,例如 markdown 请求是 text/markdown;q=1.0, …, text/html;q=0.7, */*;q=0.1

而规格文档所说的“边界 helper”正对应文件末尾的两个纯函数 extractTextFromHTML(基于 htmlparser2Parser,跳过 script/style/noscript/iframe/object/embed)与 convertHTMLToMarkdown(基于 turndown,#L158-L192)。它们是同步的 DOM-less 解析,属于可继续收编为 Effect 原生能力的残留边界。

4.4 ripgrep / 文件搜索 —— 相邻模块的原始 fs/process 用法

规格文档将 file/ripgrep.ts 列为工具迁移的相邻项:仍有 raw fs/process 用法,影响 grep.ts 与文件搜索路由。在“Filesystem notes”一节中,文档当时列出的主要 raw fs 用户就是它(fs/promises)。

对照当前仓库:该模块位于 packages/core/src/ripgrep.ts,从源码结构看已基于 Effect 构建(导入 EffectFiberLayerStreameffect/unstable/processChildProcess),说明这条清理线在规格文档之后持续推进。而 packages/core/src 下仍可见 fs/promises 直接用法,例如 fs-util.tsshell.tsflock.ts——这类 Effect 服务底层的 Node 原语封装,与规格文档指出的“工具体内的裸平台桥接”是不同层次的问题:前者是服务实现的合理边界,后者才是工具迁移要清掉的对象。

4.5 apply_patch.ts —— 已完成:Effect 落在 FSUtil 上,解析器保持纯函数

规格文档标记为已完成:apply 路径现在基于 FSUtil.Service 返回 Effect;解析器与 chunk 替换器保持纯函数(pure)。

apply_patch.ts 印证了这一分工:

export const ApplyPatchTool = Tool.define(
  "apply_patch",
  Effect.gen(function* () {
    const lsp = yield* LSP.Service
    const afs = yield* FSUtil.Service
    const format = yield* Format.Service
    const events = yield* EventV2Bridge.Service

    const run = Effect.fn("ApplyPatchTool.execute")(function* (params, ctx) {
      if (!params.patchText) {
        return yield* Effect.fail(new Error("patchText is required"))
      }
      let hunks: Patch.Hunk[]
      try {
        const parseResult = Patch.parsePatch(params.patchText) // 纯函数解析
        hunks = parseResult.hunks
      } catch (error) {
        return yield* Effect.fail(new Error(`apply_patch verification failed: ${error}`))
      }
      // ...

模式很清楚:I/O(文件 stat/读写、事件发布)全部走 Effect 服务,语法解析与内容替换保持纯函数,解析失败用 Effect.fail 归一为类型化错误。这种“纯解析 + Effect 执行”的切分方式,是规格文档建议给其余工具沿用的做法。

规格文档还列出了一批“已在目标路径上、无需单独立项”的工具:apply_patch.tsgrep.tswrite.tswebsearch.tsedit.ts

五、测试规范:与生产服务图对齐的 Effect 测试

规格文档 “Tests” 一节的要求是工具测试必须使用 test/lib/effect.ts 中现成的 Effect 助手:

  • testEffect(...) / it.live(...) 替代为 effect 工具伪造本地 wrapper;
  • yield 真实工具导出再初始化const info = yield* ReadTool,然后 const tool = yield* info.init()
  • provideTmpdirInstance(...)provideInstance(tmpdirScoped(...)) 一类的临时目录实例运行测试,让实例级服务按生产方式解析。

当前 effect.ts 的实现比文档描述更进一步,直接提供了 instance 变体:

// test/lib/effect.ts(节选)
const make = <R, E>(testLayer, liveLayer, run: Runner = isolatedRun) => {
  const effect = (name, value, opts) => test(name, () => run(value, testLayer), opts)
  // ...
  const instance = (name, value, options?, opts?) => {
    const args = instanceArgs(options, opts)
    return test(name, () => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer), args.testOptions)
  }
  return { effect, live, instance }
}

export const it = make<never, never>(testEnv, liveEnv)
export const testEffect = <R, E>(layer) =>
  make<R, E>(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv))

即:testEffect(SomeLayer).instance("name", Effect.gen(function* () { ... }), { git: true, config: {...} }) 会自动把测试体包进 withTmpdirInstance(...)(临时目录实例,fixture 提供),实例级服务如 InstanceState 的解析与生产完全一致。几个关键设计点:

  1. test 与 live 双环境test 环境合并 TestClock.layer()TestConsole.layer(可控时间、可捕获日志),live 环境用真实时钟但保留 TestConsole(#L131-L137)。涉及真实子进程、网络的工具测试用 live 变体,可确定性的逻辑用 test 变体;
  2. sharedRun 与 memoMaptestEffectShared 通过进程级 memoMap 构建测试 layer,使 BusSession 等缓存服务与 Server.Default 解析到同一实例——注释明确说明这是给“需要与进程内 HTTP server 保持 pub/sub 同一性”的测试用的,大多数测试应停留在 testEffect(#L49-L53, #L142-L147);
  3. 辅助原语awaitWithTimeout(默认 2 秒)与 pollWithTimeout(20ms 轮询、默认 5 秒,#L149-L177)用于等待异步状态收敛,替代裸 setTimeout 轮询。

工具测试的完整范式因此是:

const info = yield* ReadTool          // yield 真实工具导出(Info 即 Effect)
const tool = yield* info.init()        // 走 wrap:获得解码/截断/tracing
// 随后以 tool.execute(args, ctx) 驱动真实执行路径

仓库中对应的测试文件覆盖各工具,例如 tool-read.test.tstool-bash.test.tstool-apply-patch.test.tstool-edit.test.tstool-question.test.tstool-webfetch.test.ts 等,均位于 packages/opencode/test/ 下。规格文档的结论是:这样做的目的是让工具测试与生产服务图对齐,使后续清理工作基本是机械性的

六、迁移检查清单(可直接用于自检)

综合规格文档与源码现状,判断一个工具是否“完成 Effect 迁移”可按以下清单检查:

检查项 判定依据(当前仓库)
工具由 Tool.define 导出 src/tool/*.tsexport const XxxTool = Tool.define(...);返回 Effect<Info>
init 依赖全部经服务图解析 init 体内 yield* FSUtil.Service / yield* HttpClient / yield* ChildProcessSpawner 等,无直接 import "fs/promises"
execute 全路径 Effect execute 返回 Effect.Effect<ExecuteResult>;错误用 Effect.fail 或标签化错误,不用裸 throw 处理业务失败
无 Promise / 平台桥接 工具体内不出现 Promise.allsetTimeoutreadline 等;shell 工具的 tree-sitter 加载是已知残留点
纯解析与 I/O 分离 解析器/替换器等纯函数单独可测(参考 apply_patch.tsPatch.parsePatch
测试对齐生产服务图 使用 testEffect(...).instance(...)it.liveyield* ToolXxx + yield* info.init(),不造本地 fake wrapper

七、小结:剩余工作地图

tools.md 的口径,工具迁移的主线已经完成:Tool.define 的 init/execute 双 Effect 契约在 tool.ts 中定型,16 个内建工具全部就位并由 registry.ts 统一经 Tool.init 注册。剩余的清理面已经非常具体:

  • read.ts / apply_patch.ts:已完成(FSUtil.Service.stream + Stream.splitLines;apply 路径 Effect over FSUtil.Service,解析保持纯函数);
  • shell(bash)工具:主体已是 Effect 子进程原语,持续跟踪 wasm parser 加载与 shell 平台桥接(shell.ts);
  • webfetchHttpClient 已就位,收尾 HTML 文本提取等边界 helper(webfetch.ts);
  • ripgrep 相邻模块:raw fs/process 用法影响 grep 与文件搜索路由,当前 packages/core/src/ripgrep.ts 已从源码结构看转向 Effect + ChildProcess 实现,属于持续跟踪项。

对后续参与开发的人而言,规格文档给出的判断标准值得记住:一个工具“有效迁移完成”的充要条件,是它的 init 与 execute 路径保持 Effect 原生、依赖从真实服务图解析,并且测试通过 yield* info.init() 走生产同构路径——其余的内部桥接只需按上表逐项收编。

登录后查看全文
热门项目推荐
相关项目推荐