内置工具

上一章讲的是消息怎样在模型、Agent 和工具之间流动,这一章则想回答另一个问题:模型手里其实只有一套工具清单,它凭什么知道该调用哪个,又为什么相信调用之后真的能改文件、跑命令、搜网络。

先说结论:LLM 本身不访问文件系统,也启动不了进程。模型能做的,只是根据请求里给出的工具定义生成一个工具名和一组参数,真正执行操作的是 Agent 运行时。执行结果随后被写回消息历史,模型再拿着新结果决定下一步。所以编码 Agent 的能力上限,一半取决于模型,另一半取决于宿主程序提供了什么工具、怎样描述工具、每轮让模型看到哪些工具,以及调用之后那条执行链是否可靠。

正在渲染 Mermaid 图表...

Microsoft Agent Framework、OpenCode 和 Codex 都实现了这条链路,但对工具粒度和运行时边界的取舍却不一样。下面把它们放在一起看。

工具由什么组成,模型每轮又能看到哪些

工具要能被模型正确调用,至少要同时具备名称、用途说明、参数结构和真正的执行代码。名称用来选能力,说明告诉模型什么时候该用它,参数结构约束模型生成什么样的输入,执行代码才是最终落到文件系统或进程上的那一步。模型没有调用本地函数时的类型检查和 IDE 提示,它判断怎么调用,靠的全是名称、描述和参数 Schema,所以这几项并不是可有可无的注释。

三个项目对“工具”这个抽象的定义大体相似,表达方式却不同。OpenCode 在 packages/opencode/src/tool/tool.ts 里把它定义成 Def,包含 iddescriptionparametersexecute(args, ctx)parameters 是 Effect Schema 解码器,注册时由 packages/opencode/src/tool/json-schema.tsToolJsonSchema.fromSchema 转换成模型能够理解的 JSON Schema,补上 Draft 2020-12 的 $schema,并把 $defs 内联到主体里。MAF 走的是另一条路:AIToolAIFunction 来自外部依赖 Microsoft.Extensions.AI,它用 AIFunctionFactory.Create 把一个普通 .NET 方法转成模型工具,方法名、[Description] 特性、参数类型和默认值都会被读成名称、说明和参数 Schema。Codex 则把模型可见的规格和负责执行的处理器分开放,ToolSpecToolExecutorToolRuntimeToolExposure 都集中在 codex-rs/tools/src/tool_executor.rs,运行时既能看到工具长什么样,也能通过同一套注册表找到对应的执行逻辑。

描述写得不好会直接影响调用质量,这一点在 Codex 的 apply_patch 上最明显。它没有把补丁塞进一个普通 JSON 函数的字符串参数,而是做成了带 Lark 文法的自由格式工具:

pub fn create_apply_patch_freeform_tool(include_environment_id: bool) -> ToolSpec {
    ToolSpec::Freeform(FreeformTool {
        name: "apply_patch".to_string(),
        description: "The `apply_patch` tool can be used to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.".to_string(),
        defer_loading: None,
        format: FreeformToolFormat {
            r#type: "grammar".to_string(),
            syntax: "lark".to_string(),
            definition,
        },
    })
}

源码位于 codex-rs/core/src/tools/handlers/apply_patch_spec.rs。补丁不进 JSON 字符串,模型就不用反复转义引号、换行和反斜杠,而是直接生成接近补丁本身的结构,出错的概率自然会低一些。

编码 Agent 常见的内置能力,大致可以归成四类,三个项目的覆盖情况如下:

能力OpenCodeCodexMAF
查找与读取readglobgreplsp主要靠 exec_commandshell_command,另有 view_imagefile_access_read_filefile_access_list_filesfile_access_search_files
修改文件editwriteapply_patchapply_patchfile_access_save_filefile_access_delete_file
执行命令shellexec_commandshell_commandrun_shell
辅助工作todotaskfetchsearchskillupdate_planspawn_agentweb_searchtool_searchtodos_*background_agents_*、Agent Skills

