Skip to content

Sistema de herramientas: registro en Map e invocación

源码版本v2026.6.11

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

Flujo de datos

El registro de herramientas converge desde tres fuentes (agent-session.ts reconstruye 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 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):

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 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:

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 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: assertUniqueNames intercepta nombres duplicados en la fase del planner y lanza duplicate-tool-name — mucho más barato que dejar que el modelo se confunda después. Nótese que en src/agents/sessions/agent-session.ts:L2434-L2450 se 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 lanza missing-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 beforeToolCall es capturado por runWithSessionWriteLock y se propaga; el bucle principal marca este tool_use como fallido. Un error en afterToolCall solo afecta a la reescritura del resultado — el executor ya ha terminado.
  • Coste de reconstruir el Map: refreshToolRegistry hace new Map cada 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: toolRegistry dice «qué herramientas existen»; activeToolNames dice «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 en agent.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.