diff --git a/src/plugins/loader.runtime-registry.test.ts b/src/plugins/loader.runtime-registry.test.ts index 61da005ea10..85fb4e18364 100644 --- a/src/plugins/loader.runtime-registry.test.ts +++ b/src/plugins/loader.runtime-registry.test.ts @@ -165,7 +165,7 @@ describe("getCompatibleActivePluginRegistry", () => { ).toBeUndefined(); }); - it("reuses a scoped gateway-bindable registry for an unscoped default-mode request", () => { + it("reuses a scoped gateway-bindable registry for a matching default-mode tool scope", () => { const registry = createEmptyPluginRegistry(); registry.plugins.push( { id: "acpx" } as (typeof registry.plugins)[number], @@ -190,11 +190,12 @@ describe("getCompatibleActivePluginRegistry", () => { __testing.getCompatibleActivePluginRegistry({ config: startupOptions.config, workspaceDir: "/tmp/workspace-a", + onlyPluginIds: ["acpx", "telegram"], }), ).toBe(registry); }); - it("reuses a scoped gateway-bindable registry for an unscoped snapshot-mode request", () => { + it("reuses a scoped gateway-bindable registry for a matching snapshot-mode tool scope", () => { const registry = createEmptyPluginRegistry(); registry.plugins.push( { id: "acpx" } as (typeof registry.plugins)[number], @@ -219,18 +220,52 @@ describe("getCompatibleActivePluginRegistry", () => { __testing.getCompatibleActivePluginRegistry({ config: startupOptions.config, workspaceDir: "/tmp/workspace-a", + onlyPluginIds: ["acpx", "telegram"], activate: false, }), ).toBe(registry); }); - it("does not reuse a scoped registry when plugin IDs differ", () => { + it("does not reuse a scoped registry when the requested tool scope needs another plugin", () => { const registry = createEmptyPluginRegistry(); - registry.plugins.push({ id: "acpx" } as (typeof registry.plugins)[number]); + registry.plugins.push( + { id: "acpx" } as (typeof registry.plugins)[number], + { id: "telegram" } as (typeof registry.plugins)[number], + ); const startupOptions = { config: { plugins: { - allow: ["acpx", "telegram"], + allow: ["acpx", "telegram", "tavily"], + }, + }, + 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", + onlyPluginIds: ["acpx", "telegram", "tavily"], + }), + ).toBeUndefined(); + }); + + it("does not treat an unscoped request as compatible with the scoped startup registry", () => { + 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", "tavily"], }, }, workspaceDir: "/tmp/workspace-a", @@ -309,6 +344,7 @@ describe("getCompatibleActivePluginRegistry", () => { __testing.getCompatibleActivePluginRegistry({ config: startupOptions.config, workspaceDir: "/tmp/workspace-a", + onlyPluginIds: ["acpx", "telegram"], }), ).toBe(registry); }); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 03f09ac9887..5349dc673f6 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -1002,7 +1002,23 @@ function getCompatibleActivePluginRegistry( return undefined; } const loadContext = resolvePluginLoadCacheContext(options); - if (pluginLoadOptionsMatchCacheKey(options, activeCacheKey)) { + const matchesActiveCacheKey = (candidate: PluginLoadOptions): boolean => { + if (pluginLoadOptionsMatchCacheKey(candidate, activeCacheKey)) { + return true; + } + if (candidate.coreGatewayMethodNames !== undefined) { + return false; + } + return pluginLoadOptionsMatchCacheKey( + { + ...candidate, + coreGatewayMethodNames: activeRegistry.coreGatewayMethodNames ?? [], + }, + activeCacheKey, + ); + }; + + if (matchesActiveCacheKey(options)) { return activeRegistry; } if (!loadContext.shouldActivate) { @@ -1010,7 +1026,7 @@ function getCompatibleActivePluginRegistry( ...options, activate: true, }; - if (pluginLoadOptionsMatchCacheKey(activatingOptions, activeCacheKey)) { + if (matchesActiveCacheKey(activatingOptions)) { return activeRegistry; } } @@ -1025,7 +1041,7 @@ function getCompatibleActivePluginRegistry( allowGatewaySubagentBinding: true, }, }; - if (pluginLoadOptionsMatchCacheKey(gatewayBindableOptions, activeCacheKey)) { + if (matchesActiveCacheKey(gatewayBindableOptions)) { return activeRegistry; } if (!loadContext.shouldActivate) { @@ -1037,56 +1053,11 @@ function getCompatibleActivePluginRegistry( allowGatewaySubagentBinding: true, }, }; - if (pluginLoadOptionsMatchCacheKey(activatingGatewayBindableOptions, activeCacheKey)) { + if (matchesActiveCacheKey(activatingGatewayBindableOptions)) { return activeRegistry; } } } - 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/tools.optional.test.ts b/src/plugins/tools.optional.test.ts index 37f761410df..3173eca4a21 100644 --- a/src/plugins/tools.optional.test.ts +++ b/src/plugins/tools.optional.test.ts @@ -163,6 +163,7 @@ function resolveAutoEnabledOptionalDemoTools() { function createOptionalDemoActiveRegistry() { return { + plugins: [{ id: "optional-demo", status: "loaded" }], tools: [createOptionalDemoEntry()], diagnostics: [], }; @@ -403,10 +404,10 @@ describe("resolvePluginTools optional tools", () => { expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); }); - it("reuses the active registry for gateway-bindable tool loads before reloading", () => { + it("routes gateway-bindable tool loads through scoped runtime compatibility", () => { const activeRegistry = createOptionalDemoActiveRegistry(); setActivePluginRegistry(activeRegistry as never, "gateway-startup", "gateway-bindable"); - resolveRuntimePluginRegistryMock.mockReturnValue(undefined); + resolveRuntimePluginRegistryMock.mockReturnValue(activeRegistry); const tools = resolvePluginTools( createResolveToolsParams({ @@ -416,10 +417,46 @@ describe("resolvePluginTools optional tools", () => { ); expectResolvedToolNames(tools, ["optional_tool"]); - expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled(); + expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledWith( + expect.objectContaining({ + onlyPluginIds: ["optional-demo"], + runtimeOptions: { + allowGatewaySubagentBinding: true, + }, + }), + ); expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); }); + it("adds enabled non-startup tool plugins to the active tool runtime scope", () => { + const activeRegistry = createOptionalDemoActiveRegistry(); + setActivePluginRegistry(activeRegistry as never, "gateway-startup", "gateway-bindable"); + resolveRuntimePluginRegistryMock.mockReturnValue(activeRegistry); + + resolvePluginTools({ + context: { + ...createContext(), + config: { + plugins: { + enabled: true, + allow: ["tavily"], + entries: { + tavily: { enabled: true }, + }, + }, + }, + } as never, + toolAllowlist: ["optional_tool"], + allowGatewaySubagentBinding: true, + }); + + expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledWith( + expect.objectContaining({ + onlyPluginIds: ["optional-demo", "tavily"], + }), + ); + }); + it("loads plugin tools when gateway-bindable tool loads have no active registry", () => { setOptionalDemoRegistry(); diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index af22f8a406c..ebf96deeb35 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -1,12 +1,10 @@ import { normalizeToolName } from "../agents/tool-policy.js"; import type { AnyAgentTool } from "../agents/tools/common.js"; import { applyTestPluginDefaults, normalizePluginsConfig } from "./config-state.js"; +import { listEnabledInstalledPluginRecords } from "./installed-plugin-index.js"; import { resolveRuntimePluginRegistry, type PluginLoadOptions } from "./loader.js"; -import { - getActivePluginRegistry, - getActivePluginRegistryKey, - getActivePluginRuntimeSubagentMode, -} from "./runtime.js"; +import { loadPluginRegistrySnapshot } from "./plugin-registry-snapshot.js"; +import { getActivePluginRegistry } from "./runtime.js"; import { buildPluginRuntimeLoadOptions, resolvePluginRuntimeLoadContext, @@ -94,18 +92,29 @@ function describeMalformedPluginTool(tool: unknown): string | undefined { return undefined; } -function resolvePluginToolRegistry(params: { - loadOptions: PluginLoadOptions; - allowGatewaySubagentBinding?: boolean; -}) { - if ( - params.allowGatewaySubagentBinding && - getActivePluginRegistryKey() && - getActivePluginRuntimeSubagentMode() === "gateway-bindable" - ) { - return getActivePluginRegistry() ?? resolveRuntimePluginRegistry(params.loadOptions); +function resolvePluginToolRuntimePluginIds(params: { + config: PluginLoadOptions["config"]; + workspaceDir?: string; + env: NodeJS.ProcessEnv; +}): string[] | undefined { + const pluginIds = new Set(); + const activeRegistry = getActivePluginRegistry(); + for (const plugin of activeRegistry?.plugins ?? []) { + if (plugin.status === undefined || plugin.status === "loaded") { + pluginIds.add(plugin.id); + } } - return resolveRuntimePluginRegistry(params.loadOptions); + const index = loadPluginRegistrySnapshot({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }); + for (const plugin of listEnabledInstalledPluginRecords(index, params.config)) { + pluginIds.add(plugin.pluginId); + } + return pluginIds.size > 0 + ? [...pluginIds].toSorted((left, right) => left.localeCompare(right)) + : undefined; } export function resolvePluginTools(params: { @@ -133,16 +142,19 @@ export function resolvePluginTools(params: { const runtimeOptions = params.allowGatewaySubagentBinding ? { allowGatewaySubagentBinding: true as const } : undefined; + const onlyPluginIds = resolvePluginToolRuntimePluginIds({ + config: context.config, + workspaceDir: context.workspaceDir, + env, + }); const loadOptions = buildPluginRuntimeLoadOptions(context, { installBundledRuntimeDeps: false, activate: false, toolDiscovery: true, + ...(onlyPluginIds !== undefined ? { onlyPluginIds } : {}), runtimeOptions, }); - const registry = resolvePluginToolRegistry({ - loadOptions, - allowGatewaySubagentBinding: params.allowGatewaySubagentBinding, - }); + const registry = resolveRuntimePluginRegistry(loadOptions); if (!registry) { return []; }