Skip to content

工具系统:Map 注册与调用

源码版本v2026.6.11

职责

工具 (tool) 是 agent 主循环里唯一能"动手"的出口:读文件、跑命令、写编辑、调外部 API,全部以工具的形式暴露给模型。OpenClaw 把每一个工具存成一张 Map<string, AgentTool>,key 是工具名,value 是包含描述符 (descriptor) 与执行函数 (executor) 的对象。模型在响应里吐出 tool_use 块时,主循环拿名字去 Map 里查到对应工具,调它的执行函数,把结果包成 tool_result 块塞回下一轮对话。

工具系统并不直接执行业务逻辑,它只负责四件事:注册(插件、内置、SDK 自定义工具汇成一张表)、过滤(按 allow/deny 策略、availability 信号筛掉不可见的)、包装(给每个工具套上 before/after hook)、调度(模型产出 tool_use 后落到正确 executor)。真正"做事"的代码在工具自己的 executor 里,系统只负责把它接到模型上。

设计动机

为什么用一张 Map 而不是更花哨的结构?因为工具调用的热路径极其简单:模型给一个名字,系统查一个 executor。Map 的 get(name) 是 O(1),而且按名字去重是天然约束——同一个会话里不能有两个 read 工具,否则模型不知道该调谁。refreshToolRegistryagent-session.ts refreshToolRegistry:L2414-L2511 里把 builtin、extension、custom 三股来源合并去重,最后落成一张新的 Map 替换旧的,这种"重建而非增量"的方式让策略变更(比如用户临时禁用某个工具)立刻生效,不需要担心增量更新遗漏。

另一个动机是descriptor 与 executor 分层ToolDescriptor 是面向规划层 (planner) 的公开描述:名字、入参 schema、owner、availability 表达式;ToolExecutorRef 才是运行时落到哪个执行体上(core/plugin/channel/mcp)。这种分层让规划层可以先评估"这个工具当前能不能见"而不必加载执行体,插件作者也能在不暴露 executor 实现的前提下声明工具。planner (planner.ts buildToolPlan:L40-L67) 因此能在不接触运行时的前提下,把可见/隐藏 (visible/hidden) 工具一分为二。

关键文件

数据流

工具注册从三股来源汇流(agent-session.ts 重建 Map:L2426-L2486):

typescript
const registeredTools = this.currentExtensionRunner.getAllRegisteredTools();
const allCustomTools = [
  ...registeredTools,
  ...this.customTools.map((definition) => ({
    definition,
    sourceInfo: createSyntheticSourceInfo(`<sdk:${definition.name}>`, { source: "sdk" }),
  })),
].filter((tool) => isAllowedTool(tool.definition.name));
// ...
const toolRegistry = new Map(wrappedBuiltInTools.map((tool) => [tool.name, tool]));
for (const tool of wrappedExtensionTools) {
  toolRegistry.set(tool.name, tool);
}
this.toolRegistry = toolRegistry;

isAllowedTool 是第一道闸:若内置工具被 disableBuiltInTools 关掉、或工具名不在 allowedToolNames 白名单里,根本不会进 Map。注意 builtin 和 extension 都走同一个 wrapRegisteredTools(src/agents/sessions/agent-session.ts:L2469-L2480),这套包装把 before/after hook、错误捕获、日志埋点统一打了上去,后续 executor 调用就无需重复这些逻辑。

planner 这一层则更靠前,它操作的是 ToolDescriptor,不接触 executor(planner.ts buildToolPlan:L40-L67):

