Skip to content

Tool system: Map registration and invocation

源码版本v2026.6.11

Responsibilities

Tools are the only "hands" in the agent main loop: reading files, running commands, writing edits, calling external APIs — all exposed to the model as tools. OpenClaw stores every tool as a Map<string, AgentTool>, keyed by tool name, with values being objects containing a descriptor and an executor. When the model emits a tool_use block in its response, the main loop looks up the tool by name in the Map, calls its executor, and wraps the result as a tool_result block for the next turn.

The tool system itself doesn't execute business logic; it does four things: register (combine plugin, built-in, SDK custom tools into one table), filter (apply allow/deny policy and availability signals to drop invisible tools), wrap (attach before/after hooks to every tool), and dispatch (route the model's tool_use to the right executor). The code that "does work" lives in each tool's own executor; the system only wires it up to the model.

Design motivation

Why a single Map instead of something fancier? Because the tool-call hot path is dead simple: the model gives a name, the system looks up an executor. Map.get(name) is O(1), and deduplication by name is a natural constraint — there can't be two read tools in one session, otherwise the model wouldn't know which to call. refreshToolRegistry in agent-session.ts refreshToolRegistry:L2414-L2511 merges builtin, extension, and custom sources and deduplicates into a new Map that replaces the old one. This "rebuild rather than increment" approach makes policy changes (like a user temporarily disabling a tool) take effect immediately, without worrying about incremental-update misses.

Another motivation is descriptor / executor layering. ToolDescriptor is the public description for the planner: name, input schema, owner, availability expression. ToolExecutorRef is the runtime landing on which executor (core / plugin / channel / mcp). This layering lets the planner evaluate "can this tool be visible right now" without loading the executor, and plugin authors can declare tools without exposing the executor implementation. The planner (planner.ts buildToolPlan:L40-L67) can thus split visible / hidden tools without touching the runtime.

Key files

Data flow

Tool registration merges three sources (agent-session.ts rebuild 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 is the first gate: if a builtin tool is disabled by disableBuiltInTools, or the tool name isn't in the allowedToolNames allowlist, it never enters the Map. Note that builtin and extension both go through the same wrapRegisteredTools (src/agents/sessions/agent-session.ts:L2469-L2480), which attaches before/after hooks, error capture, and logging in one place, so subsequent executor calls don't repeat this logic.

The planner layer is earlier; it operates on ToolDescriptor and doesn't touch the 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 evaluates each descriptor's availability expression: missing auth, missing config, unset env, plugin disabled, context mismatch — any signal hit moves the tool into the hidden bucket with diagnostics. If a descriptor in the visible bucket has no executor, the planner throws missing-executor — visible means callable, that's the contract.

When the model emits a tool_use block, the main loop goes through the hook registered in 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 gives plugins an interception opportunity: return undefined to allow, return an override value to replace inputs, or throw to block. afterToolCall is symmetric, but fires after the executor runs and can rewrite the tool_result content or flip isError to true. Note the hook runs inside runWithSessionWriteLock, ensuring session state isn't concurrently modified during before/after.

Boundaries and failures

  • duplicate names throw hard: assertUniqueNames intercepts duplicate tool names at the planner stage and throws duplicate-tool-name — much cheaper than letting the model later be confused about which to call. Note src/agents/sessions/agent-session.ts:L2434-L2450 uses "later writes overwrite earlier"; extension tools overwrite same-named builtins intentionally, but the planner layer is "see duplicate, throw". The two layers differ because the Map layer has already merged; the planner layer is a contract check.
  • visible must have an executor: if a descriptor declares availability passing but no executor field, the planner throws missing-executor. This prevents "declared as callable but not implemented" dangling tools from reaching the model.
  • availability signals: auth / config / env / plugin-enabled / context — five signals can be composed into allOf / anyOf boolean expressions (src/tools/types.ts:L33-L50). Any signal failure sends the tool to the hidden bucket; diagnostics give reason + message for debugging why a tool suddenly disappeared.
  • hook throws block execution: a beforeToolCall throw is caught by runWithSessionWriteLock and bubbles up; the main loop marks this tool_use as failed. An afterToolCall throw only affects result rewriting — the executor has already finished.
  • cost of rebuilding the Map: refreshToolRegistry always news up a Map, looking wasteful, but tool counts are typically in the tens — rebuilding is far simpler than maintaining incremental state. Session-level state consistency beats micro-optimization.
  • active tools vs registry separation: toolRegistry is "which tools exist"; activeToolNames is "which tools this conversation uses". setActiveToolsByName (src/agents/sessions/agent-session.ts:L916-L931) only pulls names that exist in the Map into agent.state.tools; missing names are silently skipped — this lets callers degrade gracefully when tools aren't registered rather than hard-failing.

Summary

The tool system is the agent's only "hands": Map registration + planner bucketing + before/after hook wrapping + executor invocation. The descriptor / executor layering lets the planner evaluate availability functionally without touching the runtime; Map rebuild makes tool-set changes take effect immediately. The model emits tool_use, the system looks up the Map, runs the hook, calls the executor, and backfills tool_result — this path is the hot path every turn in Agent main loop. Difference from Skills: skills are Markdown instructions the model decides whether to read; tools are functions the system must register, and the model can only call names. Plugins can register tools (see Plugins); tools exposed by an MCP server are also bridged as entries in the same Map (see MCP).

Official references: Tools docs · README.