跳转到内容

Tool 调用系统

旧版 eec0843c 源码提交 eec0843ce422
状态已完成
难度中等
预计阅读50 分钟
  • packages/opencode/src/tool/tool.ts
  • packages/opencode/src/tool/registry.ts
  • packages/opencode/src/session/tools.ts
  • packages/plugin/src/tool.ts

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

学完这一章,你应该能够:

  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 设计一个可扩展、可审计的工具边界。

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

本章的中心问题是:

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

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

packages/opencode/src/tool/tool.ts packages/opencode/src/tool/tool.ts:16-45
16export type Context<M extends Metadata = Metadata> = {定义数据结构约束。17  sessionID: SessionID18  messageID: MessageID19  agent: string20  abort: AbortSignal用于中断运行任务。21  callID?: string22  extra?: { [key: string]: unknown }23  messages: MessageV2.WithParts[]会话消息片段结构。24  metadata(input: { title?: string; metadata?: M }): Effect.Effect<void>Effect 异步工作流。25  ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Effect.Effect<void>Effect 异步工作流。26}2728export interface ExecuteResult<M extends Metadata = Metadata> {定义数据结构约束。29  title: string30  metadata: M31  output: string32  attachments?: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[]会话消息片段结构。33}3435export interface Def<定义数据结构约束。36  Parameters extends Schema.Decoder<unknown> = Schema.Decoder<unknown>,定义并校验数据形状。37  M extends Metadata = Metadata,38> {39  id: string40  description: string41  parameters: Parameters42  jsonSchema?: JSONSchema743  execute(args: Schema.Schema.Type<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>>工具真正执行入口。44  formatValidationError?(error: unknown): string45}
packages/opencode/src/session/tools.ts packages/opencode/src/session/tools.ts:24-116
24export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {Effect 异步工作流。25  agent: Agent.Info26  model: Provider.Model选择模型或 provider。27  session: Session.Info28  processor: Pick<SessionProcessor.Handle, "message" | "updateToolCall" | "completeToolCall">处理模型流事件。29  bypassAgentCheck: boolean30  messages: MessageV2.WithParts[]会话消息片段结构。31  promptOps: TaskPromptOps32}) {33  using _ = log.time("resolveTools")34  const tools: Record<string, AITool> = {}35  const run = yield* EffectBridge.make()等待 Effect 结果。36  const plugin = yield* Plugin.Service调用插件扩展点。37  const permission = yield* Permission.Service等待 Effect 结果。38  const registry = yield* ToolRegistry.Service等待 Effect 结果。39  const mcp = yield* MCP.Service等待 Effect 结果。40  const truncate = yield* Truncate.Service等待 Effect 结果。4142  const context = (args: Record<string, unknown>, options: ToolExecutionOptions): Tool.Context => ({43    sessionID: input.session.id,44    abort: options.abortSignal!,45    messageID: input.processor.message.id,46    callID: options.toolCallId,47    extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps: input.promptOps },选择模型或 provider。48    agent: input.agent.name,49    messages: input.messages,50    metadata: (val) =>51      input.processor.updateToolCall(options.toolCallId, (match) => {52        if (!["running", "pending"].includes(match.state.status)) return match按条件进入分支。53        return {返回给上一层。54          ...match,55          state: {56            title: val.title,57            metadata: val.metadata,58            status: "running",59            input: args,60            time: { start: Date.now() },61          },62        }63      }),64    ask: (req) =>65      permission66        .ask({67          ...req,68          sessionID: input.session.id,69          tool: { messageID: input.processor.message.id, callID: options.toolCallId },70          ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),71        })72        .pipe(Effect.orDie),Effect 异步工作流。73  })7475  for (const item of yield* registry.tools({等待 Effect 结果。76    modelID: ModelID.make(input.model.api.id),选择模型或 provider。77    providerID: input.model.providerID,选择模型或 provider。78    agent: input.agent,79  })) {80    const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))定义并校验数据形状。81    tools[item.id] = tool({82      description: item.description,83      inputSchema: jsonSchema(schema),84      execute(args, options) {工具真正执行入口。85        return run.promise(返回给上一层。86          Effect.gen(function* () {Effect 异步工作流。87            const ctx = context(args, options)88            yield* plugin.trigger(调用插件扩展点。89              "tool.execute.before",90              { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },91              { args },92            )93            const result = yield* item.execute(args, ctx)工具真正执行入口。94            const output = {95              ...result,96              attachments: result.attachments?.map((attachment) => ({97                ...attachment,98                id: PartID.ascending(),99                sessionID: ctx.sessionID,100                messageID: input.processor.message.id,101              })),102            }103            yield* plugin.trigger(调用插件扩展点。104              "tool.execute.after",105              { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args },106              output,107            )108            if (options.abortSignal?.aborted) {按条件进入分支。109              yield* input.processor.completeToolCall(options.toolCallId, output)等待 Effect 结果。110            }111            return output返回给上一层。112          }),113        )114      },115    })116  }

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

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

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

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

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

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

Section titled “3. 先画地图:从定义到一次执行”
内置 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 行能力边界

Section titled “4. 最小机制:先看 16 行能力边界”
1type ToolDef<A> = {定义数据结构约束。2  name: string3  description: string4  schema: RuntimeSchema<A>定义并校验数据形状。5  execute(args: A, ctx: ToolContext): Promise<ToolResult>工具真正执行入口。6}78function toolsForRound(registry: ToolDef<unknown>[], round: RoundContext) {定义一段可复用逻辑。9  return Object.fromEntries(registry.map((def) => [返回给上一层。10    def.name,11    llmTool({12      description: def.description,13      inputSchema: adaptSchema(def.schema, round.model),14      async execute(raw, call) {工具真正执行入口。15        const args = decode(def.schema, raw)16        const ctx = bindContext(round, call)17        return def.execute(args, ctx)工具真正执行入口。18      },19    }),20  ]))21}

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

5.1 Tool.Info:可延迟初始化的注册信息

Section titled “5.1 Tool.Info:可延迟初始化的注册信息”
1export interface Info<P, M> {定义数据结构约束。2  id: string3  init: () => Effect.Effect<DefWithoutID<P, M>>Effect 异步工作流。4}

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

packages/opencode/src/tool/tool.ts packages/opencode/src/tool/tool.ts:51-57
51export interface Info<定义数据结构约束。52  Parameters extends Schema.Decoder<unknown> = Schema.Decoder<unknown>,定义并校验数据形状。53  M extends Metadata = Metadata,54> {55  id: string56  init: () => Effect.Effect<DefWithoutID<Parameters, M>>Effect 异步工作流。57}

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

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