这张表只能说明能力相似,不能说明实现相同。Codex 压根没有把 readglobgrep 注册成独立的核心工具,模型通常直接用 shell 命令去读文件、找文件,所以同样是“看代码”,Codex 和 OpenCode 的调用方式完全不一样。OpenCode 把不同的检索意图拆开了:模型知道路径就调 read,知道文件名模式就调 glob,只知道内容就调 grep,意图越明确,模型越不容易走弯路。MAF 的文件工具则属于通用 Agent Harness,接口更像一个受控的文件存取服务,并不把精细编辑大型代码库当成主要目标。

工具也不是越多越好。每轮请求模型时,工具名称、说明和参数 Schema 都要跟着进输入上下文,工具越多请求体越大,模型在相似工具之间选错的概率也会升高。所以三个项目都没有简单地把所有能力永久塞给模型,而是在注册、上下文或权限层裁剪本轮的清单,只是裁剪的位置不同。

MAF 让工具随会话状态变化,由上下文提供者按当前 Session 生成。ChatClientAgent.PrepareSessionAndMessagesAsync 在运行前依次调用已注册的 AIContextProvider,它们各自返回消息、指令和工具,再合并写进当轮的 ChatOptions,因此工具不必在创建 Agent 时永久固定。TodoProviderProvideAIContextAsync 里读取当前 Session,据此创建 todos_addtodos_completetodos_remove 等工具,同时注入一份当前待办摘要;AgentModeProvider 读取当前是 plan 还是 execute 模式,重新生成模式说明和 mode_setmode_getBackgroundAgentsProvider 也以同样的方式提供启动、等待、继续和查询后台 Agent 的工具。

protected override async ValueTask<AIContext> ProvideAIContextAsync(
    InvokingContext context,
    CancellationToken cancellationToken = default)
{
    return new AIContext
    {
        Instructions = this._instructions,
        Tools = this.CreateTools(context.Session),
        Messages = [new ChatMessage(ChatRole.User, message)],
    };
}

这样做的好处是扩展自然:Provider 不只决定工具,还能一并提供模型完成这次工具调用所需的状态。代价是选择逻辑分散在多个 Provider 里,想知道某一轮实际有哪些工具,得看合并后的 AIContext

OpenCode 则先按模型和 Agent 组装工具表,再按权限规则隐藏一部分。注册入口在 packages/opencode/src/tool/registry.ts,读取、搜索、编辑、命令、任务、待办、技能和网络访问等工具都在里面,插件也可以往里加;MCP 工具随后在 packages/opencode/src/session/tools.ts 里转换成统一的 AI SDK 工具并合入本轮清单。它还会根据模型挑编辑工具,规则不是“只有 GPT-5 用 apply_patch”,而是模型 ID 含 gpt- 且不含 ossgpt-4 的,配 apply_patch 并隐藏 editwrite;其他模型仍走 editwrite。这说明工具接口还可以针对模型擅长的输出格式来调整。注册完成之后,权限规则才决定模型最终看见什么,packages/opencode/src/permission/index.ts 里的 visibleToolsdisabled 负责剔除被整体禁止的工具,allowaskdeny 则继续在真正执行时生效。所以“模型能看见”和“这次能不能执行”是两个判断:一个只读 Agent 可以靠权限规则只留下读和搜的能力,写工具即使存在于全局注册表,也不会出现在它的模型请求里。

Codex 把直接暴露和延迟发现拆开。ToolExposure 定义在 codex-rs/tools/src/tool_executor.rs,包含 DirectDeferredDeferredModelOnlyDirectModelOnlyCodeModeOnlyHiddenDirectDirectModelOnly 能进入初始模型工具列表,DeferredDeferredModelOnly 则可以被工具搜索发现。每轮构建工具路由时,codex-rs/core/src/tools/spec_plan.rs 会汇总核心工具、MCP 工具、扩展工具和动态工具,再应用 MCP Server 的 omit_tools_from 配置,最后 build_model_visible_specs 只收集 exposure.is_direct() 的规格;若注册表里存在可搜索的延迟工具,运行时还会再加一个 tool_search

for tool in registry.entries() {
    let exposure = tool.exposure;
    if !exposure.is_direct() {
        continue;
    }

    let spec = tool.runtime.spec();
    specs.push(spec_for_model_request(..., spec));
}

