Sistema de herramientas: registro en Map e invocación
Responsabilidad
Las herramientas (tool) son la única salida del bucle principal del agent que puede «manipular»: leer archivos, ejecutar comandos, escribir ediciones, llamar APIs externas — todo se expone al modelo como herramienta. OpenClaw almacena cada herramienta en un Map<string, AgentTool>, donde la clave es el nombre y el valor es un objeto con descriptor y executor. Cuando el modelo emite un bloque tool_use en su respuesta, el bucle principal busca el nombre en el Map, invoca su función de ejecución y envuelve el resultado en un bloque tool_result que se inserta en la siguiente ronda de conversación.
El sistema de herramientas no ejecuta lógica de negocio directamente; solo hace cuatro cosas: registrar (plugins, builtin, herramientas custom del SDK se funden en una tabla), filtrar (según política allow/deny y señales de availability, descarta las no visibles), envolver (cuelga un hook before/after sobre cada herramienta) y despachar (cuando el modelo produce un tool_use, lo lleva al executor correcto). El código que de verdad «hace» está en el executor propio de cada herramienta; el sistema solo lo conecta al modelo.
Motivación de diseño
¿Por qué un Map simple en lugar de algo más sofisticado? Porque la ruta caliente de invocación de herramientas es muy sencilla: el modelo da un nombre, el sistema busca un executor. Map.get(name) es O(1), y la deduplicación por nombre es una restricción natural — no puede haber dos herramientas read en una misma sesión, o el modelo no sabría cuál invocar. refreshToolRegistry en agent-session.ts refreshToolRegistry:L2414-L2511 fusiona y deduplica tres fuentes (builtin, extension, custom), y al final reemplaza el Map viejo por uno nuevo. Este enfoque «reconstruir, no actualizar incrementalmente» hace que los cambios de política (por ejemplo, el usuario deshabilita temporalmente una herramienta) surtan efecto de inmediato, sin preocuparse por omisiones en actualizaciones incrementales.
Otra motivación es la separación entre descriptor y executor. ToolDescriptor es la descripción pública para la capa de planificación (planner): nombre, schema de entrada, owner, expresión de availability; ToolExecutorRef es la referencia runtime a qué ejecutor cae (core/plugin/channel/mcp). Esta separación permite que la capa de planificación evalúe «¿esta herramienta es visible ahora?» sin cargar el ejecutor, y los autores de plugins pueden declarar herramientas sin exponer la implementación del executor. El planner (planner.ts buildToolPlan:L40-L67) puede así separar herramientas visibles/ocultas (visible/hidden) sin tocar el runtime.
Archivos clave
types.ts ToolDescriptor:L53-L64— contrato público del descriptor de herramienta: owner/executor/availability en tres partes.types.ts ToolPlan:L96-L112— salida del planner, con buckets visible + hidden; hidden incluye diagnostics.planner.ts compare/sort:L18-L37— ordena y detecta duplicados; los duplicados lanzanToolPlanContractError.planner.ts buildToolPlan:L40-L67— entrada principal del planner; tras evaluar disponibilidad, separa en buckets.agent-session.ts toolRegistry:L395-L397— definición del campo del Map de registro a nivel sesión.agent-session.ts refreshToolRegistry:L2414-L2511— función central que reconstruye el Map cada vez que cambia el conjunto de herramientas.agent-session.ts setActiveToolsByName:L916-L931— extrae del Map el array de herramientas activas y reconstruye el system prompt.agent-session.ts installAgentToolHooks:L497-L551— punto de inyección de los dos hooksbeforeToolCall/afterToolCall.tool-dispatch.ts resolveSkillDispatchTools:L59-L249— seam que filtra por múltiples políticas cuando una skill dispara una invocación de herramienta.
Flujo de datos
El registro de herramientas converge desde tres fuentes (agent-session.ts reconstruye 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 es la primera compuerta: si una herramienta builtin está apagada por disableBuiltInTools, o si el nombre no está en la whitelist allowedToolNames, no entra en el Map. Nótese que builtin y extension pasan ambos por el mismo wrapRegisteredTools (src/agents/sessions/agent-session.ts:L2469-L2480), que añade de forma uniforme los hooks before/after, captura de errores y telemetría; después, el executor no necesita repetir esa lógica.
La capa del planner, más arriba, opera sobre ToolDescriptor y no toca el 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 evalúa la expresión availability de cada descriptor: si falta auth, si no está configurada, si no está el env, si el plugin no está activo, si el contexto no encaja — cualquier señal que se cumpla manda la herramienta al bucket hidden con sus diagnostics. Si en el bucket visible aparece un descriptor sin executor, el planner lanza missing-executor: visible implica invocable, es el contrato.
Cuando el modelo produce un bloque tool_use, el bucle principal recorre el hook registrado en 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 da a los plugins una oportunidad de intercepción: si devuelve undefined pasa, si devuelve un valor de anulación sustituye los argumentos, si lanza error bloquea la ejecución. afterToolCall es simétrico, pero se dispara tras el executor y puede reescribir el content del tool_result o pasar isError a true. Nótese que el hook va por runWithSessionWriteLock, de modo que el estado de la sesión no se modifica concurrentemente durante before/after.
Límites y fallos
- Nombre duplicado lanza error:
assertUniqueNamesintercepta nombres duplicados en la fase del planner y lanzaduplicate-tool-name— mucho más barato que dejar que el modelo se confunda después. Nótese que ensrc/agents/sessions/agent-session.ts:L2434-L2450se aplica «último en escribir gana»: las extension tools pisan los builtin con el mismo nombre, intencionado; pero en la capa del planner «ver un duplicado lanza». Las reglas difieren porque la capa del Map ya ha hecho el merge, mientras que la capa del planner valida el contrato. - Visible debe tener executor: si un descriptor pasa availability pero no rellena
executor, el planner lanzamissing-executor. Evita que una herramienta «declarada invocable pero sin implementación» se cuele hasta el modelo. - Señales de availability: cinco señales — auth/config/env/plugin-enabled/context — pueden combinarse en expresiones booleanas
allOf/anyOf(src/tools/types.ts:L33-L50). Cualquier fallo manda la herramienta al bucket hidden, con diagnostics que incluyen reason + message para depurar por qué una herramienta desapareció repentinamente. - Error de hook bloquea ejecución: un error lanzado por
beforeToolCalles capturado porrunWithSessionWriteLocky se propaga; el bucle principal marca estetool_usecomo fallido. Un error enafterToolCallsolo afecta a la reescritura del resultado — el executor ya ha terminado. - Coste de reconstruir el Map:
refreshToolRegistryhacenew Mapcada vez, parece desperdicio, pero el número de herramientas suele ser de unas decenas y reconstruir es mucho más simple que mantener estado incremental. La consistencia del estado a nivel sesión prima sobre la microoptimización. - Separación entre herramientas activas y registro:
toolRegistrydice «qué herramientas existen»;activeToolNamesdice «cuáles se usan en esta conversación».setActiveToolsByName(src/agents/sessions/agent-session.ts:L916-L931) solo saca del Map los nombres que existen y los mete enagent.state.tools; los nombres ausentes se saltan silenciosamente — permite al llamador degradar cuando una herramienta no está registrada en lugar de fallar.
Resumen
El sistema de herramientas es la única «mano» del agent: registro en Map + separación en buckets por planner + envoltura con hook before/after + ejecución real por el executor. La separación descriptor/executor permite que la capa de planificación evalúe disponibilidad de forma funcional pura sin tocar el runtime; la estrategia de reconstruir el Map hace que los cambios surtan efecto de inmediato. El modelo produce tool_use, el sistema busca en el Map, ejecuta el hook, invoca el executor y rellena tool_result — esta ruta es la ruta caliente que Bucle principal del agent recorre en cada turno. La diferencia con skills: los skills son instrucciones Markdown que el modelo decide si leer; las herramientas son funciones que el sistema debe registrar, y el modelo solo puede invocar el nombre. Los plugins pueden registrar herramientas (ver Plugins); las herramientas expuestas por un MCP server se puentean también como entradas del mismo Map (ver MCP).
Referencias oficiales: Documentación de herramientas · README.