packages/opencode/src/tool/tool.ts packages/opencode/src/tool/tool.ts:35-45
35export interface Def<定义数据结构约束。36  Parameters extends Schema.Decoder<unknown> = Schema.Decoder<unknown>,定义并校验数据形状。37  M extends Metadata = Metadata,38> {39  id: string40  description: string41  parameters: Parameters42  jsonSchema?: JSONSchema743  execute(args: Schema.Schema.Type<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>>工具真正执行入口。44  formatValidationError?(error: unknown): string45}

5.3 AI SDK Tool:发给模型 runtime 的适配形状

Section titled “5.3 AI SDK Tool:发给模型 runtime 的适配形状”

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

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

Section titled “5.4 tool call:模型提出的一次动作”

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

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

Section titled “6. 追一条典型源码旅程:read 怎样成为本轮能力”

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

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

Section titled “6.1 第一站:工具合同把执行环境显式化”

Tool.Context 包含:

1{2  sessionID,3  messageID,4  agent,5  abort,6  callID?,7  extra?,8  messages,9  metadata(...),10  ask(...),11}

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

packages/opencode/src/tool/tool.ts packages/opencode/src/tool/tool.ts:16-26
16export type Context<M extends Metadata = Metadata> = {定义数据结构约束。17  sessionID: SessionID18  messageID: MessageID19  agent: string20  abort: AbortSignal用于中断运行任务。21  callID?: string22  extra?: { [key: string]: unknown }23  messages: MessageV2.WithParts[]会话消息片段结构。24  metadata(input: { title?: string; metadata?: M }): Effect.Effect<void>Effect 异步工作流。25  ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Effect.Effect<void>Effect 异步工作流。26}
工具执行合同 packages/opencode/src/tool/tool.ts:16-45

参数与返回值只是半个合同;session、取消、进度和权限也属于执行边界。

16export type Context<M extends Metadata = Metadata> = {定义数据结构约束。17  sessionID: SessionID18  messageID: MessageID19  agent: string20  abort: AbortSignal用于中断运行任务。21  callID?: string22  extra?: { [key: string]: unknown }23  messages: MessageV2.WithParts[]会话消息片段结构。24  metadata(input: { title?: string; metadata?: M }): Effect.Effect<void>Effect 异步工作流。25  ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Effect.Effect<void>Effect 异步工作流。26}2728export interface ExecuteResult<M extends Metadata = Metadata> {定义数据结构约束。29  title: string30  metadata: M31  output: string32  attachments?: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[]会话消息片段结构。33}3435export interface Def<定义数据结构约束。36  Parameters extends Schema.Decoder<unknown> = Schema.Decoder<unknown>,定义并校验数据形状。37  M extends Metadata = Metadata,38> {39  id: string40  description: string41  parameters: Parameters42  jsonSchema?: JSONSchema743  execute(args: Schema.Schema.Type<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>>工具真正执行入口。44  formatValidationError?(error: unknown): string45}

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

packages/opencode/src/tool/tool.ts packages/opencode/src/tool/tool.ts:28-33
28export interface ExecuteResult<M extends Metadata = Metadata> {定义数据结构约束。29  title: string30  metadata: M31  output: string32  attachments?: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[]会话消息片段结构。33}

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

6.2 第二站:Tool.define 装上统一 wrapper

Section titled “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

packages/opencode/src/tool/tool.ts packages/opencode/src/tool/tool.ts:132-161
132export function define<对外暴露模块成员。133  Parameters extends Schema.Decoder<unknown>,定义并校验数据形状。134  Result extends Metadata,135  R,136  ID extends string = string,137>(138  id: ID,139  init: Effect.Effect<Init<Parameters, Result>, never, R>,Effect 异步工作流。140): Effect.Effect<Info<Parameters, Result>, never, R | Truncate.Service | Agent.Service> & { id: ID } {Effect 异步工作流。141  return Object.assign(返回给上一层。142    Effect.gen(function* () {Effect 异步工作流。143      const resolved = yield* init等待 Effect 结果。144      const truncate = yield* Truncate.Service等待 Effect 结果。145      const agents = yield* Agent.Service等待 Effect 结果。146      return { id, init: wrap(id, resolved, truncate, agents) }返回给上一层。147    }),148    { id },149  )150}151152export function init<P extends Schema.Decoder<unknown>, M extends Metadata>(定义并校验数据形状。153  info: Info<P, M>,154): Effect.Effect<Def<P, M>> {Effect 异步工作流。155  return Effect.gen(function* () {Effect 异步工作流。156    const init = yield* info.init()等待 Effect 结果。157    return {返回给上一层。158      ...init,159      id: info.id,160    }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

packages/opencode/src/tool/tool.ts packages/opencode/src/tool/tool.ts:79-130
79function wrap<Parameters extends Schema.Decoder<unknown>, Result extends Metadata>(定义并校验数据形状。80  id: string,81  init: Init<Parameters, Result>,82  truncate: Truncate.Interface,83  agents: Agent.Interface,84) {85  return () =>返回给上一层。86    Effect.gen(function* () {Effect 异步工作流。87      const toolInfo = typeof init === "function" ? { ...(yield* init()) } : { ...init }等待 Effect 结果。88      // Compile the parser closure once per tool init; `decodeUnknownEffect`89      // allocates a new closure per call, so hoisting avoids re-closing it for90      // every LLM tool invocation.91      const decode = Schema.decodeUnknownEffect(toolInfo.parameters)定义并校验数据形状。92      const execute = toolInfo.execute93      toolInfo.execute = (args, ctx) => {94        const attrs = {95          "tool.name": id,96          "session.id": ctx.sessionID,97          "message.id": ctx.messageID,98          ...(ctx.callID ? { "tool.call_id": ctx.callID } : {}),99        }100        return Effect.gen(function* () {Effect 异步工作流。101          const decoded = yield* decode(args).pipe(等待 Effect 结果。102            Effect.mapError((error) =>Effect 异步工作流。103              toolInfo.formatValidationError104                ? new Error(toolInfo.formatValidationError(error), { cause: error })105                : new Error(106                    `The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,107                    { cause: error },108                  ),109            ),110          )111          const result = yield* execute(decoded as Schema.Schema.Type<Parameters>, ctx)工具真正执行入口。112          if (result.metadata.truncated !== undefined) {按条件进入分支。113            return result返回给上一层。114          }115          const agent = yield* agents.get(ctx.agent)等待 Effect 结果。116          const truncated = yield* truncate.output(result.output, {}, agent)等待 Effect 结果。117          return {返回给上一层。118            ...result,119            output: truncated.content,120            metadata: {121              ...result.metadata,122              truncated: truncated.truncated,123              ...(truncated.truncated && { outputPath: truncated.outputPath }),124            },125          }126        }).pipe(Effect.orDie, Effect.withSpan("Tool.execute", { attributes: attrs }))Effect 异步工作流。127      }128      return toolInfo返回给上一层。129    })130}
内置工具的参数校验与输出截断 wrapper packages/opencode/src/tool/tool.ts:79-130

decode 在 execute 之前;truncate 在具体工具返回之后。

79function wrap<Parameters extends Schema.Decoder<unknown>, Result extends Metadata>(定义并校验数据形状。80  id: string,81  init: Init<Parameters, Result>,82  truncate: Truncate.Interface,83  agents: Agent.Interface,84) {85  return () =>返回给上一层。86    Effect.gen(function* () {Effect 异步工作流。87      const toolInfo = typeof init === "function" ? { ...(yield* init()) } : { ...init }等待 Effect 结果。88      // Compile the parser closure once per tool init; `decodeUnknownEffect`89      // allocates a new closure per call, so hoisting avoids re-closing it for90      // every LLM tool invocation.91      const decode = Schema.decodeUnknownEffect(toolInfo.parameters)定义并校验数据形状。92      const execute = toolInfo.execute93      toolInfo.execute = (args, ctx) => {94        const attrs = {95          "tool.name": id,96          "session.id": ctx.sessionID,97          "message.id": ctx.messageID,98          ...(ctx.callID ? { "tool.call_id": ctx.callID } : {}),99        }100        return Effect.gen(function* () {Effect 异步工作流。101          const decoded = yield* decode(args).pipe(等待 Effect 结果。102            Effect.mapError((error) =>Effect 异步工作流。103              toolInfo.formatValidationError104                ? new Error(toolInfo.formatValidationError(error), { cause: error })105                : new Error(106                    `The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,107                    { cause: error },108                  ),109            ),110          )111          const result = yield* execute(decoded as Schema.Schema.Type<Parameters>, ctx)工具真正执行入口。112          if (result.metadata.truncated !== undefined) {按条件进入分支。113            return result返回给上一层。114          }115          const agent = yield* agents.get(ctx.agent)等待 Effect 结果。116          const truncated = yield* truncate.output(result.output, {}, agent)等待 Effect 结果。117          return {返回给上一层。118            ...result,119            output: truncated.content,120            metadata: {121              ...result.metadata,122              truncated: truncated.truncated,123              ...(truncated.truncated && { outputPath: truncated.outputPath }),124            },125          }126        }).pipe(Effect.orDie, Effect.withSpan("Tool.execute", { attributes: attrs }))Effect 异步工作流。127      }128      return toolInfo返回给上一层。129    })130}

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

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

Section titled “6.3 第三站:registry 初始化内置工具”

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

packages/opencode/src/tool/registry.ts packages/opencode/src/tool/registry.ts:120-139
120    const invalid = yield* InvalidTool等待 Effect 结果。121    const task = yield* TaskTool等待 Effect 结果。122    const taskStatus = yield* TaskStatusTool等待 Effect 结果。123    const read = yield* ReadTool等待 Effect 结果。124    const question = yield* QuestionTool等待 Effect 结果。125    const todo = yield* TodoWriteTool等待 Effect 结果。126    const lsptool = yield* LspTool等待 Effect 结果。127    const plan = yield* PlanExitTool等待 Effect 结果。128    const webfetch = yield* WebFetchTool等待 Effect 结果。129    const websearch = yield* WebSearchTool等待 Effect 结果。130    const repoClone = yield* RepoCloneTool等待 Effect 结果。131    const repoOverview = yield* RepoOverviewTool等待 Effect 结果。132    const shell = yield* ShellTool处理命令执行。133    const globtool = yield* GlobTool读写本地文件。134    const writetool = yield* WriteTool等待 Effect 结果。135    const edit = yield* EditTool等待 Effect 结果。136    const greptool = yield* GrepTool等待 Effect 结果。137    const patchtool = yield* ApplyPatchTool等待 Effect 结果。138    const skilltool = yield* SkillTool等待 Effect 结果。139    const agent = yield* Agent.Service等待 Effect 结果。
packages/opencode/src/tool/registry.ts packages/opencode/src/tool/registry.ts:229-249
229        const tool = yield* Effect.all({Effect 异步工作流。230          invalid: Tool.init(invalid),声明可调用工具。231          shell: Tool.init(shell),声明可调用工具。232          read: Tool.init(read),声明可调用工具。233          glob: Tool.init(globtool),声明可调用工具。234          grep: Tool.init(greptool),声明可调用工具。235          edit: Tool.init(edit),声明可调用工具。236          write: Tool.init(writetool),声明可调用工具。237          task: Tool.init(task),声明可调用工具。238          task_status: Tool.init(taskStatus),声明可调用工具。239          fetch: Tool.init(webfetch),声明可调用工具。240          todo: Tool.init(todo),声明可调用工具。241          search: Tool.init(websearch),声明可调用工具。242          repo_clone: Tool.init(repoClone),声明可调用工具。243          repo_overview: Tool.init(repoOverview),声明可调用工具。244          skill: Tool.init(skilltool),声明可调用工具。245          patch: Tool.init(patchtool),声明可调用工具。246          question: Tool.init(question),声明可调用工具。247          lsp: Tool.init(lsptool),声明可调用工具。248          plan: Tool.init(plan),声明可调用工具。249        })

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

packages/opencode/src/tool/registry.ts packages/opencode/src/tool/registry.ts:251-275
251        return {返回给上一层。252          custom,253          builtin: [254            tool.invalid,255            ...(questionEnabled ? [tool.question] : []),256            tool.shell,处理命令执行。257            tool.read,258            tool.glob,259            tool.grep,260            tool.edit,261            tool.write,262            tool.task,263            ...(flags.experimentalBackgroundSubagents ? [tool.task_status] : []),264            tool.fetch,265            tool.todo,266            tool.search,267            ...(flags.experimentalScout ? [tool.repo_clone, tool.repo_overview] : []),268            tool.skill,269            tool.patch,270            ...(flags.experimentalLspTool ? [tool.lsp] : []),271            ...(flags.experimentalPlanMode && flags.client === "cli" ? [tool.plan] : []),272          ],273          task: tool.task,274          read: tool.read,275        }

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

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

Section titled “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

packages/opencode/src/tool/registry.ts packages/opencode/src/tool/registry.ts:203-224
203        const dirs = yield* config.directories()读取运行配置。204        const matches = dirs.flatMap((dir) =>205          Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }),读写本地文件。206        )207        if (matches.length) yield* config.waitForDependencies()读取运行配置。208        for (const match of matches) {遍历集合。209          const namespace = path.basename(match, path.extname(match))210          // `match` is an absolute filesystem path from `Glob.scanSync(..., { absolute: true })`.211          // Import it as `file://` so Node on Windows accepts the dynamic import.212          const mod = yield* Effect.promise(() => import(pathToFileURL(match).href))Effect 异步工作流。213          for (const [id, def] of Object.entries(mod)) {遍历集合。214            if (!isPluginTool(def)) continue调用插件扩展点。215            custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))调用插件扩展点。216          }217        }218219        const plugins = yield* plugin.list()等待 Effect 结果。220        for (const p of plugins) {遍历集合。221          for (const [id, def] of Object.entries(p.tool ?? {})) {遍历集合。222            custom.push(fromPlugin(id, def))调用插件扩展点。223          }224        }

plugin 公共 API 很轻:

1tool({2  description,3  args,4  async execute(args, context) { /* ... */ },工具真正执行入口。5})

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

packages/plugin/src/tool.ts packages/plugin/src/tool.ts:45-52
45export function tool<Args extends z.ZodRawShape>(input: {定义并校验数据形状。46  description: string47  args: Args48  execute(args: z.infer<z.ZodObject<Args>>, context: ToolContext): Promise<ToolResult>工具真正执行入口。49}) {50  return input返回给上一层。51}52tool.schema = z

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

packages/plugin/src/tool.ts packages/plugin/src/tool.ts:3-27
3export type ToolContext = {定义数据结构约束。4  sessionID: string5  messageID: string6  agent: string7  /**8   * Current project directory for this session.9   * Prefer this over process.cwd() when resolving relative paths.10   */11  directory: string12  /**13   * Project worktree root for this session.14   * Useful for generating stable relative paths (e.g. path.relative(worktree, absPath)).15   */16  worktree: string17  abort: AbortSignal用于中断运行任务。18  metadata(input: { title?: string; metadata?: { [key: string]: any } }): void19  ask(input: AskInput): Promise<void>20}2122type AskInput = {定义数据结构约束。23  permission: string24  patterns: string[]25  always: string[]26  metadata: { [key: string]: any }27}

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

packages/opencode/src/tool/registry.ts packages/opencode/src/tool/registry.ts:145-200
145        function fromPlugin(id: string, def: ToolDefinition): Tool.Def {调用插件扩展点。146          // Plugin tools still expose Zod args publicly; keep that compatibility147          // boxed at the registry boundary and give the LLM the original JSON Schema.148          // Normalize missing args to `{}` once — pre-1.14.49 the code was149          // `z.object(def.args)` and Zod silently tolerated undefined (#27451, #27630).150          const args = def.args ?? {}151          const entries = Object.entries(args)152          const allZod = entries.every((entry) => isZodType(entry[1]))153          const zodParams = allZod ? z.object(args) : undefined定义并校验数据形状。154          const jsonSchema = zodParams ? zodJsonSchema(zodParams) : legacyJsonSchema(entries)155          const parameters = zodParams156            ? Schema.declare<unknown>((u): u is unknown => zodParams.safeParse(u).success)定义并校验数据形状。157            : Schema.Unknown定义并校验数据形状。158          return {返回给上一层。159            id,160            parameters,161            jsonSchema,162            description: def.description,163            execute: (args, toolCtx) =>164              Effect.gen(function* () {Effect 异步工作流。165                // Bridge the host's Effect-based `ask` into a Promise-returning166                // function for the plugin to make sure context persists167                const bridge = yield* EffectBridge.make()等待 Effect 结果。168                const pluginCtx: PluginToolContext = {调用插件扩展点。169                  ...toolCtx,170                  ask: (req) => bridge.promise(toolCtx.ask(req)),171                  directory: ctx.directory,172                  worktree: ctx.worktree,173                }174                const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx))工具真正执行入口。175                const output = typeof result === "string" ? result : result.output176                const metadata = typeof result === "string" ? {} : (result.metadata ?? {})177                const attachments = typeof result === "string" ? undefined : result.attachments178                const info = yield* agent.get(toolCtx.agent)等待 Effect 结果。179                const out = yield* truncate.output(output, {}, info)等待 Effect 结果。180                return {返回给上一层。181                  title: typeof result === "string" ? "" : (result.title ?? ""),182                  output: out.truncated ? out.content : output,183                  attachments,184                  metadata: {185                    ...metadata,186                    truncated: out.truncated,187                    ...(out.truncated && { outputPath: out.outputPath }),188                  },189                }190              }).pipe(191                Effect.withSpan("Tool.execute", {Effect 异步工作流。192                  attributes: {193                    "tool.name": id,194                    "session.id": toolCtx.sessionID,195                    "message.id": toolCtx.messageID,196                    ...(toolCtx.callID ? { "tool.call_id": toolCtx.callID } : {}),197                  },198                }),199              ),200          }
plugin tool 适配成内部 Tool.Def packages/opencode/src/tool/registry.ts:145-200

公共插件 API 保持 Promise/Zod 形状,Effect 与截断细节留在宿主边界。

145        function fromPlugin(id: string, def: ToolDefinition): Tool.Def {调用插件扩展点。146          // Plugin tools still expose Zod args publicly; keep that compatibility147          // boxed at the registry boundary and give the LLM the original JSON Schema.148          // Normalize missing args to `{}` once — pre-1.14.49 the code was149          // `z.object(def.args)` and Zod silently tolerated undefined (#27451, #27630).150          const args = def.args ?? {}151          const entries = Object.entries(args)152          const allZod = entries.every((entry) => isZodType(entry[1]))153          const zodParams = allZod ? z.object(args) : undefined定义并校验数据形状。154          const jsonSchema = zodParams ? zodJsonSchema(zodParams) : legacyJsonSchema(entries)155          const parameters = zodParams156            ? Schema.declare<unknown>((u): u is unknown => zodParams.safeParse(u).success)定义并校验数据形状。157            : Schema.Unknown定义并校验数据形状。158          return {返回给上一层。159            id,160            parameters,161            jsonSchema,162            description: def.description,163            execute: (args, toolCtx) =>164              Effect.gen(function* () {Effect 异步工作流。165                // Bridge the host's Effect-based `ask` into a Promise-returning166                // function for the plugin to make sure context persists167                const bridge = yield* EffectBridge.make()等待 Effect 结果。168                const pluginCtx: PluginToolContext = {调用插件扩展点。169                  ...toolCtx,170                  ask: (req) => bridge.promise(toolCtx.ask(req)),171                  directory: ctx.directory,172                  worktree: ctx.worktree,173                }174                const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx))工具真正执行入口。175                const output = typeof result === "string" ? result : result.output176                const metadata = typeof result === "string" ? {} : (result.metadata ?? {})177                const attachments = typeof result === "string" ? undefined : result.attachments178                const info = yield* agent.get(toolCtx.agent)等待 Effect 结果。179                const out = yield* truncate.output(output, {}, info)等待 Effect 结果。180                return {返回给上一层。181                  title: typeof result === "string" ? "" : (result.title ?? ""),182                  output: out.truncated ? out.content : output,183                  attachments,184                  metadata: {185                    ...metadata,186                    truncated: out.truncated,187                    ...(out.truncated && { outputPath: out.outputPath }),188                  },189                }190              }).pipe(191                Effect.withSpan("Tool.execute", {Effect 异步工作流。192                  attributes: {193                    "tool.name": id,194                    "session.id": toolCtx.sessionID,195                    "message.id": toolCtx.messageID,196                    ...(toolCtx.callID ? { "tool.call_id": toolCtx.callID } : {}),197                  },198                }),199              ),200          }

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

Section titled “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

packages/opencode/src/tool/registry.ts packages/opencode/src/tool/registry.ts:288-367
288    const describeSkill = Effect.fn("ToolRegistry.describeSkill")(function* (agent: Agent.Info) {Effect 异步工作流。289      const list = yield* skill.available(agent)等待 Effect 结果。290      if (list.length === 0) return "No skills are currently available."按条件进入分支。291      return [返回给上一层。292        "Load a specialized skill that provides domain-specific instructions and workflows.",293        "",294        "When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",295        "",296        "The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",297        "",298        'Tool output includes a `<skill_content name="...">` block with the loaded content.',299        "",300        "The following skills provide specialized sets of instructions for particular tasks",301        "Invoke this tool to load a skill when a task matches one of the available skills listed below:",302        "",303        Skill.fmt(list, { verbose: false }),304      ].join("\n")305    })306307    const describeTask = Effect.fn("ToolRegistry.describeTask")(function* (agent: Agent.Info) {Effect 异步工作流。308      const items = (yield* agents.list()).filter((item) => item.mode !== "primary")等待 Effect 结果。309      const filtered = items.filter(310        (item) => Permission.evaluate("task", item.name, agent.permission).action !== "deny",311      )312      const list = filtered.toSorted((a, b) => a.name.localeCompare(b.name))313      const description = list314        .map(315          (item) =>316            `- ${item.name}: ${item.description ?? "This subagent should only be called manually by the user."}`,317        )318        .join("\n")319      return ["Available agent types and the tools they have access to:", description].join("\n")返回给上一层。320    })321322    const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) {Effect 异步工作流。323      const filtered = (yield* all()).filter((tool) => {等待 Effect 结果。324        if (tool.id === WebSearchTool.id) {按条件进入分支。325          return webSearchEnabled(input.providerID, { exa: flags.enableExa, parallel: flags.enableParallel })选择模型或 provider。326        }327328        const usePatch =329          input.modelID.includes("gpt-") && !input.modelID.includes("oss") && !input.modelID.includes("gpt-4")选择模型或 provider。330        if (tool.id === ApplyPatchTool.id) return usePatch按条件进入分支。331        if (tool.id === EditTool.id || tool.id === WriteTool.id) return !usePatch按条件进入分支。332333        return true返回给上一层。334      })335336      return yield* Effect.forEach(Effect 异步工作流。337        filtered,338        Effect.fnUntraced(function* (tool: Tool.Def) {Effect 异步工作流。339          using _ = log.time(tool.id)340          const output = {341            description: tool.description,342            parameters: tool.parameters,343            jsonSchema: tool.jsonSchema,344          }345          yield* plugin.trigger("tool.definition", { toolID: tool.id }, output)调用插件扩展点。346          const jsonSchema =347            output.parameters === tool.parameters || output.jsonSchema !== tool.jsonSchema348              ? output.jsonSchema349              : undefined350          return {返回给上一层。351            id: tool.id,352            description: [353              output.description,354              tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined,等待 Effect 结果。355              tool.id === SkillTool.id ? yield* describeSkill(input.agent) : undefined,等待 Effect 结果。356            ]357              .filter(Boolean)358              .join("\n"),359            parameters: output.parameters,360            jsonSchema,361            execute: tool.execute,362            formatValidationError: tool.formatValidationError,363          }364        }),365        { concurrency: "unbounded" },366      )367    })
本轮 registry 工具筛选与描述扩展 packages/opencode/src/tool/registry.ts:322-367

registry 管可见能力;它还没有绑定 session 或真正执行。

322    const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) {Effect 异步工作流。323      const filtered = (yield* all()).filter((tool) => {等待 Effect 结果。324        if (tool.id === WebSearchTool.id) {按条件进入分支。325          return webSearchEnabled(input.providerID, { exa: flags.enableExa, parallel: flags.enableParallel })选择模型或 provider。326        }327328        const usePatch =329          input.modelID.includes("gpt-") && !input.modelID.includes("oss") && !input.modelID.includes("gpt-4")选择模型或 provider。330        if (tool.id === ApplyPatchTool.id) return usePatch按条件进入分支。331        if (tool.id === EditTool.id || tool.id === WriteTool.id) return !usePatch按条件进入分支。332333        return true返回给上一层。334      })335336      return yield* Effect.forEach(Effect 异步工作流。337        filtered,338        Effect.fnUntraced(function* (tool: Tool.Def) {Effect 异步工作流。339          using _ = log.time(tool.id)340          const output = {341            description: tool.description,342            parameters: tool.parameters,343            jsonSchema: tool.jsonSchema,344          }345          yield* plugin.trigger("tool.definition", { toolID: tool.id }, output)调用插件扩展点。346          const jsonSchema =347            output.parameters === tool.parameters || output.jsonSchema !== tool.jsonSchema348              ? output.jsonSchema349              : undefined350          return {返回给上一层。351            id: tool.id,352            description: [353              output.description,354              tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined,等待 Effect 结果。355              tool.id === SkillTool.id ? yield* describeSkill(input.agent) : undefined,等待 Effect 结果。356            ]357              .filter(Boolean)358              .join("\n"),359            parameters: output.parameters,360            jsonSchema,361            execute: tool.execute,362            formatValidationError: tool.formatValidationError,363          }364        }),365        { concurrency: "unbounded" },366      )367    })

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

6.6 第六站:SessionTools.resolve 绑定本次调用环境

Section titled “6.6 第六站:SessionTools.resolve 绑定本次调用环境”

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

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

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

packages/opencode/src/session/tools.ts packages/opencode/src/session/tools.ts:42-73
42  const context = (args: Record<string, unknown>, options: ToolExecutionOptions): Tool.Context => ({43    sessionID: input.session.id,44    abort: options.abortSignal!,45    messageID: input.processor.message.id,46    callID: options.toolCallId,47    extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps: input.promptOps },选择模型或 provider。48    agent: input.agent.name,49    messages: input.messages,50    metadata: (val) =>51      input.processor.updateToolCall(options.toolCallId, (match) => {52        if (!["running", "pending"].includes(match.state.status)) return match按条件进入分支。53        return {返回给上一层。54          ...match,55          state: {56            title: val.title,57            metadata: val.metadata,58            status: "running",59            input: args,60            time: { start: Date.now() },61          },62        }63      }),64    ask: (req) =>65      permission66        .ask({67          ...req,68          sessionID: input.session.id,69          tool: { messageID: input.processor.message.id, callID: options.toolCallId },70          ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),71        })72        .pipe(Effect.orDie),Effect 异步工作流。73  })

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

每次 tool call 的 session 与权限上下文 packages/opencode/src/session/tools.ts:42-73

同一个 Tool.Def 因调用轮次不同而获得不同 messageID、callID 与 ruleset。

42  const context = (args: Record<string, unknown>, options: ToolExecutionOptions): Tool.Context => ({43    sessionID: input.session.id,44    abort: options.abortSignal!,45    messageID: input.processor.message.id,46    callID: options.toolCallId,47    extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps: input.promptOps },选择模型或 provider。48    agent: input.agent.name,49    messages: input.messages,50    metadata: (val) =>51      input.processor.updateToolCall(options.toolCallId, (match) => {52        if (!["running", "pending"].includes(match.state.status)) return match按条件进入分支。53        return {返回给上一层。54          ...match,55          state: {56            title: val.title,57            metadata: val.metadata,58            status: "running",59            input: args,60            time: { start: Date.now() },61          },62        }63      }),64    ask: (req) =>65      permission66        .ask({67          ...req,68          sessionID: input.session.id,69          tool: { messageID: input.processor.message.id, callID: options.toolCallId },70          ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),71        })72        .pipe(Effect.orDie),Effect 异步工作流。73  })

6.7 第七站:Schema 先适配 provider,再交给模型

Section titled “6.7 第七站:Schema 先适配 provider,再交给模型”

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

1const schema = ProviderTransform.schema(选择模型或 provider。2  input.model,3  ToolJsonSchema.fromTool(item),定义并校验数据形状。4)56tools[item.id] = tool({7  description: item.description,8  inputSchema: jsonSchema(schema),9  execute(args, options) { /* ... */ },工具真正执行入口。10})

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

packages/opencode/src/session/tools.ts packages/opencode/src/session/tools.ts:75-115
75  for (const item of yield* registry.tools({等待 Effect 结果。76    modelID: ModelID.make(input.model.api.id),选择模型或 provider。77    providerID: input.model.providerID,选择模型或 provider。78    agent: input.agent,79  })) {80    const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))定义并校验数据形状。81    tools[item.id] = tool({82      description: item.description,83      inputSchema: jsonSchema(schema),84      execute(args, options) {工具真正执行入口。85        return run.promise(返回给上一层。86          Effect.gen(function* () {Effect 异步工作流。87            const ctx = context(args, options)88            yield* plugin.trigger(调用插件扩展点。89              "tool.execute.before",90              { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },91              { args },92            )93            const result = yield* item.execute(args, ctx)工具真正执行入口。94            const output = {95              ...result,96              attachments: result.attachments?.map((attachment) => ({97                ...attachment,98                id: PartID.ascending(),99                sessionID: ctx.sessionID,100                messageID: input.processor.message.id,101              })),102            }103            yield* plugin.trigger(调用插件扩展点。104              "tool.execute.after",105              { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args },106              output,107            )108            if (options.abortSignal?.aborted) {按条件进入分支。109              yield* input.processor.completeToolCall(options.toolCallId, output)等待 Effect 结果。110            }111            return output返回给上一层。112          }),113        )114      },115    })

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

6.8 第八站:模型调用 read 后,执行仍经过 hook 与 wrapper

Section titled “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

packages/opencode/src/session/tools.ts packages/opencode/src/session/tools.ts:84-115
84      execute(args, options) {工具真正执行入口。85        return run.promise(返回给上一层。86          Effect.gen(function* () {Effect 异步工作流。87            const ctx = context(args, options)88            yield* plugin.trigger(调用插件扩展点。89              "tool.execute.before",90              { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },91              { args },92            )93            const result = yield* item.execute(args, ctx)工具真正执行入口。94            const output = {95              ...result,96              attachments: result.attachments?.map((attachment) => ({97                ...attachment,98                id: PartID.ascending(),99                sessionID: ctx.sessionID,100                messageID: input.processor.message.id,101              })),102            }103            yield* plugin.trigger(调用插件扩展点。104              "tool.execute.after",105              { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args },106              output,107            )108            if (options.abortSignal?.aborted) {按条件进入分支。109              yield* input.processor.completeToolCall(options.toolCallId, output)等待 Effect 结果。110            }111            return output返回给上一层。112          }),113        )114      },115    })

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

provider 生成 args
-> before hook
-> Schema decode
-> 具体 read.execute
-> output truncate
-> 附件补归属 ID
-> after hook
-> 返回 runtime
内部工具到 AI SDK execute 的桥 packages/opencode/src/session/tools.ts:75-115

这一段把 registry 能力变成模型真的可以调用的函数。

75  for (const item of yield* registry.tools({等待 Effect 结果。76    modelID: ModelID.make(input.model.api.id),选择模型或 provider。77    providerID: input.model.providerID,选择模型或 provider。78    agent: input.agent,79  })) {80    const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))定义并校验数据形状。81    tools[item.id] = tool({82      description: item.description,83      inputSchema: jsonSchema(schema),84      execute(args, options) {工具真正执行入口。85        return run.promise(返回给上一层。86          Effect.gen(function* () {Effect 异步工作流。87            const ctx = context(args, options)88            yield* plugin.trigger(调用插件扩展点。89              "tool.execute.before",90              { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },91              { args },92            )93            const result = yield* item.execute(args, ctx)工具真正执行入口。94            const output = {95              ...result,96              attachments: result.attachments?.map((attachment) => ({97                ...attachment,98                id: PartID.ascending(),99                sessionID: ctx.sessionID,100                messageID: input.processor.message.id,101              })),102            }103            yield* plugin.trigger(调用插件扩展点。104              "tool.execute.after",105              { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args },106              output,107            )108            if (options.abortSignal?.aborted) {按条件进入分支。109              yield* input.processor.completeToolCall(options.toolCallId, output)等待 Effect 结果。110            }111            return output返回给上一层。112          }),113        )114      },115    })

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

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