tool_search 不是简单的名称过滤。codex-rs/core/src/tools/handlers/tool_search.rs 会从延迟工具的 ToolSearchInfo 建一个 BM25 索引,模型提交查询后,工具返回匹配到的可加载规格。这样常用的核心工具可以直接出现,大量 MCP 或扩展能力就不必全塞进初始上下文。

三种做法解决的是同一个问题,却落在不同的抽象层:MAF 根据会话状态生成工具,OpenCode 根据模型和 Agent 权限过滤工具,Codex 根据暴露面区分常驻能力和延迟能力。真实系统也可以组合它们,比如先按角色移除写工具,再把剩下的大量外部工具设为延迟发现。

文件工具,三种不同的取向

读写文件是编码 Agent 最基础的能力,也是最容易看出框架定位差异的地方。读取要控制输出大小,编辑则要解决一个更难的题:模型嘴里说的“把这段改成那样”,怎样准确地对应到文件里的实际位置。三个项目给出的答案各不相同。

MAF 的 FileSystemAgentFileStoreReadFileAsyncWriteFileAsync 读写完整文本,没有按行读取的参数,也没有局部替换的接口。它并不是毫无限制地接受任意路径:StorePaths.NormalizeRelativePath 会拒绝根路径和 .. 这类片段,ResolveSafePath 把路径约束在配置的根目录里,并检查符号链接或 reparse point,避免模型通过路径跳出文件存储边界。这种接口适合报告、记忆和生成文件这类通用场景,但要在几千行的源文件里改几行,整文件读和整文件覆盖会吃掉更多 Token,模型在复述未修改内容时还容易引入无关差异。

OpenCode 的 read 针对代码阅读加了 offsetlimit。返回内容带行号,默认最多 2000 行,同时限制累计字节数和单行长度,超出范围会提示模型从新的 offset 继续读;完整输出超过限制时会被写入 tool-output 目录,模型只拿到受控的预览。

41: export function login(user: User) {
42:   if (!user.token) return false
43:   return verify(user.token)
44: }

它的 edit 让模型提交 oldStringnewString。默认要求旧文本唯一,否则工具会让模型补充更多上下文。为了容忍缩进、空白、转义和局部复述的差异,packages/opencode/src/tool/edit.ts 依次尝试多个替换策略,其中一部分用 Levenshtein 相似度。源码里确实有九个 Replacer,但更准确的说法是“九种候选匹配策略”,而不是一套带严格等级语义的九级模糊算法。工具还会拒绝范围明显失衡的匹配,并为同一个文件加锁,避免两个编辑同时覆盖。apply_patch 适合一次描述多个 hunk 或多个文件,它先解析全部补丁并计算文件变化,检查外部目录访问,再统一申请编辑权限,之后才写盘并触发文件事件和 LSP 诊断;write 则用于整文件内容,它会先算 diff,再申请编辑权限,并保留 BOM 等文件信息。

Codex 没有专用的核心 readglobgrep,模型用 exec_commandshell_command 调系统命令来读文件、搜文件;编辑则主要交给 apply_patch。它的补丁格式靠上下文而不是绝对行号定位修改:

*** Begin Patch
*** Update File: src/app.py
@@ def greet():
-    print("Hi")
+    print("Hello")
*** End Patch

codex-rs/apply-patch/src/seek_sequence.rs 会先精确查找上下文序列,再依次尝试忽略行尾空白、忽略两端空白,以及归一化常见 Unicode 标点。补丁在执行前经过解析和预检,无法定位的修改会作为错误返回,运行时还会记录已提交的 delta,用来展示本轮文件变化。不过预检和 delta 跟踪不等于跨文件事务,不能把它描述成“任一失败都会自动回滚整个补丁”。

把三者摆在一起看,文件工具并不存在唯一正确的形态。MAF 选择简单、受根目录保护的完整文件接口,靠路径约束覆盖安全的边界;OpenCode 用行范围读取和唯一字符串替换,降低模型操作的门槛;Codex 用 shell 负责通用读取,再用上下文补丁承担结构化编辑。选哪一种,取决于 Agent 面向的是通用文件任务还是大型代码库,也取决于目标模型更擅长生成字符串替换还是补丁。

