ツールシステム:Map 登録と呼び出し
責務
ツール (tool) は agent メインループで唯一「手を動かせる」出口です:ファイル読み取り、コマンド実行、編集書き込み、外部 API 呼び出し,すべてツールの形式でモデルに公開されます。OpenClaw は各ツールを Map<string, AgentTool> で格納し,key がツール名,value がディスクリプタ (descriptor) とエグゼキュータ (executor) を含むオブジェクトです。モデルが応答内に tool_use ブロックを吐き出したとき,メインループは名前で Map から該当ツールを探し,そのエグゼキュータを呼び,結果を tool_result ブロックに包んで次ラウンドの対話に戻します。
ツールシステム自体はビジネスロジックを実行せず,4 件事だけを担当します:登録(プラグイン、ビルトイン、SDK カスタムツールを 1 枚の表に)、フィルタ(allow/deny ポリシー、availability シグナルで不可視なものを筛落とす)、ラップ(各ツールに before/after hook を取り付け)、スケジュール(モデルが tool_use を産出した後,正しい executor に落とす)。本当に「事をする」コードはツール自身の executor にあり,システムはそれをモデルに接続するだけです。
設計動機
なぜもっと派手な構造ではなく Map を使うのか?ツール呼び出しのホットパスが極めて単純だからです:モデルが名前を渡し,システムが executor を探す。Map の get(name) は O(1) で,名前による重複排除が自然な制約になります——同じセッションに 2 つの read ツールがあってはならず,さもなくばモデルは誰を呼ぶべきか分かりません。refreshToolRegistry(agent-session.ts refreshToolRegistry:L2414-L2511) は builtin、extension、custom の 3 つのソースをマージして重複排除し,最後に新しい Map を作って古いものを置き換えます。この「増分ではなく再構築」方式でポリシー変更(例:ユーザが一時的にあるツールを無効化)が即時反映され,増分更新の漏れを気にする必要がありません。
もう一つの動機はディスクリプタとエグゼキュータの階層分離です。ToolDescriptor はプランナー層 (planner) 向けの公開説明:名前、入力 schema、owner、availability 式。ToolExecutorRef が実行時にどの実行体に落ちるか(core/plugin/channel/mcp)。この階層化によりプランナー層は「このツールが現在看えるか」を実行体を読み込まずに評価でき,プラグイン作者も executor 実装を暴露せずにツールを宣言できます。プランナー(planner.ts buildToolPlan:L40-L67) はランタイムに触れずに可視/不可視 (visible/hidden) ツールを二分できます。
主要ファイル
types.ts ToolDescriptor:L53-L64— ツールディスクリプタ公開契約,owner/executor/availability の 3 段構成。types.ts ToolPlan:L96-L112— 計画出力,visible + hidden のバケット分け,hidden は diagnostics を持つ。planner.ts compare/sort:L18-L37— ソート + 重複名検出,重名は直接ToolPlanContractErrorをスロー。planner.ts buildToolPlan:L40-L67— プランナーメインエントリ,可用性評価後にバケット分け。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/afterToolCall2 つの hook の注入ポイント。tool-dispatch.ts resolveSkillDispatchTools:L59-L249— スキルがツール呼び出しをトリガーしたときに多層ポリシーでフィルタする継ぎ目。
データフロー
ツール登録は 3 つのソースから合流(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 呼び出しでこれらのロジックを繰り返す必要がありません。
プランナー層はより手前で,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 が出現すると,プランナーは 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はプランナー段階で重複ツール名を拦截し,duplicate-tool-nameをスローします——モデルに事後誰を呼ぶべきか困惑させるより安価です。src/agents/sessions/agent-session.ts:L2434-L2450の方は「後書きが前書きを上書き」で,extension ツールが同名 builtin を上書きします,これは意図的です。しかしプランナー層は「重名を見たらスロー」です。2 層のルールは異なり,Map 層はマージを完了した後,プランナー層は契約検証だからです。 - visible には executor が必須:descriptor が availability を通過したと宣言しながら
executorフィールドを記入しないと,プランナーはmissing-executorをスローします。これは「呼べると宣言したが実装がない」という宙に浮いたツールがモデル前に漏れるのを防ぐためです。 - availability シグナル:auth/config/env/plugin-enabled/context の 5 種シグナルは
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 登録 + プランナーのバケット分け + before/after hook ラップ + executor の本当の実行。descriptor/executor の階層分離によりプランナー層は純関数で可用性を評価しランタイムに触れません。Map 再構築ポリシーでツールセット変更が即時反映されます。モデルが tool_use を産出し,システムが Map を引き,hook を走り,executor を呼び,tool_result を還流する——このパスは Agent メインループ で毎ラウンド走るホットパスです。スキル との違いは:スキルは Markdown ガイドでモデル自身が読むか決め,ツールはシステムが登録した関数でモデルは名前を呼ぶだけです。プラグインはツールを登録でき(プラグイン 参照),MCP server が公開するツールも同じ Map の項目としてブリッジされます(MCP 参照)。
公式資料:Tools ドキュメント · README。