Werkzeugsystem: Map-Registrierung und Aufruf
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
types.ts ToolDescriptor:L53-L64— Öffentlicher Werkzeugdeskriptor-Vertrag, owner/executor/availability dreiteilig.types.ts ToolPlan:L96-L112— Planungsausgabe, visible + hidden getrennt, hidden trägt diagnostics.planner.ts compare/sort:L18-L37— Sortierung + Namensduplikat-Erkennung; Duplikat wirft direktToolPlanContractError.planner.ts buildToolPlan:L40-L67— planner-Haupteintritt; nach Verfügbarkeitsbewertung in Buckets.agent-session.ts toolRegistry:L395-L397— Sitzungslevel Map-Registry-Felddefinition.agent-session.ts refreshToolRegistry:L2414-L2511— Kernfunktion zum Neuaufbau der Map bei jeder Werkzeugmengenänderung.agent-session.ts setActiveToolsByName:L916-L931— Holt das aktuell aktivierte Werkzeug-Array aus der Map und baut den System-Prompt neu.agent-session.ts installAgentToolHooks:L497-L551— Injektionsstelle für die zwei HookbeforeToolCall/afterToolCall.tool-dispatch.ts resolveSkillDispatchTools:L59-L249— Seam, der bei Fertigkeit-ausgelöstem Werkzeugaufruf nach mehrschichtiger Strategie filtert.
Datenfluss
Werkzeugregistrierung vereint drei Quellen (agent-session.ts Map-Neuaufbau: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 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):
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:
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:
assertUniqueNamesfängt im planner-Stadium doppelte Werkzeugnamen ab und wirftduplicate-tool-name— billiger, als das Modell nachher irritiert. Beachten Sie: Aufsrc/agents/sessions/agent-session.ts:L2434-L2450gilt „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 plannermissing-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 vonrunWithSessionWriteLockgefangen und bubbelt hoch; die Hauptschleife markiert diesen tool_use als fehlgeschlagen. EinafterToolCall-Throw berührt nur die Ergebnisumschreibung, der Executor ist bereits gelaufen. - Kosten des Map-Neuaufbaus:
refreshToolRegistrynewt 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:
toolRegistryist „welche Werkzeuge existieren",activeToolNamesist „welche in dieser Konversation".setActiveToolsByName(src/agents/sessions/agent-session.ts:L916-L931) fischt nur Namen, die in der Map existieren, inagent.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.