一次工具调用之后,还有一条执行链

模型返回工具名和参数,不等于运行时可以把它当成可信操作。一次完整执行通常还要经过参数校验、权限判断、并发准入、实际执行、错误转换和结果回填,任何一环处理不当,都可能让 Agent 改错文件、卡住会话,或被一次巨大的命令输出耗尽上下文。

正在渲染 Mermaid 图表...

MAF 的 ChatClientAgent 自己只负责调用 IChatClient.GetResponseAsync,真正的函数调用循环来自外部依赖 Microsoft.Extensions.AIFunctionInvokingChatClient。MAF 在 ChatClientExtensions.WithDefaultAgentMiddleware 里装配这层中间件,并在自己的 Harness 里提供审批和状态管理。这个边界很重要:从 MAF 仓库能确认工具怎样被装进 ChatOptions、审批怎样排队,却不能只凭这个仓库断言 FunctionInvokingChatClient 内部全部的并行和错误处理细节。

它的 FileAccessProvider 会把六个文件工具全部包成 ApprovalRequiredAIFunction,连只读工具也不例外。调用方可以用 ReadOnlyToolsAutoApprovalRule 自动批准读取、列举和搜索,也可以用 AllToolsAutoApprovalRule 自动批准全部文件操作。ToolApprovalAgent 收到多个未批准请求时会分类处理:能自动批准的先批准,其余请求排队,再逐个交给调用方,而不是把所有请求当成一个不可拆分的整体。

OpenCode 在 packages/opencode/src/session/tools.ts 里把内部工具和 MCP 工具包装成模型工具,调用发生时再执行各自的 Effect。工具通过 ctx.ask 主动声明所需权限,比如 editwebfetch 或外部目录访问,等待用户回答时当前执行会被挂起;工具结果、错误和状态变化则由 packages/opencode/src/session/processor.ts 持久化成 Session Part,下一次 provider turn 重新读取历史后继续。它还会检查最近三次工具调用,如果同一工具带着完全相同的参数连续调用三次,就触发 doom_loop 权限询问。这个机制并不是判断所有循环,而是拦截一种非常具体的失控迹象,把决定权交还给用户。

Codex 的工具调用由 ToolCallRuntime 路由。codex-rs/core/src/tools/parallel.rs 为每次调用启动任务,并通过共享的 RwLock 控制并发:声明了 supports_parallel_tool_calls() 的工具取读锁,可以同时执行;其他工具取写锁,需要独占执行。这样只读或彼此独立的操作可以并发,有副作用的操作仍然串行。除致命错误外,Codex 会把工具失败转换成 success: false 的工具结果,而不是直接终止整个 turn,模型看到“参数错误”“命令失败”或“用户取消”后,可以调整参数或换一条路径。危险操作还会进入 ToolOrchestrator,依次处理审批、沙箱选择、执行和必要的升级重试。命令行内部怎样选 shell、管进程、截断输出,会在下一章继续展开;更完整的权限规则和审批策略,则留到后面的权限控制章节。

这三套实现放在一起,能得出一个更实际的判断:内置工具的价值不在数量,而在工具定义、暴露策略和执行边界能不能互相配合。定义决定模型会不会正确调用,暴露策略决定每轮请求承担多少上下文成本,执行边界决定一次模型输出最终能否安全、可恢复地作用到真实环境。只注册几个函数并不难,难的是把这三部分做成一条稳定的 Agent 运行链路。

本文涉及的主要源码入口如下:

  • MAF:src/Microsoft.Agents.AI/Harnesssrc/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cssrc/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cssrc/Microsoft.Agents.AI.Tools.Shell
  • OpenCode:packages/opencode/src/toolpackages/opencode/src/session/tools.tspackages/opencode/src/session/processor.tspackages/opencode/src/permission/index.ts
  • Codex:codex-rs/tools/src/tool_executor.rscodex-rs/core/src/toolscodex-rs/core/src/session/turn.rscodex-rs/apply-patch