Section titled “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

packages/opencode/src/session/tools.ts packages/opencode/src/session/tools.ts:118-203
118  for (const [key, item] of Object.entries(yield* mcp.tools())) {等待 Effect 结果。119    const execute = item.execute120    if (!execute) continue按条件进入分支。121122    const schema = yield* Effect.promise(() => Promise.resolve(asSchema(item.inputSchema).jsonSchema))Effect 异步工作流。123    const transformed = ProviderTransform.schema(input.model, schema)选择模型或 provider。124    item.inputSchema = jsonSchema(transformed)125    item.execute = (args, opts) =>126      run.promise(127        Effect.gen(function* () {Effect 异步工作流。128          const ctx = context(args, opts)129          yield* plugin.trigger(调用插件扩展点。130            "tool.execute.before",131            { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId },132            { args },133          )134          const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* Effect.gen(function* () {Effect 异步工作流。135            yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })进入权限审批。136            return yield* Effect.promise(() => execute(args, opts))工具真正执行入口。137          }).pipe(138            Effect.withSpan("Tool.execute", {Effect 异步工作流。139              attributes: {140                "tool.name": key,141                "tool.call_id": opts.toolCallId,142                "session.id": ctx.sessionID,143                "message.id": input.processor.message.id,144              },145            }),146          )147          yield* plugin.trigger(调用插件扩展点。148            "tool.execute.after",149            { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId, args },150            result,151          )152153          const textParts: string[] = []154          const attachments: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[] = []会话消息片段结构。155          for (const contentItem of result.content) {遍历集合。156            if (contentItem.type === "text") textParts.push(contentItem.text)按条件进入分支。157            else if (contentItem.type === "image") {158              attachments.push({159                type: "file",160                mime: contentItem.mimeType,161                url: `data:${contentItem.mimeType};base64,${contentItem.data}`,162              })163            } else if (contentItem.type === "resource") {164              const { resource } = contentItem165              if (resource.text) textParts.push(resource.text)按条件进入分支。166              if (resource.blob) {按条件进入分支。167                attachments.push({168                  type: "file",169                  mime: resource.mimeType ?? "application/octet-stream",170                  url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`,171                  filename: resource.uri,172                })173              }174            }175          }176177          const truncated = yield* truncate.output(textParts.join("\n\n"), {}, input.agent)等待 Effect 结果。178          const metadata = {179            ...result.metadata,180            truncated: truncated.truncated,181            ...(truncated.truncated && { outputPath: truncated.outputPath }),182          }183184          const output = {185            title: "",186            metadata,187            output: truncated.content,188            attachments: attachments.map((attachment) => ({189              ...attachment,190              id: PartID.ascending(),191              sessionID: ctx.sessionID,192              messageID: input.processor.message.id,193            })),194            content: result.content,195          }196          if (opts.abortSignal?.aborted) {按条件进入分支。197            yield* input.processor.completeToolCall(opts.toolCallId, output)等待 Effect 结果。198          }199          return output返回给上一层。200        }),201      )202    tools[key] = item203  }

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

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

Section titled “7. 五个决定安全与可维护性的分支”

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

packages/opencode/src/session/tools.ts packages/opencode/src/session/tools.ts:64-72
64    ask: (req) =>65      permission66        .ask({67          ...req,68          sessionID: input.session.id,69          tool: { messageID: input.processor.message.id, callID: options.toolCallId },70          ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),71        })72        .pipe(Effect.orDie),Effect 异步工作流。

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

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

Section titled “7.2 TypeScript 类型不等于运行时可信”

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

packages/opencode/src/tool/tool.ts packages/opencode/src/tool/tool.ts:87-111
87      const toolInfo = typeof init === "function" ? { ...(yield* init()) } : { ...init }等待 Effect 结果。88      // Compile the parser closure once per tool init; `decodeUnknownEffect`89      // allocates a new closure per call, so hoisting avoids re-closing it for90      // every LLM tool invocation.91      const decode = Schema.decodeUnknownEffect(toolInfo.parameters)定义并校验数据形状。92      const execute = toolInfo.execute93      toolInfo.execute = (args, ctx) => {94        const attrs = {95          "tool.name": id,96          "session.id": ctx.sessionID,97          "message.id": ctx.messageID,98          ...(ctx.callID ? { "tool.call_id": ctx.callID } : {}),99        }100        return Effect.gen(function* () {Effect 异步工作流。101          const decoded = yield* decode(args).pipe(等待 Effect 结果。102            Effect.mapError((error) =>Effect 异步工作流。103              toolInfo.formatValidationError104                ? new Error(toolInfo.formatValidationError(error), { cause: error })105                : new Error(106                    `The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,107                    { cause: error },108                  ),109            ),110          )111          const result = yield* execute(decoded as Schema.Schema.Type<Parameters>, ctx)工具真正执行入口。

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

packages/opencode/src/tool/tool.ts packages/opencode/src/tool/tool.ts:111-125
111          const result = yield* execute(decoded as Schema.Schema.Type<Parameters>, ctx)工具真正执行入口。112          if (result.metadata.truncated !== undefined) {按条件进入分支。113            return result返回给上一层。114          }115          const agent = yield* agents.get(ctx.agent)等待 Effect 结果。116          const truncated = yield* truncate.output(result.output, {}, agent)等待 Effect 结果。117          return {返回给上一层。118            ...result,119            output: truncated.content,120            metadata: {121              ...result.metadata,122              truncated: truncated.truncated,123              ...(truncated.truncated && { outputPath: truncated.outputPath }),124            },125          }
packages/opencode/src/tool/registry.ts packages/opencode/src/tool/registry.ts:174-188
174                const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx))工具真正执行入口。175                const output = typeof result === "string" ? result : result.output176                const metadata = typeof result === "string" ? {} : (result.metadata ?? {})177                const attachments = typeof result === "string" ? undefined : result.attachments178                const info = yield* agent.get(toolCtx.agent)等待 Effect 结果。179                const out = yield* truncate.output(output, {}, info)等待 Effect 结果。180                return {返回给上一层。181                  title: typeof result === "string" ? "" : (result.title ?? ""),182                  output: out.truncated ? out.content : output,183                  attachments,184                  metadata: {185                    ...metadata,186                    truncated: out.truncated,187                    ...(out.truncated && { outputPath: out.outputPath }),188                  },
packages/opencode/src/session/tools.ts packages/opencode/src/session/tools.ts:177-193
177          const truncated = yield* truncate.output(textParts.join("\n\n"), {}, input.agent)等待 Effect 结果。178          const metadata = {179            ...result.metadata,180            truncated: truncated.truncated,181            ...(truncated.truncated && { outputPath: truncated.outputPath }),182          }183184          const output = {185            title: "",186            metadata,187            output: truncated.content,188            attachments: attachments.map((attachment) => ({189              ...attachment,190              id: PartID.ascending(),191              sessionID: ctx.sessionID,192              messageID: input.processor.message.id,193            })),

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

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

Section titled “7.4 取消后仍需要尽量闭合工具状态”

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

packages/opencode/src/session/tools.ts packages/opencode/src/session/tools.ts:42-49
42  const context = (args: Record<string, unknown>, options: ToolExecutionOptions): Tool.Context => ({43    sessionID: input.session.id,44    abort: options.abortSignal!,45    messageID: input.processor.message.id,46    callID: options.toolCallId,47    extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps: input.promptOps },选择模型或 provider。48    agent: input.agent.name,49    messages: input.messages,
packages/opencode/src/session/tools.ts packages/opencode/src/session/tools.ts:108-110
108            if (options.abortSignal?.aborted) {按条件进入分支。109              yield* input.processor.completeToolCall(options.toolCallId, output)等待 Effect 结果。110            }

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

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

packages/opencode/src/tool/registry.ts packages/opencode/src/tool/registry.ts:203-216
203        const dirs = yield* config.directories()读取运行配置。204        const matches = dirs.flatMap((dir) =>205          Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }),读写本地文件。206        )207        if (matches.length) yield* config.waitForDependencies()读取运行配置。208        for (const match of matches) {遍历集合。209          const namespace = path.basename(match, path.extname(match))210          // `match` is an absolute filesystem path from `Glob.scanSync(..., { absolute: true })`.211          // Import it as `file://` so Node on Windows accepts the dynamic import.212          const mod = yield* Effect.promise(() => import(pathToFileURL(match).href))Effect 异步工作流。213          for (const [id, def] of Object.entries(mod)) {遍历集合。214            if (!isPluginTool(def)) continue调用插件扩展点。215            custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))调用插件扩展点。216          }
packages/opencode/src/session/tools.ts packages/opencode/src/session/tools.ts:75-81
75  for (const item of yield* registry.tools({等待 Effect 结果。76    modelID: ModelID.make(input.model.api.id),选择模型或 provider。77    providerID: input.model.providerID,选择模型或 provider。78    agent: input.agent,79  })) {80    const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))定义并校验数据形状。81    tools[item.id] = tool({

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

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

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

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

Section titled “9. TypeScript / Effect:只补四个阅读障碍”

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

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

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

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

packages/opencode/src/session/tools.ts packages/opencode/src/session/tools.ts:34-35
34  const tools: Record<string, AITool> = {}35  const run = yield* EffectBridge.make()等待 Effect 结果。
packages/opencode/src/tool/registry.ts packages/opencode/src/tool/registry.ts:163-174
163            execute: (args, toolCtx) =>164              Effect.gen(function* () {Effect 异步工作流。165                // Bridge the host's Effect-based `ask` into a Promise-returning166                // function for the plugin to make sure context persists167                const bridge = yield* EffectBridge.make()等待 Effect 结果。168                const pluginCtx: PluginToolContext = {调用插件扩展点。169                  ...toolCtx,170                  ask: (req) => bridge.promise(toolCtx.ask(req)),171                  directory: ctx.directory,172                  worktree: ctx.worktree,173                }174                const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx))工具真正执行入口。

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

Section titled “方法一:把工具拆成“定义、发现、绑定、执行””

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

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

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

Section titled “方法二:在最靠近副作用的位置授权”

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

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

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

Section titled “方法三:让失败与截断成为显式协议”

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

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

不要看上文,补全:

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

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

packages/opencode/src/tool/registry.ts packages/opencode/src/tool/registry.ts:322-367
322    const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) {Effect 异步工作流。323      const filtered = (yield* all()).filter((tool) => {等待 Effect 结果。324        if (tool.id === WebSearchTool.id) {按条件进入分支。325          return webSearchEnabled(input.providerID, { exa: flags.enableExa, parallel: flags.enableParallel })选择模型或 provider。326        }327328        const usePatch =329          input.modelID.includes("gpt-") && !input.modelID.includes("oss") && !input.modelID.includes("gpt-4")选择模型或 provider。330        if (tool.id === ApplyPatchTool.id) return usePatch按条件进入分支。331        if (tool.id === EditTool.id || tool.id === WriteTool.id) return !usePatch按条件进入分支。332333        return true返回给上一层。334      })335336      return yield* Effect.forEach(Effect 异步工作流。337        filtered,338        Effect.fnUntraced(function* (tool: Tool.Def) {Effect 异步工作流。339          using _ = log.time(tool.id)340          const output = {341            description: tool.description,342            parameters: tool.parameters,343            jsonSchema: tool.jsonSchema,344          }345          yield* plugin.trigger("tool.definition", { toolID: tool.id }, output)调用插件扩展点。346          const jsonSchema =347            output.parameters === tool.parameters || output.jsonSchema !== tool.jsonSchema348              ? output.jsonSchema349              : undefined350          return {返回给上一层。351            id: tool.id,352            description: [353              output.description,354              tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined,等待 Effect 结果。355              tool.id === SkillTool.id ? yield* describeSkill(input.agent) : undefined,等待 Effect 结果。356            ]357              .filter(Boolean)358              .join("\n"),359            parameters: output.parameters,360            jsonSchema,361            execute: tool.execute,362            formatValidationError: tool.formatValidationError,363          }364        }),365        { concurrency: "unbounded" },366      )367    })
packages/opencode/src/session/tools.ts packages/opencode/src/session/tools.ts:24-116
24export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {Effect 异步工作流。25  agent: Agent.Info26  model: Provider.Model选择模型或 provider。27  session: Session.Info28  processor: Pick<SessionProcessor.Handle, "message" | "updateToolCall" | "completeToolCall">处理模型流事件。29  bypassAgentCheck: boolean30  messages: MessageV2.WithParts[]会话消息片段结构。31  promptOps: TaskPromptOps32}) {33  using _ = log.time("resolveTools")34  const tools: Record<string, AITool> = {}35  const run = yield* EffectBridge.make()等待 Effect 结果。36  const plugin = yield* Plugin.Service调用插件扩展点。37  const permission = yield* Permission.Service等待 Effect 结果。38  const registry = yield* ToolRegistry.Service等待 Effect 结果。39  const mcp = yield* MCP.Service等待 Effect 结果。40  const truncate = yield* Truncate.Service等待 Effect 结果。4142  const context = (args: Record<string, unknown>, options: ToolExecutionOptions): Tool.Context => ({43    sessionID: input.session.id,44    abort: options.abortSignal!,45    messageID: input.processor.message.id,46    callID: options.toolCallId,47    extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps: input.promptOps },选择模型或 provider。48    agent: input.agent.name,49    messages: input.messages,50    metadata: (val) =>51      input.processor.updateToolCall(options.toolCallId, (match) => {52        if (!["running", "pending"].includes(match.state.status)) return match按条件进入分支。53        return {返回给上一层。54          ...match,55          state: {56            title: val.title,57            metadata: val.metadata,58            status: "running",59            input: args,60            time: { start: Date.now() },61          },62        }63      }),64    ask: (req) =>65      permission66        .ask({67          ...req,68          sessionID: input.session.id,69          tool: { messageID: input.processor.message.id, callID: options.toolCallId },70          ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),71        })72        .pipe(Effect.orDie),Effect 异步工作流。73  })7475  for (const item of yield* registry.tools({等待 Effect 结果。76    modelID: ModelID.make(input.model.api.id),选择模型或 provider。77    providerID: input.model.providerID,选择模型或 provider。78    agent: input.agent,79  })) {80    const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))定义并校验数据形状。81    tools[item.id] = tool({82      description: item.description,83      inputSchema: jsonSchema(schema),84      execute(args, options) {工具真正执行入口。85        return run.promise(返回给上一层。86          Effect.gen(function* () {Effect 异步工作流。87            const ctx = context(args, options)88            yield* plugin.trigger(调用插件扩展点。89              "tool.execute.before",90              { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },91              { args },92            )93            const result = yield* item.execute(args, ctx)工具真正执行入口。94            const output = {95              ...result,96              attachments: result.attachments?.map((attachment) => ({97                ...attachment,98                id: PartID.ascending(),99                sessionID: ctx.sessionID,100                messageID: input.processor.message.id,101              })),102            }103            yield* plugin.trigger(调用插件扩展点。104              "tool.execute.after",105              { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args },106              output,107            )108            if (options.abortSignal?.aborted) {按条件进入分支。109              yield* input.processor.completeToolCall(options.toolCallId, output)等待 Effect 结果。110            }111            return output返回给上一层。112          }),113        )114      },115    })116  }
  1. 入门:列出 Tool.ContextExecuteResult 的字段。
  2. 进阶:从 registry 的 read 初始化追到 AI SDK tools 对象中的 read key。
  3. 辨析:解释内部 parameters、jsonSchema 与 provider-transformed inputSchema 的区别。
  4. 失败路径:画出非法参数、权限拒绝、执行异常、输出截断四条分支。

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

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

Section titled “12. 最后复盘:工具已经接通,接下来进入具体副作用”

本章主链是:

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

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

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