diff --git a/CHANGELOG.md b/CHANGELOG.md index 99bdc5ad822..b663cd7d03b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai - Plugins/startup: carry the Gateway `PluginLookUpTable` into deferred channel full-runtime reloads so post-listen startup does not rebuild manifest metadata after the provisional setup-runtime load. Thanks @shakkernerd. - Gateway/startup: extend `OPENCLAW_GATEWAY_STARTUP_TRACE=1` with per-phase event-loop delay plus plugin lookup-table timing and count metrics for installed-index, manifest, startup-plan, and owner-map work, and include the new timing fields in startup benchmark summaries. Thanks @shakkernerd. - Plugins/channels: resolve read-only channel command defaults from one plugin index plus manifest pass instead of reloading plugin metadata while checking candidate plugin enablement. Thanks @shakkernerd. +- Plugins/capabilities: cache manifest-derived capability provider plugin IDs per config snapshot so repeated TTS, media, realtime, memory, image, video, and music provider resolution avoids redundant manifest scans. Thanks @shakkernerd. - Plugins/contracts: resolve runtime manifest-contract plugin owners from one plugin index plus manifest pass instead of rebuilding manifest metadata separately for all owners and enabled owners. Thanks @shakkernerd. - Plugins/extractors: reuse one manifest registry pass while resolving bundled document and web-content extractor plugins instead of rereading manifests for compatibility and enablement filtering. Thanks @shakkernerd. - Plugins/registry: resolve lookup-table owner maps for providers, CLI backends, setup providers, command aliases, model catalogs, channel configs, and manifest contracts while preserving setup-only CLI backend ownership. Thanks @shakkernerd. diff --git a/src/plugins/capability-provider-runtime.test.ts b/src/plugins/capability-provider-runtime.test.ts index 0e2dac21b88..b7adac097eb 100644 --- a/src/plugins/capability-provider-runtime.test.ts +++ b/src/plugins/capability-provider-runtime.test.ts @@ -502,6 +502,27 @@ describe("resolvePluginCapabilityProviders", () => { }); }); + it("reuses manifest-derived capability plugin ids for the same config snapshot", () => { + const { cfg, enablementCompat } = createCompatChainConfig(); + setBundledCapabilityFixture("mediaUnderstandingProviders"); + mocks.withBundledPluginEnablementCompat.mockReturnValue(enablementCompat); + mocks.withBundledPluginVitestCompat.mockReturnValue(enablementCompat); + + expectNoResolvedCapabilityProviders( + resolvePluginCapabilityProviders({ key: "mediaUnderstandingProviders", cfg }), + ); + expectNoResolvedCapabilityProviders( + resolvePluginCapabilityProviders({ key: "mediaUnderstandingProviders", cfg }), + ); + + expect(mocks.loadPluginManifestRegistry).toHaveBeenCalledOnce(); + expect(mocks.withBundledPluginAllowlistCompat).toHaveBeenCalledTimes(2); + expect(mocks.withBundledPluginAllowlistCompat).toHaveBeenCalledWith({ + config: cfg, + pluginIds: ["openai"], + }); + }); + it("reuses a compatible active registry even when the capability list is empty", () => { const active = createEmptyPluginRegistry(); mocks.resolveRuntimePluginRegistry.mockReturnValue(active); diff --git a/src/plugins/capability-provider-runtime.ts b/src/plugins/capability-provider-runtime.ts index 364cf444e0d..e0c44dd5174 100644 --- a/src/plugins/capability-provider-runtime.ts +++ b/src/plugins/capability-provider-runtime.ts @@ -4,6 +4,11 @@ import { withBundledPluginEnablementCompat, withBundledPluginVitestCompat, } from "./bundled-compat.js"; +import { + buildPluginSnapshotCacheEnvKey, + resolvePluginSnapshotCacheTtlMs, + shouldUsePluginSnapshotCache, +} from "./cache-controls.js"; import { hasExplicitPluginConfig } from "./config-policy.js"; import { resolveRuntimePluginRegistry } from "./loader.js"; import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry.js"; @@ -32,6 +37,11 @@ type CapabilityContractKey = type CapabilityProviderForKey = PluginRegistry[K][number] extends { provider: infer T } ? T : never; +type CapabilityProviderPluginIdCacheEntry = { + expiresAt: number; + pluginIds: string[]; +}; + const CAPABILITY_CONTRACT_KEY: Record = { memoryEmbeddingProviders: "memoryEmbeddingProviders", speechProviders: "speechProviders", @@ -43,15 +53,86 @@ const CAPABILITY_CONTRACT_KEY: Record> +>(); + +function buildCapabilityProviderPluginIdCacheKey(params: { + key: CapabilityProviderRegistryKey; + env: NodeJS.ProcessEnv; + providerId?: string; +}): string { + return JSON.stringify({ + key: params.key, + providerId: params.providerId ?? "", + env: buildPluginSnapshotCacheEnvKey(params.env), + }); +} + +function getCachedCapabilityProviderPluginIds(params: { + key: CapabilityProviderRegistryKey; + cfg?: OpenClawConfig; + env: NodeJS.ProcessEnv; + providerId?: string; +}): string[] | undefined { + if (!params.cfg || !shouldUsePluginSnapshotCache(params.env)) { + return undefined; + } + const envCache = capabilityProviderPluginIdCache.get(params.cfg)?.get(params.env); + const cached = envCache?.get(buildCapabilityProviderPluginIdCacheKey(params)); + if (!cached || cached.expiresAt <= Date.now()) { + return undefined; + } + return [...cached.pluginIds]; +} + +function memoizeCapabilityProviderPluginIds(params: { + key: CapabilityProviderRegistryKey; + cfg?: OpenClawConfig; + env: NodeJS.ProcessEnv; + providerId?: string; + pluginIds: string[]; +}): void { + if (!params.cfg || !shouldUsePluginSnapshotCache(params.env)) { + return; + } + let configCache = capabilityProviderPluginIdCache.get(params.cfg); + if (!configCache) { + configCache = new WeakMap< + NodeJS.ProcessEnv, + Map + >(); + capabilityProviderPluginIdCache.set(params.cfg, configCache); + } + let envCache = configCache.get(params.env); + if (!envCache) { + envCache = new Map(); + configCache.set(params.env, envCache); + } + envCache.set(buildCapabilityProviderPluginIdCacheKey(params), { + expiresAt: Date.now() + resolvePluginSnapshotCacheTtlMs(params.env), + pluginIds: [...params.pluginIds], + }); +} + function resolveBundledCapabilityCompatPluginIds(params: { key: CapabilityProviderRegistryKey; cfg?: OpenClawConfig; providerId?: string; }): string[] { + const env = process.env; + const cached = getCachedCapabilityProviderPluginIds({ + ...params, + env, + }); + if (cached) { + return cached; + } const contractKey = CAPABILITY_CONTRACT_KEY[params.key]; - return loadPluginManifestRegistryForPluginRegistry({ + const pluginIds = loadPluginManifestRegistryForPluginRegistry({ config: params.cfg, - env: process.env, + env, includeDisabled: true, }) .plugins.filter( @@ -62,6 +143,12 @@ function resolveBundledCapabilityCompatPluginIds(params: { ) .map((plugin) => plugin.id) .toSorted((left, right) => left.localeCompare(right)); + memoizeCapabilityProviderPluginIds({ + ...params, + env, + pluginIds, + }); + return pluginIds; } function resolveCapabilityProviderConfig(params: {