Système d'outils: enregistrement Map et invocation
Responsabilités
Un outil (tool) est la seule sortie « qui agit » dans la boucle principale de l'agent: lire un fichier, lancer une commande, écrire un edit, appeler une API externe — tout est exposé au modèle sous forme d'outil. OpenClaw stocke chaque outil dans une Map<string, AgentTool>, clé = nom d'outil, valeur = un objet contenant un descripteur (descriptor) et une fonction d'exécution (executor). Quand le modèle émet un bloc tool_use, la boucle principale cherche le nom dans la Map, appelle son exécuteur, et remplit le résultat dans un bloc tool_result qui sera renvoyé au tour suivant.
Le système d'outils n'exécute pas lui-même la logique métier; il fait quatre choses: enregistrement (plugins, builtin, outils SDK custom fusionnés en une table), filtrage (par politique allow/deny, par signaux d'availability), wrapping (before/after hook autour de chaque outil), dispatch (le tool_use produit par le modèle atterrit sur le bon exécuteur). Le code qui « fait vraiment » est dans l'exécuteur propre à chaque outil; le système ne fait que le brancher au modèle.
Motivation de conception
Pourquoi une Map plutôt qu'une structure plus sophistiquée? Parce que le hot path d'appel d'outil est extrêmement simple: le modèle donne un nom, le système cherche un exécuteur. Map.get(name) est O(1), et la déduplication par nom est une contrainte naturelle — une même session ne peut pas avoir deux outils read, sinon le modèle ne sait pas lequel appeler. refreshToolRegistry dans agent-session.ts refreshToolRegistry:L2414-L2511 fusionne et déduplique les trois sources (builtin, extension, custom) et produit une nouvelle Map qui remplace l'ancienne. Cette approche « rebuild而非 incrémentale » fait que tout changement de politique (par exemple désactiver temporairement un outil) prend effet immédiatement, sans risque d'oubli incrémental.
L'autre motivation est la séparation descriptor / exécuteur. ToolDescriptor est la description publique destinée à la couche de planification (planner): nom, schéma des paramètres, owner, expression d'availability; ToolExecutorRef est la référence runtime vers l'exécuteur (core/plugin/channel/mcp). Cette séparation permet au planner d'évaluer « cet outil est-il visible actuellement » sans charger l'exécuteur; les auteurs de plugins peuvent aussi déclarer un outil sans exposer l'implémentation de l'exécuteur. Le planner (planner.ts buildToolPlan:L40-L67) peut donc séparer outils visibles/cachés (visible/hidden) sans toucher au runtime.
Fichiers clés
types.ts ToolDescriptor:L53-L64— Contrat public du descripteur d'outil; triptyque owner/executor/availability.types.ts ToolPlan:L96-L112— Sortie de planification, buckets visible + hidden; hidden porte des diagnostics.planner.ts compare/sort:L18-L37— Tri + détection de doublons; doublon lèveToolPlanContractError.planner.ts buildToolPlan:L40-L67— Entrée principale du planner; bucketing après évaluation d'availability.agent-session.ts toolRegistry:L395-L397— Définition du champ registre de session Map.agent-session.ts refreshToolRegistry:L2414-L2511— Fonction centrale qui rebuild la Map à chaque changement d'ensemble d'outils.agent-session.ts setActiveToolsByName:L916-L931— Extrait le tableau d'outils actifs de la Map et rebuild le system prompt.agent-session.ts installAgentToolHooks:L497-L551— Point d'injection des deux hooksbeforeToolCall/afterToolCall.tool-dispatch.ts resolveSkillDispatchTools:L59-L249— Seam qui filtre par politiques multi-niveaux quand une compétence déclenche un appel d'outil.
Flux de données
L'enregistrement des outils fusionne trois sources (agent-session.ts rebuild 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 est la première barrière: si l'outil builtin est désactivé par disableBuiltInTools, ou si le nom n'est pas dans l'allowlist allowedToolNames, il n'entre jamais dans la Map. Notez que builtin et extension passent tous deux par le même wrapRegisteredTools (src/agents/sessions/agent-session.ts:L2469-L2480), qui applique uniformément les hooks before/after, l'error catching, et le logging; les appels d'exécuteur ultérieurs n'ont pas à répéter cette logique.
La couche planner, en amont, manipule des ToolDescriptor sans toucher aux exécuteurs (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 évalue l'expression availability de chaque descriptor: auth manquante, config vide, env non posée, plugin désactivé, contexte non matchant — n'importe quel signal hit envoie l'outil dans le bucket hidden avec ses diagnostics. Si dans le bucket visible apparaît un descriptor sans executor, le planner lève missing-executor — visible implique invocable; c'est le contrat.
Dès que le modèle émet un bloc tool_use, la boucle principale passe par le hook enregistré dans agent-session.ts installAgentToolHooks:L497-L551:
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 offre au plugin une opportunité d'interception: retourner undefined passe; retourner une valeur de remplacement substitute les args; lever une erreur bloquer l'exécution. afterToolCall symétriquement, sauf qu'il se déclenche après l'exécution de l'exécuteur, pouvant réécrire le contenu de tool_result ou traduire isError en true. Notez que le hook passe par runWithSessionWriteLock, garantissant que l'état de session ne soit pas modifié concurremment pendant before/after.
Limites et modes d'échec
- Doublon lève systématiquement:
assertUniqueNamesintercepte au stade planner les noms d'outils dupliqués, lèveduplicate-tool-name— bien moins cher que de laisser le modèle confus après-coup. Notez quesrc/agents/sessions/agent-session.ts:L2434-L2450a une politique « dernier écrit écrase premier », l'outil d'extension écrase le builtin de même nom; mais le planner, lui, lève sur doublon. Les deux couches ont des règles différentes car la Map a déjà fini sa fusion quand le planner valide le contrat. - Visible doit avoir un executor: si un descriptor déclare availability OK mais sans champ
executor, le planner lèvemissing-executor. C'est pour empêcher un outil « déclaré appelable mais non implémenté » de s'infiltrer devant le modèle. - Signaux d'availability: auth/config/env/plugin-enabled/context — cinq signaux composables via
allOf/anyOf(src/tools/types.ts:L33-L50). Tout signal qui fail envoie dans le bucket hidden, avec diagnostics donnant reason + message, pour déboguer pourquoi un outil disparaît soudainement. - Hook qui lève bloque l'exécution: une erreur de
beforeToolCallest capturée parrunWithSessionWriteLocket remonte; la boucle principale marque ce tool_use comme failed. Une erreurafterToolCalln'affecte que la réécriture du résultat; l'exécuteur a déjà fini. - Coût du rebuild de Map:
refreshToolRegistrynew une Map à chaque fois — gaspillage apparent, mais le nombre d'outils est typiquement de quelques dizaines, et le rebuild est bien plus simple que de maintenir un état incrémental. La cohérence d'état au niveau session prime sur la micro-optimisation. - Séparation outils actifs et registre:
toolRegistrydécrit « quels outils existent »,activeToolNamesdécrit « lesquels sont utilisés dans cette conversation ».setActiveToolsByName(src/agents/sessions/agent-session.ts:L916-L931) ne sort de la Map que les noms présents et les pousse dansagent.state.tools; les noms manquants sont skipés silencieusement — l'appelant peut ainsi dégrader quand l'outil n'est pas enregistré plutôt que de planter.
Résumé
Le système d'outils est les « mains » de l'agent: enregistrement Map + bucketing planner + wrapping before/after hook + exécution par exécuteur. La séparation descriptor/executor permet à la couche de planification d'évaluer l'availability de façon purement fonctionnelle sans toucher au runtime; le rebuild de Map rend tout changement d'ensemble d'outils immédiatement effectif. Modèle émet tool_use, système consulte la Map, lance hook, appelle exécuteur, remplit tool_result — ce chemin est le hot path parcouru à chaque tour par la Boucle principale de l'agent. La différence avec les compétences: les compétences sont des guides Markdown que le modèle décide lui-même de lire ou non; les outils sont des fonctions qui doivent être enregistrées par le système, et le modèle ne peut qu'invoquer leur nom. Les plugins peuvent enregistrer des outils (voir Plugins); les outils exposés par un MCP server sont aussi pontés en entrées de la même Map (voir MCP).
Pour comparer avec la documentation officielle: Tools docs · README.