diff --git a/CHANGELOG.md b/CHANGELOG.md index 17dfb4aafa0..31c75632ad1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -162,6 +162,7 @@ Docs: https://docs.openclaw.ai - Agents/tools: skip unavailable media generation and PDF tool factories from the live reply path when Gateway metadata and the active auth store prove no configured provider can back them, while keeping explicit config and auth-backed providers on the normal factory path. Thanks @shakkernerd. - Agents/runtime: reuse the Gateway metadata startup plan when ensuring reply runtime plugins are loaded, so live agent turns do not broad-load plugin runtimes after the Gateway already scoped startup activation. Thanks @shakkernerd. - Agents/runtime: delegate scoped reply runtime registry reuse to the plugin loader cache-key compatibility checks, so config changes with the same startup plugin ids cannot keep stale runtime hooks or tools active. Thanks @shakkernerd. +- Agents/runtime: let compatible wider plugin registries satisfy scoped reply runtime requests when they already contain the requested plugins, avoiding redundant runtime loading without bypassing loader cache-key freshness checks. Thanks @shakkernerd. - Agents/runtime: validate agent model allowlists against manifest model catalog metadata during reply startup, avoiding broad provider runtime catalog loading before the agent run lane starts. Thanks @shakkernerd. - Agents/runtime: keep allowlisted configured model thinking metadata available when manifest catalog rows are absent, so explicit high-reasoning levels remain valid for custom configured models. Thanks @shakkernerd. - Agents/tools: route media and generation capability lookups through the Gateway plugin metadata snapshot during reply tool registration, avoiding repeated manifest registry reloads on the live reply path. Thanks @shakkernerd. diff --git a/src/plugins/loader.runtime-registry.test.ts b/src/plugins/loader.runtime-registry.test.ts index 0e8cc54f176..e1af883d9a3 100644 --- a/src/plugins/loader.runtime-registry.test.ts +++ b/src/plugins/loader.runtime-registry.test.ts @@ -21,6 +21,7 @@ import { registerMemoryRuntime, resolveMemoryFlushPlan, } from "./memory-state.js"; +import type { PluginRecord } from "./registry-types.js"; import { createEmptyPluginRegistry } from "./registry.js"; import { setActivePluginRegistry } from "./runtime.js"; import type { CreatePluginRuntimeOptions } from "./runtime/index.js"; @@ -29,6 +30,42 @@ afterEach(() => { resetPluginLoaderTestStateForTest(); }); +function createLoadedPluginRecord(id: string): PluginRecord { + return { + id, + name: id, + source: "test", + origin: "workspace", + enabled: true, + status: "loaded", + toolNames: [], + hookNames: [], + channelIds: [], + cliBackendIds: [], + providerIds: [], + speechProviderIds: [], + realtimeTranscriptionProviderIds: [], + realtimeVoiceProviderIds: [], + mediaUnderstandingProviderIds: [], + imageGenerationProviderIds: [], + videoGenerationProviderIds: [], + musicGenerationProviderIds: [], + webFetchProviderIds: [], + webSearchProviderIds: [], + migrationProviderIds: [], + memoryEmbeddingProviderIds: [], + agentHarnessIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + gatewayDiscoveryServiceIds: [], + commands: [], + httpRoutes: 0, + hookCount: 0, + configSchema: false, + }; +} + describe("getCompatibleActivePluginRegistry", () => { it("reuses the active registry only when the load context cache key matches", () => { const registry = createEmptyPluginRegistry(); @@ -130,6 +167,81 @@ describe("getCompatibleActivePluginRegistry", () => { ).toBe(registry); }); + it("reuses an active wider registry for compatible scoped runtime loads", () => { + const registry = createEmptyPluginRegistry(); + registry.plugins.push(createLoadedPluginRecord("demo"), createLoadedPluginRecord("other")); + const loadOptions = { + config: { + plugins: { + allow: ["demo", "other"], + load: { paths: ["/tmp/demo.js"] }, + }, + }, + workspaceDir: "/tmp/workspace-a", + runtimeOptions: { + allowGatewaySubagentBinding: true, + }, + }; + const { cacheKey } = __testing.resolvePluginLoadCacheContext(loadOptions); + setActivePluginRegistry(registry, cacheKey, "gateway-bindable"); + + expect( + __testing.getCompatibleActivePluginRegistry({ + ...loadOptions, + onlyPluginIds: ["demo"], + installBundledRuntimeDeps: false, + }), + ).toBe(registry); + }); + + it("does not reuse a wider registry for scoped loads when the load context changes", () => { + const registry = createEmptyPluginRegistry(); + registry.plugins.push(createLoadedPluginRecord("demo"), createLoadedPluginRecord("other")); + const loadOptions = { + config: { + plugins: { + allow: ["demo", "other"], + load: { paths: ["/tmp/demo.js"] }, + }, + }, + workspaceDir: "/tmp/workspace-a", + runtimeOptions: { + allowGatewaySubagentBinding: true, + }, + }; + const { cacheKey } = __testing.resolvePluginLoadCacheContext(loadOptions); + setActivePluginRegistry(registry, cacheKey, "gateway-bindable"); + + expect( + __testing.getCompatibleActivePluginRegistry({ + ...loadOptions, + workspaceDir: "/tmp/workspace-b", + onlyPluginIds: ["demo"], + installBundledRuntimeDeps: false, + }), + ).toBeUndefined(); + expect( + __testing.getCompatibleActivePluginRegistry({ + ...loadOptions, + config: { + plugins: { + allow: ["demo"], + load: { paths: ["/tmp/changed.js"] }, + }, + }, + onlyPluginIds: ["demo"], + installBundledRuntimeDeps: false, + }), + ).toBeUndefined(); + expect( + __testing.getCompatibleActivePluginRegistry({ + ...loadOptions, + onlyPluginIds: ["missing"], + installBundledRuntimeDeps: false, + }), + ).toBeUndefined(); + }); + it("does not reuse a default-mode active registry for gateway-bindable tool discovery", () => { const registry = createEmptyPluginRegistry(); const loadOptions = { diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 4b9dc843a0f..0cf33530ad6 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -804,6 +804,35 @@ function pluginToolDiscoveryOptionsMatchActiveCacheKey( ); } +function registryContainsPluginScope( + registry: PluginRegistry, + onlyPluginIds: readonly string[] | undefined, +): boolean { + if (!onlyPluginIds || onlyPluginIds.length === 0) { + return false; + } + const loadedPluginIds = new Set(registry.plugins.map((plugin) => plugin.id)); + return onlyPluginIds.every((pluginId) => loadedPluginIds.has(pluginId)); +} + +function scopedPluginLoadOptionsMatchWiderActiveCacheKey( + options: PluginLoadOptions, + expectedCacheKey: string, + activeRegistry: PluginRegistry, +): boolean { + const { onlyPluginIds } = resolvePluginLoadCacheContext(options); + if (!registryContainsPluginScope(activeRegistry, onlyPluginIds)) { + return false; + } + return pluginLoadOptionsMatchCacheKey( + { + ...options, + onlyPluginIds: undefined, + }, + expectedCacheKey, + ); +} + type PluginRegistrationPlan = { /** Public compatibility label passed to plugin register(api). */ mode: PluginRegistrationMode; @@ -1048,6 +1077,9 @@ function getCompatibleActivePluginRegistry( if (matchesActiveCacheKey(options)) { return activeRegistry; } + if (scopedPluginLoadOptionsMatchWiderActiveCacheKey(options, activeCacheKey, activeRegistry)) { + return activeRegistry; + } if (!loadContext.shouldActivate) { const activatingOptions = { ...options, @@ -1056,6 +1088,15 @@ function getCompatibleActivePluginRegistry( if (matchesActiveCacheKey(activatingOptions)) { return activeRegistry; } + if ( + scopedPluginLoadOptionsMatchWiderActiveCacheKey( + activatingOptions, + activeCacheKey, + activeRegistry, + ) + ) { + return activeRegistry; + } } if (pluginToolDiscoveryOptionsMatchActiveCacheKey(options, activeCacheKey)) { return activeRegistry; @@ -1074,6 +1115,15 @@ function getCompatibleActivePluginRegistry( if (matchesActiveCacheKey(gatewayBindableOptions)) { return activeRegistry; } + if ( + scopedPluginLoadOptionsMatchWiderActiveCacheKey( + gatewayBindableOptions, + activeCacheKey, + activeRegistry, + ) + ) { + return activeRegistry; + } if (pluginToolDiscoveryOptionsMatchActiveCacheKey(gatewayBindableOptions, activeCacheKey)) { return activeRegistry; } @@ -1089,6 +1139,15 @@ function getCompatibleActivePluginRegistry( if (matchesActiveCacheKey(activatingGatewayBindableOptions)) { return activeRegistry; } + if ( + scopedPluginLoadOptionsMatchWiderActiveCacheKey( + activatingGatewayBindableOptions, + activeCacheKey, + activeRegistry, + ) + ) { + return activeRegistry; + } } } return undefined;