工具系統:Map 登錄與呼叫
職責
工具 (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 工具,否則模型不知道該叫誰。refreshToolRegistry 在 agent-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) 工具一分為二。
關鍵檔案
types.ts ToolDescriptor:L53-L64— 工具描述符公開契約,owner/executor/availability 三段式。types.ts ToolPlan:L96-L112— 規劃輸出,visible + hidden 分桶,hidden 帶 diagnostics。planner.ts compare/sort:L18-L37— 排序 + 重名偵測,重名直接拋ToolPlanContractError。planner.ts buildToolPlan:L40-L67— planner 主入口,可用性評估後分桶。agent-session.ts toolRegistry:L395-L397— 工作階段級 Map 登錄表欄位定義。agent-session.ts refreshToolRegistry:L2414-L2511— 每次工具集變更時重建 Map 的核心函式。agent-session.ts setActiveToolsByName:L916-L931— 從 Map 裡取出當前啟用工具陣列並重建系統提示。agent-session.ts installAgentToolHooks:L497-L551—beforeToolCall/afterToolCall兩個 hook 的注入點。tool-dispatch.ts resolveSkillDispatchTools:L59-L249— 技能觸發工具呼叫時按多層策略過濾的 seam。
資料流
工具登錄從三股來源匯流(agent-session.ts 重建 Map:L2426-L2486):
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):
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:
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)。