From 1466878c36ff81256af3b128e03b331332919038 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 14:52:21 +0100 Subject: [PATCH] refactor: cache plugin tool descriptors (#76079) Co-authored-by: Shakker --- CHANGELOG.md | 2 +- docs/plugins/building-plugins.md | 5 + docs/tools/index.md | 6 + src/plugins/tool-descriptor-cache.ts | 156 ++++++++++ src/plugins/tool-factory-cache.ts | 102 ------- src/plugins/tools.optional.test.ts | 162 +++++------ src/plugins/tools.ts | 407 ++++++++++++++++++++++----- 7 files changed, 571 insertions(+), 269 deletions(-) create mode 100644 src/plugins/tool-descriptor-cache.ts delete mode 100644 src/plugins/tool-factory-cache.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1457dec865f..3079be3fbd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai - Agents/runtime: memoize transcript replay-policy resolution for stable config and process-env runs while preserving custom-env provider hook behavior. Thanks @DmitryPogodaev. - Infra/path-guards: add a fast path for canonical absolute POSIX containment checks, avoiding repeated `path.resolve` and `path.relative` work in hot filesystem walkers. Refs #75895, #75575, and #68782. Thanks @Enderfga. - Tools: add a platform-level tool descriptor planner for descriptor-first visibility, generic availability checks, and executor references. Thanks @shakkernerd. +- Plugins/tools: cache plugin tool descriptors captured from `api.registerTool(...)` so repeated prompt-time planning can skip plugin runtime loading while execution still loads the live plugin tool. (#76079) Thanks @shakkernerd. - Docs/Codex: clarify that ChatGPT/Codex subscription setups should use `openai/gpt-*` with `agentRuntime.id: "codex"` for native Codex runtime, while `openai-codex/*` remains the PI OAuth route. Thanks @pashpashpash. - Plugins/source checkout: load bundled plugins from the `extensions/*` pnpm workspace tree in source checkouts, so plugin-local dependencies and edits are used directly while packaged installs keep using the built runtime tree. Thanks @vincentkoc. - Plugins/beta: externalize ACPX behind the official `@openclaw/acpx` package so packaged installs keep ACP harness adapter binaries out of core until the ACP backend is installed. Thanks @vincentkoc. @@ -275,7 +276,6 @@ Docs: https://docs.openclaw.ai - Agents/status: resolve `session_status(sessionKey="current")` for sparse channel-plugin sessions after literal current lookups miss, so Scope, Slack, Discord, and other plugin-driven agents avoid retrying through `Unknown sessionKey: current`. Fixes #74141. (#72306) Thanks @bittoby. - Cron: retry recurring wake-now main-session jobs through temporary heartbeat busy skips before recording success, so queued cron events no longer appear as ok ghost runs while the main lane is still busy. Fixes #75964. (#76083) Thanks @kshetrajna12 and @xuruiray. - ## 2026.4.30 ### Changes diff --git a/docs/plugins/building-plugins.md b/docs/plugins/building-plugins.md index 0b43a001ec6..a2001407135 100644 --- a/docs/plugins/building-plugins.md +++ b/docs/plugins/building-plugins.md @@ -257,6 +257,11 @@ plugin manifest: } ``` +OpenClaw captures and caches the validated descriptor from the registered tool, +so plugins do not duplicate `description` or schema data in the manifest. The +manifest contract only declares ownership and discovery; execution still calls +the live registered tool implementation. + Users enable optional tools in config: ```json5 diff --git a/docs/tools/index.md b/docs/tools/index.md index f3f7ce801af..7238b92da73 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -111,6 +111,12 @@ Plugins can register additional tools. Some examples: - [OpenProse](/prose) — markdown-first workflow orchestration - [Tokenjuice](/tools/tokenjuice) — compact noisy `exec` and `bash` tool results +Plugin tools are still authored with `api.registerTool(...)` and declared in +the plugin manifest's `contracts.tools` list. OpenClaw captures the validated +tool descriptor during discovery and caches it by plugin source and contract, so +later tool planning can skip plugin runtime loading. Tool execution still loads +the owning plugin and calls the live registered implementation. + ## Tool configuration ### Allow and deny lists diff --git a/src/plugins/tool-descriptor-cache.ts b/src/plugins/tool-descriptor-cache.ts new file mode 100644 index 00000000000..9ea47b6520f --- /dev/null +++ b/src/plugins/tool-descriptor-cache.ts @@ -0,0 +1,156 @@ +import fs from "node:fs"; +import type { AnyAgentTool } from "../agents/tools/common.js"; +import { resolveRuntimeConfigCacheKey } from "../config/runtime-snapshot.js"; +import type { JsonObject, ToolDescriptor } from "../tools/types.js"; +import type { PluginLoadOptions } from "./loader.js"; +import type { OpenClawPluginToolContext } from "./types.js"; + +const PLUGIN_TOOL_DESCRIPTOR_CACHE_VERSION = 1; +const PLUGIN_TOOL_DESCRIPTOR_CACHE_LIMIT = 256; + +export type CachedPluginToolDescriptor = { + descriptor: ToolDescriptor; + displaySummary?: string; + ownerOnly?: boolean; + optional: boolean; +}; + +const descriptorCache = new Map(); +let descriptorCacheObjectIds = new WeakMap(); +let nextDescriptorCacheObjectId = 1; + +export function resetPluginToolDescriptorCache(): void { + descriptorCache.clear(); + descriptorCacheObjectIds = new WeakMap(); + nextDescriptorCacheObjectId = 1; +} + +function sourceFingerprint(source: string): string { + try { + const stat = fs.statSync(source); + return `${stat.size}:${Math.round(stat.mtimeMs)}`; + } catch { + return "missing"; + } +} + +function getDescriptorCacheObjectId(value: object | null | undefined): number | null { + if (!value) { + return null; + } + const existing = descriptorCacheObjectIds.get(value); + if (existing !== undefined) { + return existing; + } + const next = nextDescriptorCacheObjectId++; + descriptorCacheObjectIds.set(value, next); + return next; +} + +function getDescriptorConfigCacheKey( + value: PluginLoadOptions["config"] | null | undefined, +): string | number | null { + if (!value) { + return null; + } + try { + return resolveRuntimeConfigCacheKey(value); + } catch { + return getDescriptorCacheObjectId(value); + } +} + +function buildDescriptorContextCacheKey(params: { + ctx: OpenClawPluginToolContext; + currentRuntimeConfig?: PluginLoadOptions["config"] | null; +}): string { + const { ctx } = params; + return JSON.stringify({ + config: getDescriptorConfigCacheKey(ctx.config), + runtimeConfig: getDescriptorConfigCacheKey(ctx.runtimeConfig), + currentRuntimeConfig: getDescriptorConfigCacheKey(params.currentRuntimeConfig), + fsPolicy: ctx.fsPolicy ?? null, + workspaceDir: ctx.workspaceDir ?? null, + agentDir: ctx.agentDir ?? null, + agentId: ctx.agentId ?? null, + sessionKey: ctx.sessionKey ?? null, + sessionId: ctx.sessionId ?? null, + browser: ctx.browser ?? null, + messageChannel: ctx.messageChannel ?? null, + agentAccountId: ctx.agentAccountId ?? null, + deliveryContext: ctx.deliveryContext ?? null, + requesterSenderId: ctx.requesterSenderId ?? null, + senderIsOwner: ctx.senderIsOwner ?? null, + sandboxed: ctx.sandboxed ?? null, + }); +} + +export function buildPluginToolDescriptorCacheKey(params: { + pluginId: string; + source: string; + rootDir?: string; + contractToolNames: readonly string[]; + ctx: OpenClawPluginToolContext; + currentRuntimeConfig?: PluginLoadOptions["config"] | null; +}): string { + return JSON.stringify({ + version: PLUGIN_TOOL_DESCRIPTOR_CACHE_VERSION, + pluginId: params.pluginId, + source: params.source, + rootDir: params.rootDir ?? null, + sourceFingerprint: sourceFingerprint(params.source), + contractToolNames: [...params.contractToolNames].toSorted(), + context: buildDescriptorContextCacheKey({ + ctx: params.ctx, + currentRuntimeConfig: params.currentRuntimeConfig, + }), + }); +} + +function asJsonObject(value: unknown): JsonObject { + return value as JsonObject; +} + +export function capturePluginToolDescriptor(params: { + pluginId: string; + tool: AnyAgentTool; + optional: boolean; +}): CachedPluginToolDescriptor { + const label = (params.tool as { label?: unknown }).label; + const title = typeof label === "string" && label.trim() ? label.trim() : undefined; + return { + ...(params.tool.displaySummary ? { displaySummary: params.tool.displaySummary } : {}), + ...(params.tool.ownerOnly === true ? { ownerOnly: true } : {}), + optional: params.optional, + descriptor: { + name: params.tool.name, + ...(title ? { title } : {}), + description: params.tool.description, + inputSchema: asJsonObject(params.tool.parameters), + owner: { kind: "plugin", pluginId: params.pluginId }, + executor: { kind: "plugin", pluginId: params.pluginId, toolName: params.tool.name }, + }, + }; +} + +export function readCachedPluginToolDescriptors( + cacheKey: string, +): readonly CachedPluginToolDescriptor[] | undefined { + return descriptorCache.get(cacheKey); +} + +export function writeCachedPluginToolDescriptors(params: { + cacheKey: string; + descriptors: readonly CachedPluginToolDescriptor[]; +}): void { + if ( + !descriptorCache.has(params.cacheKey) && + descriptorCache.size >= PLUGIN_TOOL_DESCRIPTOR_CACHE_LIMIT + ) { + const oldestKey = descriptorCache.keys().next().value; + if (oldestKey !== undefined) { + descriptorCache.delete(oldestKey); + } + } + descriptorCache.set(params.cacheKey, [...params.descriptors]); +} diff --git a/src/plugins/tool-factory-cache.ts b/src/plugins/tool-factory-cache.ts deleted file mode 100644 index e268cbea057..00000000000 --- a/src/plugins/tool-factory-cache.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { AnyAgentTool } from "../agents/tools/common.js"; -import { resolveRuntimeConfigCacheKey } from "../config/runtime-snapshot.js"; -import type { PluginLoadOptions } from "./loader.js"; -import type { OpenClawPluginToolContext, OpenClawPluginToolFactory } from "./types.js"; - -const PLUGIN_TOOL_FACTORY_CACHE_LIMIT_PER_FACTORY = 64; - -export type PluginToolFactoryResult = AnyAgentTool | AnyAgentTool[] | null | undefined; - -let pluginToolFactoryCache = new WeakMap< - OpenClawPluginToolFactory, - Map ->(); -let pluginToolFactoryCacheObjectIds = new WeakMap(); -let nextPluginToolFactoryCacheObjectId = 1; - -export function resetPluginToolFactoryCache(): void { - pluginToolFactoryCache = new WeakMap(); - pluginToolFactoryCacheObjectIds = new WeakMap(); - nextPluginToolFactoryCacheObjectId = 1; -} - -function getPluginToolFactoryCacheObjectId(value: object | null | undefined): number | null { - if (!value) { - return null; - } - const existing = pluginToolFactoryCacheObjectIds.get(value); - if (existing !== undefined) { - return existing; - } - const next = nextPluginToolFactoryCacheObjectId++; - pluginToolFactoryCacheObjectIds.set(value, next); - return next; -} - -function getPluginToolFactoryConfigCacheKey( - value: PluginLoadOptions["config"] | null | undefined, -): string | number | null { - if (!value) { - return null; - } - try { - return resolveRuntimeConfigCacheKey(value); - } catch { - return getPluginToolFactoryCacheObjectId(value); - } -} - -export function buildPluginToolFactoryCacheKey(params: { - ctx: OpenClawPluginToolContext; - currentRuntimeConfig?: PluginLoadOptions["config"] | null; -}): string { - const { ctx } = params; - return JSON.stringify({ - config: getPluginToolFactoryConfigCacheKey(ctx.config), - runtimeConfig: getPluginToolFactoryConfigCacheKey(ctx.runtimeConfig), - currentRuntimeConfig: getPluginToolFactoryConfigCacheKey(params.currentRuntimeConfig), - fsPolicy: ctx.fsPolicy ?? null, - workspaceDir: ctx.workspaceDir ?? null, - agentDir: ctx.agentDir ?? null, - agentId: ctx.agentId ?? null, - sessionKey: ctx.sessionKey ?? null, - sessionId: ctx.sessionId ?? null, - browser: ctx.browser ?? null, - messageChannel: ctx.messageChannel ?? null, - agentAccountId: ctx.agentAccountId ?? null, - deliveryContext: ctx.deliveryContext ?? null, - requesterSenderId: ctx.requesterSenderId ?? null, - senderIsOwner: ctx.senderIsOwner ?? null, - sandboxed: ctx.sandboxed ?? null, - }); -} - -export function readCachedPluginToolFactoryResult(params: { - factory: OpenClawPluginToolFactory; - cacheKey: string; -}): { hit: boolean; result: PluginToolFactoryResult } { - const cache = pluginToolFactoryCache.get(params.factory); - if (!cache || !cache.has(params.cacheKey)) { - return { hit: false, result: undefined }; - } - return { hit: true, result: cache.get(params.cacheKey) }; -} - -export function writeCachedPluginToolFactoryResult(params: { - factory: OpenClawPluginToolFactory; - cacheKey: string; - result: PluginToolFactoryResult; -}): void { - let cache = pluginToolFactoryCache.get(params.factory); - if (!cache) { - cache = new Map(); - pluginToolFactoryCache.set(params.factory, cache); - } - if (!cache.has(params.cacheKey) && cache.size >= PLUGIN_TOOL_FACTORY_CACHE_LIMIT_PER_FACTORY) { - const oldestKey = cache.keys().next().value; - if (oldestKey !== undefined) { - cache.delete(oldestKey); - } - } - cache.set(params.cacheKey, params.result); -} diff --git a/src/plugins/tools.optional.test.ts b/src/plugins/tools.optional.test.ts index 9e6d5ea112c..92dfde59b72 100644 --- a/src/plugins/tools.optional.test.ts +++ b/src/plugins/tools.optional.test.ts @@ -763,6 +763,22 @@ describe("resolvePluginTools optional tools", () => { }, ])("$name", ({ params, expectedLoaderCall }) => { setOptionalDemoRegistry(); + if (params.env) { + installToolManifestSnapshot({ + config: createContext().config, + env: params.env, + plugin: { + id: "optional-demo", + origin: "bundled", + enabledByDefault: true, + channels: [], + providers: [], + contracts: { + tools: ["optional_tool"], + }, + }, + }); + } resolvePluginTools(createResolveToolsParams(params)); @@ -869,8 +885,16 @@ describe("resolvePluginTools optional tools", () => { expect(warnSpy).not.toHaveBeenCalled(); }); - it("caches plugin tool factory results for equivalent request context", () => { - const factory = vi.fn(() => makeTool("cached_tool")); + it("caches plugin tool descriptors and uses the runtime only on execution", async () => { + const factory = vi.fn((rawCtx: unknown) => { + const ctx = rawCtx as { sessionId?: string }; + return { + ...makeTool("cached_tool"), + async execute() { + return { content: [{ type: "text", text: ctx.sessionId ?? "missing" }] }; + }, + }; + }); setRegistry([ { pluginId: "cache-test", @@ -881,16 +905,30 @@ describe("resolvePluginTools optional tools", () => { }, ]); - const first = resolvePluginTools(createResolveToolsParams({ context: createContext() })); - const second = resolvePluginTools(createResolveToolsParams({ context: createContext() })); + const first = resolvePluginTools( + createResolveToolsParams({ + context: { ...createContext(), sessionId: "same" }, + }), + ); + const second = resolvePluginTools( + createResolveToolsParams({ + context: { ...createContext(), sessionId: "same" }, + }), + ); expectResolvedToolNames(first, ["cached_tool"]); expectResolvedToolNames(second, ["cached_tool"]); expect(factory).toHaveBeenCalledTimes(1); - expect(second[0]).toBe(first[0]); + expect(second[0]).not.toBe(first[0]); + expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); + + await expect(second[0]?.execute("call", {}, undefined)).resolves.toEqual({ + content: [{ type: "text", text: "same" }], + }); + expect(factory).toHaveBeenCalledTimes(2); }); - it("does not reuse plugin tool factory results across sandbox context changes", () => { + it("does not reuse cached plugin tool descriptors across sandbox context changes", () => { const factory = vi.fn((rawCtx: unknown) => { const ctx = rawCtx as { sandboxed?: boolean }; return ctx.sandboxed ? null : makeTool("sandbox_sensitive_tool"); @@ -921,109 +959,35 @@ describe("resolvePluginTools optional tools", () => { expect(factory).toHaveBeenCalledTimes(2); }); - it("does not reuse plugin tool factory results across runtime config changes", () => { - const firstRuntimeConfig = { - ...createContext().config, - plugins: { ...createContext().config.plugins, allow: ["runtime_sensitive_tool"] }, - }; - const secondRuntimeConfig = { - ...createContext().config, - plugins: { ...createContext().config.plugins, allow: ["runtime_sensitive_next_tool"] }, - }; - const factory = vi.fn((rawCtx: unknown) => { - const ctx = rawCtx as { runtimeConfig?: { plugins?: { allow?: string[] } } }; - return makeTool(ctx.runtimeConfig?.plugins?.allow?.[0] ?? "runtime_missing_tool"); - }); + it("executes cached plugin tools registered with implicit names", async () => { + const factory = vi.fn(() => ({ + ...makeTool("implicit_tool"), + async execute() { + return { content: [{ type: "text", text: "implicit-ok" }] }; + }, + })); setRegistry([ { - pluginId: "runtime-sensitive", + pluginId: "implicit-owner", optional: false, - source: "/tmp/runtime-sensitive.js", - names: ["runtime_sensitive_tool", "runtime_sensitive_next_tool"], + source: "/tmp/implicit-owner.js", + names: [], + declaredNames: ["implicit_tool"], factory, }, ]); - const first = resolvePluginTools( - createResolveToolsParams({ - context: { ...createContext(), runtimeConfig: firstRuntimeConfig as never }, - }), - ); - const second = resolvePluginTools( - createResolveToolsParams({ - context: { ...createContext(), runtimeConfig: secondRuntimeConfig as never }, - }), - ); + const first = resolvePluginTools(createResolveToolsParams()); + const second = resolvePluginTools(createResolveToolsParams()); - expectResolvedToolNames(first, ["runtime_sensitive_tool"]); - expectResolvedToolNames(second, ["runtime_sensitive_next_tool"]); - expect(factory).toHaveBeenCalledTimes(2); - }); - - it("reuses plugin tool factory results when only runtime config getter identity changes", () => { - const runtimeConfig = { - ...createContext().config, - plugins: { ...createContext().config.plugins, allow: ["getter_sensitive_tool"] }, - }; - const factory = vi.fn((rawCtx: unknown) => { - const ctx = rawCtx as { getRuntimeConfig?: () => { plugins?: { allow?: string[] } } }; - return makeTool(ctx.getRuntimeConfig?.()?.plugins?.allow?.[0] ?? "getter_missing_tool"); - }); - setRegistry([ - { - pluginId: "getter-sensitive", - optional: false, - source: "/tmp/getter-sensitive.js", - names: ["getter_sensitive_tool"], - factory, - }, - ]); - - const context = createContext(); - const first = resolvePluginTools( - createResolveToolsParams({ - context: { ...context, getRuntimeConfig: () => runtimeConfig as never }, - }), - ); - const second = resolvePluginTools( - createResolveToolsParams({ - context: { ...context, getRuntimeConfig: () => runtimeConfig as never }, - }), - ); - - expectResolvedToolNames(first, ["getter_sensitive_tool"]); - expectResolvedToolNames(second, ["getter_sensitive_tool"]); + expectResolvedToolNames(first, ["implicit_tool"]); + expectResolvedToolNames(second, ["implicit_tool"]); expect(factory).toHaveBeenCalledTimes(1); - }); - it("reads live runtime config once per plugin tool resolution for cache keys", () => { - const runtimeConfig = createContext().config; - const getRuntimeConfig = vi.fn(() => runtimeConfig); - setRegistry([ - { - pluginId: "getter-a", - optional: false, - source: "/tmp/getter-a.js", - names: ["getter_a_tool"], - factory: () => makeTool("getter_a_tool"), - }, - { - pluginId: "getter-b", - optional: false, - source: "/tmp/getter-b.js", - names: ["getter_b_tool"], - factory: () => makeTool("getter_b_tool"), - }, - ]); - - const tools = resolvePluginTools( - createResolveToolsParams({ - context: { ...createContext(), getRuntimeConfig: getRuntimeConfig as never }, - }), - ); - - expectResolvedToolNames(tools, ["getter_a_tool", "getter_b_tool"]); - expect(getRuntimeConfig).toHaveBeenCalledTimes(1); + await expect(second[0]?.execute("call", {}, undefined)).resolves.toEqual({ + content: [{ type: "text", text: "implicit-ok" }], + }); + expect(factory).toHaveBeenCalledTimes(2); }); it("skips factory-returned tools outside the manifest tool contract", () => { diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index 97492c69844..580daddb07b 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -8,7 +8,9 @@ import { isManifestPluginAvailableForControlPlane, loadManifestContractSnapshot, } from "./manifest-contract-eligibility.js"; +import type { PluginManifestRecord } from "./manifest-registry.js"; import { hasManifestToolAvailability } from "./manifest-tool-availability.js"; +import type { PluginMetadataManifestView } from "./plugin-metadata-snapshot.types.js"; import type { PluginToolRegistration } from "./registry-types.js"; import { buildPluginRuntimeLoadOptions, @@ -17,14 +19,18 @@ import { import { ensureStandaloneRuntimePluginRegistryLoaded } from "./runtime/standalone-runtime-registry-loader.js"; import { findUndeclaredPluginToolNames } from "./tool-contracts.js"; import { - buildPluginToolFactoryCacheKey, - readCachedPluginToolFactoryResult, - type PluginToolFactoryResult, - writeCachedPluginToolFactoryResult, -} from "./tool-factory-cache.js"; + buildPluginToolDescriptorCacheKey, + capturePluginToolDescriptor, + readCachedPluginToolDescriptors, + type CachedPluginToolDescriptor, + writeCachedPluginToolDescriptors, +} from "./tool-descriptor-cache.js"; import type { OpenClawPluginToolContext } from "./types.js"; -export { resetPluginToolFactoryCache } from "./tool-factory-cache.js"; +export { + resetPluginToolDescriptorCache, + resetPluginToolDescriptorCache as resetPluginToolFactoryCache, +} from "./tool-descriptor-cache.js"; export type PluginToolMeta = { pluginId: string; @@ -43,6 +49,8 @@ type PluginToolFactoryTiming = { optional: boolean; }; +type PluginToolFactoryResult = AnyAgentTool | AnyAgentTool[] | null | undefined; + const log = createSubsystemLogger("plugins/tools"); const PLUGIN_TOOL_FACTORY_WARN_TOTAL_MS = 5_000; const PLUGIN_TOOL_FACTORY_WARN_FACTORY_MS = 1_000; @@ -170,7 +178,6 @@ function resolvePluginToolFactoryEntry(params: { entry: PluginToolRegistration; ctx: OpenClawPluginToolContext; declaredNames: string[]; - currentRuntimeConfig: PluginLoadOptions["config"] | null | undefined; factoryTimingStartedAt: number; logError: (message: string) => void; }): { @@ -181,45 +188,12 @@ function resolvePluginToolFactoryEntry(params: { let resolved: PluginToolFactoryResult = null; let failed = false; const factoryStartedAt = Date.now(); - const factoryCacheKey = buildPluginToolFactoryCacheKey({ - ctx: params.ctx, - currentRuntimeConfig: params.currentRuntimeConfig, - }); - const cached = readCachedPluginToolFactoryResult({ - factory: params.entry.factory, - cacheKey: factoryCacheKey, - }); - - if (cached.hit) { - resolved = cached.result; - return { - resolved, - failed: false, - timing: createPluginToolFactoryTiming({ - pluginId: params.entry.pluginId, - names: params.declaredNames, - durationMs: 0, - elapsedMs: toElapsedMs(Date.now() - params.factoryTimingStartedAt), - resolved, - failed: false, - optional: params.entry.optional, - }), - }; - } try { resolved = params.entry.factory(params.ctx); } catch (err) { failed = true; params.logError(`plugin tool failed (${params.entry.pluginId}): ${String(err)}`); - } finally { - if (!failed) { - writeCachedPluginToolFactoryResult({ - factory: params.entry.factory, - cacheKey: factoryCacheKey, - result: resolved, - }); - } } const factoryEndedAt = Date.now(); @@ -359,14 +333,17 @@ function resolvePluginToolRuntimePluginIds(params: { env: NodeJS.ProcessEnv; toolAllowlist?: string[]; hasAuthForProvider?: (providerId: string) => boolean; + snapshot?: PluginMetadataManifestView; }): string[] { const pluginIds = new Set(); const allowlist = normalizeAllowlist(params.toolAllowlist); - const snapshot = loadManifestContractSnapshot({ - config: params.config, - workspaceDir: params.workspaceDir, - env: params.env, - }); + const snapshot = + params.snapshot ?? + loadManifestContractSnapshot({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }); for (const plugin of snapshot.plugins) { if ( !isManifestPluginAvailableForControlPlane({ @@ -402,6 +379,225 @@ function resolvePluginToolRuntimePluginIds(params: { return [...pluginIds].toSorted((left, right) => left.localeCompare(right)); } +function readPluginCacheSource(plugin: PluginManifestRecord): string { + const source = (plugin as { source?: unknown; manifestPath?: unknown }).source; + if (typeof source === "string" && source.trim()) { + return source; + } + const manifestPath = (plugin as { manifestPath?: unknown }).manifestPath; + if (typeof manifestPath === "string" && manifestPath.trim()) { + return manifestPath; + } + return plugin.id; +} + +function buildPluginDescriptorCacheKey(params: { + plugin: PluginManifestRecord; + ctx: OpenClawPluginToolContext; + currentRuntimeConfig?: PluginLoadOptions["config"] | null; +}): string { + return buildPluginToolDescriptorCacheKey({ + pluginId: params.plugin.id, + source: readPluginCacheSource(params.plugin), + rootDir: params.plugin.rootDir, + contractToolNames: params.plugin.contracts?.tools ?? [], + ctx: params.ctx, + currentRuntimeConfig: params.currentRuntimeConfig, + }); +} + +function cachedDescriptorsCoverToolNames(params: { + descriptors: readonly CachedPluginToolDescriptor[]; + toolNames: readonly string[]; +}): boolean { + const descriptorNames = new Set( + params.descriptors.map((entry) => normalizeToolName(entry.descriptor.name)), + ); + return params.toolNames.every((name) => descriptorNames.has(normalizeToolName(name))); +} + +function createCachedDescriptorPluginTool(params: { + descriptor: CachedPluginToolDescriptor; + ctx: OpenClawPluginToolContext; + loadContext: ReturnType; + runtimeOptions: PluginLoadOptions["runtimeOptions"]; +}): AnyAgentTool { + const { descriptor } = params.descriptor; + const pluginId = descriptor.owner.kind === "plugin" ? descriptor.owner.pluginId : ""; + const toolName = descriptor.name; + const tool: AnyAgentTool = { + name: descriptor.name, + label: descriptor.title ?? descriptor.name, + description: descriptor.description, + parameters: descriptor.inputSchema as never, + async execute(toolCallId, executeParams, signal, onUpdate) { + const loadOptions = buildPluginRuntimeLoadOptions(params.loadContext, { + activate: false, + toolDiscovery: true, + onlyPluginIds: [pluginId], + ...(params.runtimeOptions ? { runtimeOptions: params.runtimeOptions } : {}), + }); + const registry = resolvePluginToolRegistry({ + loadOptions, + onlyPluginIds: [pluginId], + }); + const entry = registry?.tools.find( + (candidate) => + candidate.pluginId === pluginId && + (candidate.names.length > 0 ? candidate.names : (candidate.declaredNames ?? [])).some( + (name) => normalizeToolName(name) === normalizeToolName(toolName), + ), + ); + if (!entry) { + throw new Error(`plugin tool runtime unavailable (${pluginId}): ${toolName}`); + } + const resolved = entry.factory(params.ctx); + const listRaw: unknown[] = Array.isArray(resolved) ? resolved : resolved ? [resolved] : []; + for (const toolRaw of listRaw) { + const malformedReason = describeMalformedPluginTool(toolRaw); + if (malformedReason) { + throw new Error(`plugin tool is malformed (${pluginId}): ${malformedReason}`); + } + const runtimeTool = toolRaw as AnyAgentTool; + if (normalizeToolName(runtimeTool.name) === normalizeToolName(toolName)) { + return runtimeTool.execute(toolCallId, executeParams, signal, onUpdate); + } + } + throw new Error(`plugin tool runtime missing (${pluginId}): ${toolName}`); + }, + }; + if (params.descriptor.displaySummary) { + tool.displaySummary = params.descriptor.displaySummary; + } + if (params.descriptor.ownerOnly === true) { + tool.ownerOnly = true; + } + setPluginToolMeta(tool, { + pluginId, + optional: params.descriptor.optional, + }); + return tool; +} + +function resolveCachedPluginTools(params: { + snapshot: PluginMetadataManifestView; + config: PluginLoadOptions["config"]; + availabilityConfig: PluginLoadOptions["config"]; + env: NodeJS.ProcessEnv; + allowlist: Set; + hasAuthForProvider?: (providerId: string) => boolean; + onlyPluginIds: readonly string[]; + existing: Set; + existingNormalized: Set; + ctx: OpenClawPluginToolContext; + loadContext: ReturnType; + runtimeOptions: PluginLoadOptions["runtimeOptions"]; + currentRuntimeConfig?: PluginLoadOptions["config"] | null; +}): { tools: AnyAgentTool[]; handledPluginIds: Set } { + const tools: AnyAgentTool[] = []; + const handledPluginIds = new Set(); + const onlyPluginIdSet = new Set(params.onlyPluginIds); + for (const plugin of params.snapshot.plugins) { + if (!onlyPluginIdSet.has(plugin.id)) { + continue; + } + if ( + !isManifestPluginAvailableForControlPlane({ + snapshot: params.snapshot, + plugin, + config: params.config, + }) + ) { + continue; + } + const contractToolNames = plugin.contracts?.tools ?? []; + const availableToolNames = listManifestToolNamesForAvailability({ + toolNames: contractToolNames, + pluginId: plugin.id, + allowlist: params.allowlist, + }); + if ( + !hasManifestToolAvailability({ + plugin, + toolNames: availableToolNames, + config: params.availabilityConfig, + env: params.env, + hasAuthForProvider: params.hasAuthForProvider, + }) + ) { + continue; + } + if (params.existingNormalized.has(normalizeToolName(plugin.id))) { + continue; + } + const cached = readCachedPluginToolDescriptors( + buildPluginDescriptorCacheKey({ + plugin, + ctx: params.ctx, + currentRuntimeConfig: params.currentRuntimeConfig, + }), + ); + if ( + !cached || + !cachedDescriptorsCoverToolNames({ + descriptors: cached, + toolNames: availableToolNames, + }) + ) { + continue; + } + const pluginTools: AnyAgentTool[] = []; + let hasNameConflict = false; + const localNames = new Set(); + for (const cachedDescriptor of cached) { + if ( + !cachedDescriptor.optional && + !availableToolNames.some( + (name) => normalizeToolName(name) === normalizeToolName(cachedDescriptor.descriptor.name), + ) + ) { + continue; + } + if ( + cachedDescriptor.optional && + !isOptionalToolAllowed({ + toolName: cachedDescriptor.descriptor.name, + pluginId: plugin.id, + allowlist: params.allowlist, + }) + ) { + continue; + } + if ( + localNames.has(cachedDescriptor.descriptor.name) || + params.existing.has(cachedDescriptor.descriptor.name) + ) { + hasNameConflict = true; + break; + } + localNames.add(cachedDescriptor.descriptor.name); + pluginTools.push( + createCachedDescriptorPluginTool({ + descriptor: cachedDescriptor, + ctx: params.ctx, + loadContext: params.loadContext, + runtimeOptions: params.runtimeOptions, + }), + ); + } + if (hasNameConflict) { + continue; + } + for (const pluginTool of pluginTools) { + params.existing.add(pluginTool.name); + params.existingNormalized.add(normalizeToolName(pluginTool.name)); + tools.push(pluginTool); + } + handledPluginIds.add(plugin.id); + } + return { tools, handledPluginIds }; +} + function resolvePluginToolRegistry(params: { loadOptions: PluginLoadOptions; onlyPluginIds?: readonly string[]; @@ -424,8 +620,11 @@ function resolvePluginToolLoadState(params: { }): | { context: ReturnType; + env: NodeJS.ProcessEnv; loadOptions: PluginLoadOptions; onlyPluginIds: string[]; + runtimeOptions: PluginLoadOptions["runtimeOptions"]; + snapshot: PluginMetadataManifestView; } | undefined { const env = params.env ?? process.env; @@ -443,6 +642,11 @@ function resolvePluginToolLoadState(params: { const runtimeOptions = params.allowGatewaySubagentBinding ? { allowGatewaySubagentBinding: true as const } : undefined; + const snapshot = loadManifestContractSnapshot({ + config: context.config, + workspaceDir: context.workspaceDir, + env, + }); const onlyPluginIds = resolvePluginToolRuntimePluginIds({ config: context.config, availabilityConfig: params.context.runtimeConfig ?? context.config, @@ -450,14 +654,15 @@ function resolvePluginToolLoadState(params: { env, toolAllowlist: params.toolAllowlist, hasAuthForProvider: params.hasAuthForProvider, + snapshot, }); const loadOptions = buildPluginRuntimeLoadOptions(context, { activate: false, toolDiscovery: true, - ...(onlyPluginIds !== undefined ? { onlyPluginIds } : {}), + onlyPluginIds, runtimeOptions, }); - return { context, loadOptions, onlyPluginIds }; + return { context, env, loadOptions, onlyPluginIds, runtimeOptions, snapshot }; } export function ensureStandalonePluginToolRegistryLoaded(params: { @@ -493,32 +698,62 @@ export function resolvePluginTools(params: { if (!loadState) { return []; } - const { context, loadOptions, onlyPluginIds } = loadState; - const registry = resolvePluginToolRegistry({ - loadOptions, - onlyPluginIds, - }); - if (!registry) { - return []; - } - + const { context, env, onlyPluginIds, runtimeOptions, snapshot } = loadState; const tools: AnyAgentTool[] = []; const existing = params.existingToolNames ?? new Set(); const existingNormalized = new Set(Array.from(existing, (tool) => normalizeToolName(tool))); const allowlist = normalizeAllowlist(params.toolAllowlist); - const scopedPluginIds = new Set(onlyPluginIds); + let currentRuntimeConfigForDescriptorCache: PluginLoadOptions["config"] | null | undefined = + params.context.runtimeConfig; + if (currentRuntimeConfigForDescriptorCache === undefined && params.context.getRuntimeConfig) { + try { + currentRuntimeConfigForDescriptorCache = params.context.getRuntimeConfig(); + } catch { + currentRuntimeConfigForDescriptorCache = null; + } + } + const cached = resolveCachedPluginTools({ + snapshot, + config: context.config, + availabilityConfig: params.context.runtimeConfig ?? context.config, + env, + allowlist, + hasAuthForProvider: params.hasAuthForProvider, + onlyPluginIds, + existing, + existingNormalized, + ctx: params.context, + loadContext: context, + runtimeOptions, + currentRuntimeConfig: currentRuntimeConfigForDescriptorCache, + }); + tools.push(...cached.tools); + const runtimePluginIds = onlyPluginIds.filter( + (pluginId) => !cached.handledPluginIds.has(pluginId), + ); + if (runtimePluginIds.length === 0) { + return tools; + } + const loadOptions = buildPluginRuntimeLoadOptions(context, { + activate: false, + toolDiscovery: true, + onlyPluginIds: runtimePluginIds, + runtimeOptions, + }); + const registry = resolvePluginToolRegistry({ + loadOptions, + onlyPluginIds: runtimePluginIds, + }); + if (!registry) { + return tools; + } + + const scopedPluginIds = new Set(runtimePluginIds); const blockedPlugins = new Set(); const factoryTimingStartedAt = Date.now(); const factoryTimings: PluginToolFactoryTiming[] = []; - let currentRuntimeConfigForFactoryCache: PluginLoadOptions["config"] | null | undefined = - params.context.runtimeConfig; - if (currentRuntimeConfigForFactoryCache === undefined && params.context.getRuntimeConfig) { - try { - currentRuntimeConfigForFactoryCache = params.context.getRuntimeConfig(); - } catch { - currentRuntimeConfigForFactoryCache = null; - } - } + const capturedDescriptorsByPluginId = new Map(); + const manifestPluginsById = new Map(snapshot.plugins.map((plugin) => [plugin.id, plugin])); for (const entry of registry.tools) { if (!scopedPluginIds.has(entry.pluginId)) { @@ -557,7 +792,6 @@ export function resolvePluginTools(params: { entry, ctx: params.context, declaredNames, - currentRuntimeConfig: currentRuntimeConfigForFactoryCache, factoryTimingStartedAt, logError: (message) => context.logger.error(message), }); @@ -640,10 +874,49 @@ export function resolvePluginTools(params: { pluginId: entry.pluginId, optional: entry.optional, }); + const manifestPlugin = manifestPluginsById.get(entry.pluginId); + if (manifestPlugin) { + const capturedDescriptors = capturedDescriptorsByPluginId.get(entry.pluginId) ?? []; + capturedDescriptors.push( + capturePluginToolDescriptor({ + pluginId: entry.pluginId, + tool, + optional: entry.optional, + }), + ); + capturedDescriptorsByPluginId.set(entry.pluginId, capturedDescriptors); + } tools.push(tool); } } + for (const [pluginId, descriptors] of capturedDescriptorsByPluginId) { + const manifestPlugin = manifestPluginsById.get(pluginId); + if (!manifestPlugin) { + continue; + } + const availableToolNames = listManifestToolNamesForAvailability({ + toolNames: manifestPlugin.contracts?.tools ?? [], + pluginId, + allowlist, + }); + if ( + cachedDescriptorsCoverToolNames({ + descriptors, + toolNames: availableToolNames, + }) + ) { + writeCachedPluginToolDescriptors({ + cacheKey: buildPluginDescriptorCacheKey({ + plugin: manifestPlugin, + ctx: params.context, + currentRuntimeConfig: currentRuntimeConfigForDescriptorCache, + }), + descriptors, + }); + } + } + if (factoryTimings.length > 0) { const totalMs = factoryTimings.at(-1)?.elapsedMs ?? toElapsedMs(Date.now() - factoryTimingStartedAt);