Skip to content

Werkzeugsystem: Map-Registrierung und Aufruf

源码版本v2026.6.11

Verantwortung

Ein Werkzeug (tool) ist im Agent-Hauptschleife der einzige Ausgang, der „mit anpackt": Datei lesen, Befehl ausführen, Bearbeitung schreiben, externe API aufrufen — alles als Werkzeug dem Modell präsentiert. OpenClaw speichert jedes Werkzeug in einer Map<string, AgentTool>; Schlüssel ist der Werkzeugname, Wert ein Objekt aus Deskriptor (descriptor) und Ausführungsfunktion (executor). Spielt das Modell in der Antwort einen tool_use-Block aus, schlägt die Hauptschleife den Namen in der Map nach, ruft die Ausführungsfunktion auf und packt das Ergebnis als tool_result-Block in die nächste Runde.

Das Werkzeugsystem selbst führt keine Geschäftslogik aus, es macht nur vier Dinge: Registrierung (registration) (Plugin/intern/SDK-eigene Werkzeuge zu einer Tabelle vereinen), Filterung (filtering) (nach Allow/Deny-Strategie und Availability-Signalen Unsichtbares aussieben), Verpackung (wrapping) (für jedes Werkzeug Before/After-Hook aufsetzen), Dispatch (dispatching) (modellproduzierter tool_use landet beim richtigen executor). Der Code, der „wirklich etwas tut", steckt im Werkzeug-eigenen executor; das System verbindet ihn nur mit dem Modell.

Designmotivation

Warum eine Map und keine schickere Struktur? Weil der Hot Path des Werkzeugaufrufs extrem einfach ist: Modell liefert einen Namen, System schlägt einen executor nach. Map.get(name) ist O(1), und Entdopplung nach Namen ist ein natürliches Constraint — in derselben Sitzung kann es nicht zwei read-Werkzeuge geben, sonst weiß das Modell nicht, wen es rufen soll. refreshToolRegistry in agent-session.ts refreshToolRegistry:L2414-L2511 vereint drei Quellen (builtin/extension/custom) zu einer entdoppelten Tabelle und ersetzt am Ende die alte Map durch eine neue. Diese „Neuaufbau statt inkrementell"-Strategie lässt Strategieänderungen (z. B. Nutzer deaktiviert kurzfristig ein Werkzeug) sofort wirksam werden, ohne Angst vor inkrementellen Update-Lücken.

Ein weiteres Motiv ist Deskriptor/Executor-Schichtung. Der ToolDescriptor ist die öffentliche Beschreibung für die Planungsschicht (planner): Name, Input-Schema, owner, Availability-Ausdruck; ToolExecutorRef ist die Laufzeit, auf welchen Executor es fällt (core/plugin/channel/mcp). Diese Schichtung lässt die Planungsschicht zuerst bewerten „kann dieses Werkzeug gerade gesehen werden", ohne den Executor zu laden; Plugin-Autoren können Werkzeuge deklarieren, ohne die Executor-Implementierung preiszugeben. Der planner (planner.ts buildToolPlan:L40-L67) kann daher sichtbare/verdeckte (visible/hidden) Werkzeuge trennen, ohne die Laufzeit zu berühren.

Schlüsseldateien

Datenfluss