typescript
export function buildToolPlan(options: BuildToolPlanOptions): ToolPlan {
  const descriptors = options.descriptors.toSorted(compareDescriptors);
  assertUniqueNames(descriptors);

  const visible: ToolPlanEntry[] = [];
  const hidden: HiddenToolPlanEntry[] = [];

  for (const descriptor of descriptors) {
    const diagnostics = [
      ...evaluateToolAvailability({ descriptor, context: options.availability }),
    ];
    if (diagnostics.length > 0) {
      hidden.push({ descriptor, diagnostics });
      continue;
    }
    if (!descriptor.executor) {
      throw new ToolPlanContractError({
        code: "missing-executor",
        toolName: descriptor.name,
        message: `Visible tool descriptor has no executor ref: ${descriptor.name}`,
      });
    }
    visible.push({ descriptor, executor: descriptor.executor });
  }

evaluateToolAvailability 把每个 descriptor 的 availability 表达式放进去算:auth 缺失、config 没填、env 没设、插件未启用、context 不匹配,任何一个信号命中就把工具扔进 hidden 桶并附上 diagnostics。visible 桶里如果出现"没 executor"的 descriptor,planner 直接抛 missing-executor——可见即必须可调用,这是契约。

模型一旦在响应里产出 tool_use 块,主循环走 agent-session.ts installAgentToolHooks:L497-L551 里注册的 hook:

typescript
this.agent.beforeToolCall = async ({ toolCall, args }) => {
  const runner = this.currentExtensionRunner;
  return await this.runWithSessionWriteLock(async () => {
    if (!runner.hasHandlers("tool_call")) {
      return undefined;
    }
    try {
      return await runner.emitToolCall({
        type: "tool_call",
        toolName: toolCall.name,
        toolCallId: toolCall.id,
        input: args as Record<string, unknown>,
      });
    } catch (err) {
      if (err instanceof Error) {
        throw err;
      }
      throw new Error(`Extension failed, blocking execution: ${String(err)}`, { cause: err });
    }
  });
};

beforeToolCall 给插件一个拦截机会:它返回 undefined 放行,返回覆盖值替换入参,抛错直接阻断执行。afterToolCall 同理,只是它在 executor 跑完后触发,可以改写 tool_result 的 content 或把 isError 翻成 true。注意 hook 走 runWithSessionWriteLock,保证 before/after 期间会话状态不会被并发改写。

边界与失败

  • 重名直接抛错:assertUniqueNames 在 planner 阶段就拦截重复工具名,抛 duplicate-tool-name——比让模型事后困惑该调谁要便宜得多。注意 src/agents/sessions/agent-session.ts:L2434-L2450 那边是"后写覆盖前写",extension 工具会覆盖同名 builtin,这是有意的;但 planner 层是"看见重名就抛"。两层规则不同,因为 Map 那层已经做完合并,planner 那层是契约校验。
  • visible 必须有 executor:descriptor 若声明了 availability 通过却没填 executor 字段,planner 抛 missing-executor。这是防止"声明了能调但没实现"的悬空工具漏到模型面前。
  • availability 信号:auth/config/env/plugin-enabled/context 五种信号都能组合成 allOf / anyOf 布尔表达式(src/tools/types.ts:L33-L50)。任一信号失败都会进 hidden 桶,diagnostics 给出 reason + message,方便调试为什么某个工具突然不见。
  • hook 抛错阻断执行:beforeToolCall 抛错会被 runWithSessionWriteLock 捕获并向上冒泡,主循环把这次 tool_use 标记为失败。afterToolCall 抛错则只影响结果改写,executor 已经跑完了。
  • 重建 Map 的代价:refreshToolRegistry 每次都 new 一个 Map,看起来浪费,但工具数量通常几十个,重建比维护增量状态简单太多。会话级状态一致性优先于微优化。
  • 激活工具与注册表分离:toolRegistry 是"存在哪些工具",activeToolNames 是"这次对话用哪些"。setActiveToolsByName(src/agents/sessions/agent-session.ts:L916-L931) 只把 Map 里存在的名字捞出来塞进 agent.state.tools,缺名字会被静默跳过——这允许调用方在工具未注册时降级而不是硬错。

小结

工具系统是 agent 唯一的"手脚":Map 注册 + planner 分桶 + before/after hook 包装 + executor 真正执行。descriptor/executor 分层让规划层可以纯函数式评估可用性而不碰运行时;Map 重建策略让工具集变更立即生效。模型产 tool_use、系统查 Map、跑 hook、调 executor、回灌 tool_result——这条路径是 Agent 主循环 每一轮都要走的热路径。和 技能 的区别是:技能是 Markdown 指引,模型自己决定读不读;工具是必须由系统注册的函数,模型只能调名字。插件可以注册工具(见 插件),MCP server 暴露的工具也会被桥接成同一张 Map 里的条目(见 MCP)。

对照官方资料:Tools 文档 · README