From b779c45a78147337fda65dc400519ded7190c8b1 Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Sat, 2 May 2026 15:25:00 -0500 Subject: [PATCH] fix: memoize plugin descriptor config keys (#76240) --- CHANGELOG.md | 1 + src/plugins/tool-descriptor-cache.test.ts | 93 +++++++++++++++++++++++ src/plugins/tool-descriptor-cache.ts | 34 +++++++-- src/plugins/tools.ts | 9 +++ 4 files changed, 130 insertions(+), 7 deletions(-) create mode 100644 src/plugins/tool-descriptor-cache.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bcd871cc89..1af27bdd6dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai - Gateway/CLI: make `openclaw gateway start` repair stale managed service definitions that point at old OpenClaw versions, missing binaries, or temporary installer paths before starting. - Heartbeat/scheduler: make heartbeat phase scheduling active-hours-aware so the scheduler seeks forward to the first in-window phase slot instead of arming timers for quiet-hours slots and relying solely on the runtime guard. Non-UTC `activeHours.timezone` values (e.g. `Asia/Shanghai`) now correctly influence when the next heartbeat timer fires, avoiding wasted quiet-hours ticks and long dormant gaps after gateway restarts. Fixes #75487. Thanks @amknight. - Status: show the `openai-codex` OAuth profile for `openai/gpt-*` sessions running through the native Codex runtime instead of reporting auth as unknown. (#76197) Thanks @mbelinky. +- Gateway: avoid repeated plugin tool descriptor config hashing so large runtime configs do not block reply startup and trigger reconnect/timeouts. (#75944) Thanks @joshavant. - Plugins/externalization: keep diagnostics ClawHub packages and persisted bundled-plugin relocation on npm-first install metadata for launch, and omit Discord from the core package now that its external package is published. Thanks @vincentkoc. - Plugins/Codex: allow the official npm Codex plugin to install without the unsafe-install override, keep `/codex` command ownership, and cover the real npm Docker live path through managed `.openclaw/npm` dependencies plus uninstall failure proof. - Gateway/status: add concrete service, config, listener-owner, and log collection next steps when gateway probes fail and Bonjour finds no local gateway, so frozen or port-conflict reports include the data needed for root-cause triage. Refs #49012. Thanks @vincentkoc. diff --git a/src/plugins/tool-descriptor-cache.test.ts b/src/plugins/tool-descriptor-cache.test.ts new file mode 100644 index 00000000000..f9e5128a405 --- /dev/null +++ b/src/plugins/tool-descriptor-cache.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const hoisted = vi.hoisted(() => ({ + resolveRuntimeConfigCacheKey: vi.fn((value: unknown) => { + const id = + value && typeof value === "object" && "id" in value + ? String((value as { id?: unknown }).id) + : "config"; + return `config:${id}`; + }), +})); + +vi.mock("../config/runtime-snapshot.js", () => ({ + resolveRuntimeConfigCacheKey: hoisted.resolveRuntimeConfigCacheKey, +})); + +import { + buildPluginToolDescriptorCacheKey, + createPluginToolDescriptorConfigCacheKeyMemo, + resetPluginToolDescriptorCache, +} from "./tool-descriptor-cache.js"; + +describe("plugin tool descriptor cache keys", () => { + afterEach(() => { + hoisted.resolveRuntimeConfigCacheKey.mockClear(); + resetPluginToolDescriptorCache(); + }); + + it("memoizes config cache keys across plugin descriptor keys in one resolution pass", () => { + const config = { + id: "runtime", + plugins: { + entries: { + demo: { enabled: true }, + }, + }, + } as never; + const configCacheKeyMemo = createPluginToolDescriptorConfigCacheKeyMemo(); + + for (let index = 0; index < 25; index += 1) { + buildPluginToolDescriptorCacheKey({ + pluginId: `plugin-${index}`, + source: `/tmp/plugin-${index}.js`, + contractToolNames: [`tool_${index}`], + ctx: { + config, + runtimeConfig: config, + workspaceDir: "/tmp/workspace", + agentDir: "/tmp/agent", + agentId: "main", + sessionKey: "agent:main", + sessionId: "session", + }, + currentRuntimeConfig: config, + configCacheKeyMemo, + }); + } + + expect(hoisted.resolveRuntimeConfigCacheKey).toHaveBeenCalledTimes(1); + }); + + it("keeps distinct config objects distinct within the memo", () => { + const firstConfig = { id: "first" } as never; + const secondConfig = { id: "second" } as never; + const configCacheKeyMemo = createPluginToolDescriptorConfigCacheKeyMemo(); + + const firstKey = buildPluginToolDescriptorCacheKey({ + pluginId: "demo", + source: "/tmp/demo.js", + contractToolNames: ["demo"], + ctx: { + config: firstConfig, + runtimeConfig: firstConfig, + }, + currentRuntimeConfig: firstConfig, + configCacheKeyMemo, + }); + const secondKey = buildPluginToolDescriptorCacheKey({ + pluginId: "demo", + source: "/tmp/demo.js", + contractToolNames: ["demo"], + ctx: { + config: secondConfig, + runtimeConfig: secondConfig, + }, + currentRuntimeConfig: secondConfig, + configCacheKeyMemo, + }); + + expect(hoisted.resolveRuntimeConfigCacheKey).toHaveBeenCalledTimes(2); + expect(firstKey).not.toBe(secondKey); + }); +}); diff --git a/src/plugins/tool-descriptor-cache.ts b/src/plugins/tool-descriptor-cache.ts index 9ea47b6520f..a219d5e56ec 100644 --- a/src/plugins/tool-descriptor-cache.ts +++ b/src/plugins/tool-descriptor-cache.ts @@ -19,6 +19,12 @@ const descriptorCache = new Map(); let descriptorCacheObjectIds = new WeakMap(); let nextDescriptorCacheObjectId = 1; +export type PluginToolDescriptorConfigCacheKeyMemo = WeakMap; + +export function createPluginToolDescriptorConfigCacheKeyMemo(): PluginToolDescriptorConfigCacheKeyMemo { + return new WeakMap(); +} + export function resetPluginToolDescriptorCache(): void { descriptorCache.clear(); descriptorCacheObjectIds = new WeakMap(); @@ -49,26 +55,38 @@ function getDescriptorCacheObjectId(value: object | null | undefined): number | function getDescriptorConfigCacheKey( value: PluginLoadOptions["config"] | null | undefined, + memo?: PluginToolDescriptorConfigCacheKeyMemo, ): string | number | null { if (!value) { return null; } - try { - return resolveRuntimeConfigCacheKey(value); - } catch { - return getDescriptorCacheObjectId(value); + const cached = memo?.get(value); + if (cached !== undefined) { + return cached; } + let resolved: string | number | null; + try { + resolved = resolveRuntimeConfigCacheKey(value); + } catch { + resolved = getDescriptorCacheObjectId(value); + } + memo?.set(value, resolved); + return resolved; } function buildDescriptorContextCacheKey(params: { ctx: OpenClawPluginToolContext; currentRuntimeConfig?: PluginLoadOptions["config"] | null; + configCacheKeyMemo?: PluginToolDescriptorConfigCacheKeyMemo; }): string { const { ctx } = params; return JSON.stringify({ - config: getDescriptorConfigCacheKey(ctx.config), - runtimeConfig: getDescriptorConfigCacheKey(ctx.runtimeConfig), - currentRuntimeConfig: getDescriptorConfigCacheKey(params.currentRuntimeConfig), + config: getDescriptorConfigCacheKey(ctx.config, params.configCacheKeyMemo), + runtimeConfig: getDescriptorConfigCacheKey(ctx.runtimeConfig, params.configCacheKeyMemo), + currentRuntimeConfig: getDescriptorConfigCacheKey( + params.currentRuntimeConfig, + params.configCacheKeyMemo, + ), fsPolicy: ctx.fsPolicy ?? null, workspaceDir: ctx.workspaceDir ?? null, agentDir: ctx.agentDir ?? null, @@ -92,6 +110,7 @@ export function buildPluginToolDescriptorCacheKey(params: { contractToolNames: readonly string[]; ctx: OpenClawPluginToolContext; currentRuntimeConfig?: PluginLoadOptions["config"] | null; + configCacheKeyMemo?: PluginToolDescriptorConfigCacheKeyMemo; }): string { return JSON.stringify({ version: PLUGIN_TOOL_DESCRIPTOR_CACHE_VERSION, @@ -103,6 +122,7 @@ export function buildPluginToolDescriptorCacheKey(params: { context: buildDescriptorContextCacheKey({ ctx: params.ctx, currentRuntimeConfig: params.currentRuntimeConfig, + configCacheKeyMemo: params.configCacheKeyMemo, }), }); } diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index bcad128496c..a04c33f3b6a 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -21,8 +21,10 @@ import { findUndeclaredPluginToolNames } from "./tool-contracts.js"; import { buildPluginToolDescriptorCacheKey, capturePluginToolDescriptor, + createPluginToolDescriptorConfigCacheKeyMemo, readCachedPluginToolDescriptors, type CachedPluginToolDescriptor, + type PluginToolDescriptorConfigCacheKeyMemo, writeCachedPluginToolDescriptors, } from "./tool-descriptor-cache.js"; import type { OpenClawPluginToolContext } from "./types.js"; @@ -395,6 +397,7 @@ function buildPluginDescriptorCacheKey(params: { plugin: PluginManifestRecord; ctx: OpenClawPluginToolContext; currentRuntimeConfig?: PluginLoadOptions["config"] | null; + configCacheKeyMemo?: PluginToolDescriptorConfigCacheKeyMemo; }): string { return buildPluginToolDescriptorCacheKey({ pluginId: params.plugin.id, @@ -403,6 +406,7 @@ function buildPluginDescriptorCacheKey(params: { contractToolNames: params.plugin.contracts?.tools ?? [], ctx: params.ctx, currentRuntimeConfig: params.currentRuntimeConfig, + configCacheKeyMemo: params.configCacheKeyMemo, }); } @@ -493,6 +497,7 @@ function resolveCachedPluginTools(params: { loadContext: ReturnType; runtimeOptions: PluginLoadOptions["runtimeOptions"]; currentRuntimeConfig?: PluginLoadOptions["config"] | null; + configCacheKeyMemo: PluginToolDescriptorConfigCacheKeyMemo; }): { tools: AnyAgentTool[]; handledPluginIds: Set } { const tools: AnyAgentTool[] = []; const handledPluginIds = new Set(); @@ -535,6 +540,7 @@ function resolveCachedPluginTools(params: { plugin, ctx: params.ctx, currentRuntimeConfig: params.currentRuntimeConfig, + configCacheKeyMemo: params.configCacheKeyMemo, }), ); if ( @@ -714,6 +720,7 @@ export function resolvePluginTools(params: { const existing = params.existingToolNames ?? new Set(); const existingNormalized = new Set(Array.from(existing, (tool) => normalizeToolName(tool))); const allowlist = normalizeAllowlist(params.toolAllowlist); + const configCacheKeyMemo = createPluginToolDescriptorConfigCacheKeyMemo(); let currentRuntimeConfigForDescriptorCache: PluginLoadOptions["config"] | null | undefined = params.context.runtimeConfig; if (currentRuntimeConfigForDescriptorCache === undefined && params.context.getRuntimeConfig) { @@ -737,6 +744,7 @@ export function resolvePluginTools(params: { loadContext: context, runtimeOptions, currentRuntimeConfig: currentRuntimeConfigForDescriptorCache, + configCacheKeyMemo, }); tools.push(...cached.tools); const runtimePluginIds = onlyPluginIds.filter( @@ -922,6 +930,7 @@ export function resolvePluginTools(params: { plugin: manifestPlugin, ctx: params.context, currentRuntimeConfig: currentRuntimeConfigForDescriptorCache, + configCacheKeyMemo, }), descriptors, });