Werkzeugregistrierung vereint drei Quellen (agent-session.ts Map-Neuaufbau: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 ist die erste Schleuse: Ist ein eingebautes Werkzeug über disableBuiltInTools abgeschaltet oder der Werkzeugname nicht in der allowedToolNames-Whitelist, kommt es gar nicht in die Map. Beachten Sie: builtin und extension gehen beide durch denselben wrapRegisteredTools (src/agents/sessions/agent-session.ts:L2469-L2480); dieses Wrapping schlägt Before/After-Hook, Fehlerabfang, Logging einheitlich auf, sodass spätere Executor-Aufrufe diese Logik nicht wiederholen müssen.

Die planner-Schicht liegt weiter vorn und operiert auf ToolDescriptor, ohne den Executor zu berühren (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 setzt den availability-Ausdruck jedes Deskriptors ein: fehlendes auth, nicht gesetzte config, nicht gesetztes env, Plugin deaktiviert, Kontext passt nicht — trifft ein Signal, wandert das Werkzeug in den hidden-Bucket mit diagnostics. Taucht im visible-Bucket ein Deskriptor „ohne executor" auf, wirft der planner missing-executor — sichtbar impliziert aufrufbar, das ist der Vertrag.

Spielt das Modell in der Antwort einen tool_use-Block aus, läuft die Hauptschleife durch den in agent-session.ts installAgentToolHooks:L497-L551 registrierten Hook:

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 gibt dem Plugin eine Abfangmöglichkeit: undefined liefert Durchlass, ein override-Wert ersetzt die Parameter, ein Throw blockt die Ausführung. afterToolCall symmetrisch, nur dass es nach dem Executor-Lauf feuert und tool_result-content umschreiben oder isError auf true kippen kann. Der Hook läuft über runWithSessionWriteLock, damit der Sitzungszustand während before/after nicht nebenläufig umgeschrieben wird.

Grenzen und Fehler

  • Namensduplikat wirft direkt: assertUniqueNames fängt im planner-Stadium doppelte Werkzeugnamen ab und wirft duplicate-tool-name — billiger, als das Modell nachher irritiert. Beachten Sie: Auf src/agents/sessions/agent-session.ts:L2434-L2450 gilt „später geschrieben überschreibt früheres" — ein extension-Werkzeug überschreibt ein gleichnamiges builtin (bewusst); auf der planner-Schicht hingegen „sieht Duplikat, wirft". Die zwei Schichten haben unterschiedliche Regeln, weil die Map-Schicht bereits zusammengeführt ist, der planner die Vertragsprüfung macht.
  • visible muss executor haben: Deklariert ein Deskriptor die Verfügbarkeit als erfüllt, aber ohne executor-Feld, wirft der planner missing-executor. Verhindert „deklariert aufrufbar, aber keine Implementierung"-Werkzeuge vor das Modell durchrutschen.
  • Availability-Signale: auth/config/env/plugin-enabled/context — fünf Signale, die zu allOf / anyOf-Bool-Ausdrücken kombiniert werden (src/tools/types.ts:L33-L50). Trifft eines zu, geht es in den hidden-Bucket; diagnostics liefern reason + message, damit sich nachverfolgen lässt, warum ein Werkzeug plötzlich verschwand.
  • Hook-Throw blockt Ausführung: ein beforeToolCall-Throw wird von runWithSessionWriteLock gefangen und bubbelt hoch; die Hauptschleife markiert diesen tool_use als fehlgeschlagen. Ein afterToolCall-Throw berührt nur die Ergebnisumschreibung, der Executor ist bereits gelaufen.
  • Kosten des Map-Neuaufbaus: refreshToolRegistry newt jedes Mal eine Map — wirkt verschwenderisch, aber bei Werkzeugzahlen im Bereich Dutzender ist Neuaufbau schlicht einfacher als inkrementeller Zustand. Sitzungslevel Zustandskonsistenz schlägt Mikrooptimierung.
  • Aktive Werkzeuge vs. Registry getrennt: toolRegistry ist „welche Werkzeuge existieren", activeToolNames ist „welche in dieser Konversation". setActiveToolsByName (src/agents/sessions/agent-session.ts:L916-L931) fischt nur Namen, die in der Map existieren, in agent.state.tools — fehlende Namen werden stillschweigend übersprungen, damit Aufrufer degradieren können, statt hart zu scheitern.

Zusammenfassung

Das Werkzeugsystem ist des Agenten einzige „Hand": Map-Registrierung + planner-Buckets + Before/After-Hook-Wrapping + echte Executor-Ausführung. Deskriptor/Executor-Schichtung lässt die Planungsschicht Verfügbarkeit rein funktional bewerten, ohne Laufzeit zu berühren; Map-Neuaufbau lässt Werkzeugmengenänderungen sofort wirksam werden. Modell produziert tool_use, System schlägt in Map nach, läuft Hook, ruft Executor, führt tool_result zurück — dieser Pfad ist der Hot Path, den Agent-Hauptschleife jede Runde geht. Unterschied zu Fertigkeiten: Fertigkeiten sind Markdown-Anleitungen, das Modell entscheidet selbst, ob es liest; Werkzeuge sind zwingend vom System registrierte Funktionen, das Modell kann nur den Namen aufrufen. Plugins können Werkzeuge registrieren (siehe Plugins); Werkzeuge, die ein MCP-Server exponiert, werden als Einträge in dieselbe Map gebrückt (siehe MCP).

Vergleich mit offiziellen Ressourcen: Tools-Doku · README.