fix(plugins): reuse scoped tool registries

This commit is contained in:
Peter Steinberger
2026-05-01 12:31:08 +01:00
parent 84c85734a8
commit f46871bc74
6 changed files with 207 additions and 4 deletions

View File

@@ -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.

View File

@@ -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", () => {

View File

@@ -347,6 +347,7 @@ type PluginRegistrySnapshot = {
};
gatewayHandlers: PluginRegistry["gatewayHandlers"];
gatewayMethodScopes: NonNullable<PluginRegistry["gatewayMethodScopes"]>;
coreGatewayMethodNames: NonNullable<PluginRegistry["coreGatewayMethodNames"]>;
};
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;
}

View File

@@ -26,6 +26,7 @@ export function createEmptyPluginRegistry(): PluginRegistry {
memoryEmbeddingProviders: [],
agentHarnesses: [],
gatewayHandlers: {},
coreGatewayMethodNames: [],
gatewayMethodScopes: {},
httpRoutes: [],
cliRegistrars: [],

View File

@@ -403,6 +403,7 @@ export type PluginRegistry = {
memoryEmbeddingProviders: PluginMemoryEmbeddingProviderRegistration[];
agentHarnesses: PluginAgentHarnessRegistration[];
gatewayHandlers: GatewayRequestHandlers;
coreGatewayMethodNames?: string[];
gatewayMethodScopes?: Partial<Record<string, OperatorScope>>;
httpRoutes: PluginHttpRouteRegistration[];
cliRegistrars: PluginCliRegistration[];

View File

@@ -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<string, HookRollbackEntry[]>();
const pluginsWithChannelRegistrationConflict = new Set<string>();