From f46871bc74ea3615c72f8baf607d454a03ca03c1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 1 May 2026 12:31:08 +0100 Subject: [PATCH] fix(plugins): reuse scoped tool registries --- CHANGELOG.md | 1 + src/plugins/loader.runtime-registry.test.ts | 148 ++++++++++++++++++++ src/plugins/loader.ts | 48 +++++++ src/plugins/registry-empty.ts | 1 + src/plugins/registry-types.ts | 1 + src/plugins/registry.ts | 12 +- 6 files changed, 207 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54c8fbcc69a..798f767d517 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ Docs: https://docs.openclaw.ai - Plugins/TTS: keep bundled speech-provider discovery available on cold package Gateway paths and add bundled plugin matrix runtime probes for health, readiness, RPC, TTS discovery, and post-ready runtime-deps watchdog coverage. Refs #75283. Thanks @vincentkoc. - Google Meet/Twilio: show delegated voice call ID, DTMF, and intro-greeting state in `googlemeet doctor`, and avoid claiming DTMF was sent when no Meet PIN sequence was configured. Refs #72478. Thanks @DougButdorf. - Plugins/tools: prefer built bundled plugin code during tool discovery and skip channel runtime hydration while preserving companion provider registrations, reducing per-run plugin-tool prep cost without dropping executable plugin tools. Fixes #75290. Thanks @thanos-openclaw. +- Plugins/loader: scope plugin-tool registry reuse to the enabled plugin plan and stored Gateway method keys, so embedded runner tool lookup can reuse compatible startup registries without hiding enabled non-startup plugin tools. Fixes #75520. Thanks @whtoo. - Voice Call/Twilio: send notify-mode initial TwiML directly in the outbound create-call request while keeping conversation and pre-connect DTMF calls webhook-driven, so one-shot notify calls do not depend on a first-answer webhook fetch. Supersedes #72758. Thanks @tyshepps. - Discord/Slack: defer status-reaction cleanup until run finalization so queued, thinking, tool, and terminal reactions no longer flicker during normal progress updates. (#75582) - Discord/voice: leave Discord voice off for text-only configs unless `channels.discord.voice` is explicitly configured, avoiding default `GuildVoiceStates` traffic and idle gateway CPU pressure for bots that do not use `/vc`. Fixes #73753; refs #74044. Thanks @sanchezm86 and @SecureCloudProjO. diff --git a/src/plugins/loader.runtime-registry.test.ts b/src/plugins/loader.runtime-registry.test.ts index 8b44a825e84..61da005ea10 100644 --- a/src/plugins/loader.runtime-registry.test.ts +++ b/src/plugins/loader.runtime-registry.test.ts @@ -164,6 +164,154 @@ describe("getCompatibleActivePluginRegistry", () => { }), ).toBeUndefined(); }); + + it("reuses a scoped gateway-bindable registry for an unscoped default-mode request", () => { + const registry = createEmptyPluginRegistry(); + registry.plugins.push( + { id: "acpx" } as (typeof registry.plugins)[number], + { id: "telegram" } as (typeof registry.plugins)[number], + ); + const startupOptions = { + config: { + plugins: { + allow: ["acpx", "telegram"], + }, + }, + workspaceDir: "/tmp/workspace-a", + onlyPluginIds: ["acpx", "telegram"], + runtimeOptions: { + allowGatewaySubagentBinding: true, + }, + }; + const { cacheKey } = __testing.resolvePluginLoadCacheContext(startupOptions); + setActivePluginRegistry(registry, cacheKey, "gateway-bindable"); + + expect( + __testing.getCompatibleActivePluginRegistry({ + config: startupOptions.config, + workspaceDir: "/tmp/workspace-a", + }), + ).toBe(registry); + }); + + it("reuses a scoped gateway-bindable registry for an unscoped snapshot-mode request", () => { + const registry = createEmptyPluginRegistry(); + registry.plugins.push( + { id: "acpx" } as (typeof registry.plugins)[number], + { id: "telegram" } as (typeof registry.plugins)[number], + ); + const startupOptions = { + config: { + plugins: { + allow: ["acpx", "telegram"], + }, + }, + workspaceDir: "/tmp/workspace-a", + onlyPluginIds: ["acpx", "telegram"], + runtimeOptions: { + allowGatewaySubagentBinding: true, + }, + }; + const { cacheKey } = __testing.resolvePluginLoadCacheContext(startupOptions); + setActivePluginRegistry(registry, cacheKey, "gateway-bindable"); + + expect( + __testing.getCompatibleActivePluginRegistry({ + config: startupOptions.config, + workspaceDir: "/tmp/workspace-a", + activate: false, + }), + ).toBe(registry); + }); + + it("does not reuse a scoped registry when plugin IDs differ", () => { + const registry = createEmptyPluginRegistry(); + registry.plugins.push({ id: "acpx" } as (typeof registry.plugins)[number]); + const startupOptions = { + config: { + plugins: { + allow: ["acpx", "telegram"], + }, + }, + workspaceDir: "/tmp/workspace-a", + onlyPluginIds: ["acpx", "telegram"], + runtimeOptions: { + allowGatewaySubagentBinding: true, + }, + }; + const { cacheKey } = __testing.resolvePluginLoadCacheContext(startupOptions); + setActivePluginRegistry(registry, cacheKey, "gateway-bindable"); + + expect( + __testing.getCompatibleActivePluginRegistry({ + config: startupOptions.config, + workspaceDir: "/tmp/workspace-a", + }), + ).toBeUndefined(); + }); + + it("does not reuse a scoped gateway-bindable registry for an explicit subagent request", () => { + const registry = createEmptyPluginRegistry(); + registry.plugins.push( + { id: "acpx" } as (typeof registry.plugins)[number], + { id: "telegram" } as (typeof registry.plugins)[number], + ); + const startupOptions = { + config: { + plugins: { + allow: ["acpx", "telegram"], + }, + }, + workspaceDir: "/tmp/workspace-a", + onlyPluginIds: ["acpx", "telegram"], + runtimeOptions: { + allowGatewaySubagentBinding: true, + }, + }; + const { cacheKey } = __testing.resolvePluginLoadCacheContext(startupOptions); + setActivePluginRegistry(registry, cacheKey, "gateway-bindable"); + + expect( + __testing.getCompatibleActivePluginRegistry({ + config: startupOptions.config, + workspaceDir: "/tmp/workspace-a", + runtimeOptions: { + subagent: {} as CreatePluginRuntimeOptions["subagent"], + }, + }), + ).toBeUndefined(); + }); + + it("reuses a scoped startup registry when only the request omits gateway methods", () => { + const registry = createEmptyPluginRegistry(); + registry.plugins.push( + { id: "acpx" } as (typeof registry.plugins)[number], + { id: "telegram" } as (typeof registry.plugins)[number], + ); + registry.coreGatewayMethodNames = ["sessions.get", "sessions.list"]; + const startupOptions = { + config: { + plugins: { + allow: ["acpx", "telegram"], + }, + }, + workspaceDir: "/tmp/workspace-a", + onlyPluginIds: ["acpx", "telegram"], + coreGatewayMethodNames: ["sessions.get", "sessions.list"], + runtimeOptions: { + allowGatewaySubagentBinding: true, + }, + }; + const { cacheKey } = __testing.resolvePluginLoadCacheContext(startupOptions); + setActivePluginRegistry(registry, cacheKey, "gateway-bindable"); + + expect( + __testing.getCompatibleActivePluginRegistry({ + config: startupOptions.config, + workspaceDir: "/tmp/workspace-a", + }), + ).toBe(registry); + }); }); describe("resolveRuntimePluginRegistry", () => { diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 1c9e3625203..03f09ac9887 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -347,6 +347,7 @@ type PluginRegistrySnapshot = { }; gatewayHandlers: PluginRegistry["gatewayHandlers"]; gatewayMethodScopes: NonNullable; + coreGatewayMethodNames: NonNullable; }; function snapshotPluginRegistry(registry: PluginRegistry): PluginRegistrySnapshot { @@ -387,6 +388,7 @@ function snapshotPluginRegistry(registry: PluginRegistry): PluginRegistrySnapsho }, gatewayHandlers: { ...registry.gatewayHandlers }, gatewayMethodScopes: { ...registry.gatewayMethodScopes }, + coreGatewayMethodNames: [...(registry.coreGatewayMethodNames ?? [])], }; } @@ -426,6 +428,7 @@ function restorePluginRegistry(registry: PluginRegistry, snapshot: PluginRegistr registry.diagnostics = snapshot.arrays.diagnostics; registry.gatewayHandlers = snapshot.gatewayHandlers; registry.gatewayMethodScopes = snapshot.gatewayMethodScopes; + registry.coreGatewayMethodNames = snapshot.coreGatewayMethodNames; } function createGuardedPluginRegistrationApi(api: OpenClawPluginApi): { @@ -1039,6 +1042,51 @@ function getCompatibleActivePluginRegistry( } } } + if (loadContext.onlyPluginIds === undefined) { + const scopedOptions = { + ...options, + onlyPluginIds: activeRegistry.plugins.map((entry) => entry.id).toSorted(), + coreGatewayMethodNames: activeRegistry.coreGatewayMethodNames ?? [], + }; + if (pluginLoadOptionsMatchCacheKey(scopedOptions, activeCacheKey)) { + return activeRegistry; + } + if (!loadContext.shouldActivate) { + const activatingScopedOptions = { + ...scopedOptions, + activate: true, + }; + if (pluginLoadOptionsMatchCacheKey(activatingScopedOptions, activeCacheKey)) { + return activeRegistry; + } + } + if ( + loadContext.runtimeSubagentMode === "default" && + getActivePluginRuntimeSubagentMode() === "gateway-bindable" + ) { + const gatewayBindableScopedOptions = { + ...scopedOptions, + runtimeOptions: { + ...options.runtimeOptions, + allowGatewaySubagentBinding: true, + }, + }; + if (pluginLoadOptionsMatchCacheKey(gatewayBindableScopedOptions, activeCacheKey)) { + return activeRegistry; + } + if (!loadContext.shouldActivate) { + const activatingGatewayBindableScopedOptions = { + ...gatewayBindableScopedOptions, + activate: true, + }; + if ( + pluginLoadOptionsMatchCacheKey(activatingGatewayBindableScopedOptions, activeCacheKey) + ) { + return activeRegistry; + } + } + } + } return undefined; } diff --git a/src/plugins/registry-empty.ts b/src/plugins/registry-empty.ts index 348c8d5eec8..3ce15e2ecc7 100644 --- a/src/plugins/registry-empty.ts +++ b/src/plugins/registry-empty.ts @@ -26,6 +26,7 @@ export function createEmptyPluginRegistry(): PluginRegistry { memoryEmbeddingProviders: [], agentHarnesses: [], gatewayHandlers: {}, + coreGatewayMethodNames: [], gatewayMethodScopes: {}, httpRoutes: [], cliRegistrars: [], diff --git a/src/plugins/registry-types.ts b/src/plugins/registry-types.ts index 16a63f5e264..3e6c8895815 100644 --- a/src/plugins/registry-types.ts +++ b/src/plugins/registry-types.ts @@ -403,6 +403,7 @@ export type PluginRegistry = { memoryEmbeddingProviders: PluginMemoryEmbeddingProviderRegistration[]; agentHarnesses: PluginAgentHarnessRegistration[]; gatewayHandlers: GatewayRequestHandlers; + coreGatewayMethodNames?: string[]; gatewayMethodScopes?: Partial>; httpRoutes: PluginHttpRouteRegistration[]; cliRegistrars: PluginCliRegistration[]; diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index aef8bf069c0..df748ccb8d0 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -277,10 +277,14 @@ function resolvePluginRegistrationCapabilities( export function createPluginRegistry(registryParams: PluginRegistryParams) { const registry = createEmptyPluginRegistry(); - const coreGatewayMethods = new Set([ - ...(registryParams.coreGatewayMethodNames ?? []), - ...Object.keys(registryParams.coreGatewayHandlers ?? {}), - ]); + const coreGatewayMethodNames = Array.from( + new Set([ + ...(registryParams.coreGatewayMethodNames ?? []), + ...Object.keys(registryParams.coreGatewayHandlers ?? {}), + ]), + ).toSorted(); + registry.coreGatewayMethodNames = coreGatewayMethodNames; + const coreGatewayMethods = new Set(coreGatewayMethodNames); const pluginHookRollback = new Map(); const pluginsWithChannelRegistrationConflict = new Set();