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