diff --git a/src/plugins/hook-runner-global-state.ts b/src/plugins/hook-runner-global-state.ts index 40b95e83d2f2..f9aabd7317f3 100644 --- a/src/plugins/hook-runner-global-state.ts +++ b/src/plugins/hook-runner-global-state.ts @@ -8,6 +8,7 @@ import type { PluginRegistry, PluginTrustedToolPolicyRegistryRegistration, } from "./registry-types.js"; +import { getPluginRegistryState } from "./runtime-state.js"; import { collectLivePluginRegistries } from "./runtime.js"; type TrustedPolicyHookRunnerRegistry = GlobalHookRunnerRegistry & { @@ -47,11 +48,15 @@ function collectHookRegistrySources( seen.add(registry); ordered.push(registry); }; - // Precedence: the explicitly initialized registry wins so an SDK caller that - // initializes an isolated registry stays authoritative; in the gateway it is - // the same object as the active registry, so this just dedupes. - add(lastInitialized); - for (const registry of collectLivePluginRegistries()) { + const liveRegistries = collectLivePluginRegistries(); + const initializedLiveRegistry = liveRegistries.some((registry) => registry === lastInitialized); + // SDK callers can initialize an isolated registry and expect it to stay + // authoritative. Runtime activations compose all live registries; owner + // selection below aligns same-plugin hooks with their tool registry. + if (!initializedLiveRegistry) { + add(lastInitialized); + } + for (const registry of liveRegistries) { add(registry); } return ordered; @@ -84,6 +89,36 @@ function composeLiveHookRegistry( } return ids; }); + const liveRegistries = collectLivePluginRegistries(); + if (lastInitialized && !liveRegistries.includes(lastInitialized as PluginRegistry)) { + const isolatedSourceIndex = sources.indexOf(lastInitialized); + if (isolatedSourceIndex >= 0) { + for (const pluginId of expectDefined( + hookPluginIdsBySource[isolatedSourceIndex], + "isolated hook plugin ids", + )) { + claimOwner(pluginId, isolatedSourceIndex); + } + } + } + const claimToolOwners = (registry: PluginRegistry | null | undefined) => { + if (!registry) { + return; + } + const sourceIndex = sources.indexOf(registry); + if (sourceIndex < 0) { + return; + } + for (const tool of registry.tools) { + claimOwner(tool.pluginId, sourceIndex); + } + }; + const runtimeState = getPluginRegistryState(); + // Match tool resolution: an isolated initialized registry stays authoritative, + // then the pinned Gateway owner wins only for tools it actually registered, + // followed by the active registry for remaining tool owners. + claimToolOwners(runtimeState?.channel.pinned ? runtimeState.channel.registry : null); + claimToolOwners(runtimeState?.activeRegistry); // Prefer the highest-precedence source where the plugin loaded AND actually // contributes a hook, so a loaded-but-hookless record (failed/disabled scoped // reload, or a setup-runtime channel load) cannot shadow a lower-precedence diff --git a/src/plugins/hook-runner-global.compose.test.ts b/src/plugins/hook-runner-global.compose.test.ts index caab21733f7e..43e8b64949e1 100644 --- a/src/plugins/hook-runner-global.compose.test.ts +++ b/src/plugins/hook-runner-global.compose.test.ts @@ -30,12 +30,105 @@ function runner() { return value; } +function addToolOwner(registry: PluginRegistry, pluginId: string, toolName: string) { + registry.tools.push({ + pluginId, + factory: () => [], + names: [toolName], + declaredNames: [toolName], + optional: false, + source: "test", + }); +} + afterEach(() => { resetGlobalHookRunner(); resetPluginRuntimeStateForTest(); }); -describe("global hook runner composition (#91918)", () => { +describe("global hook runner composition (#91918, #107933)", () => { + it("uses the pinned gateway owner when the active registry has the same plugin", async () => { + const gatewayHook = vi.fn(); + const activeHook = vi.fn(); + const gateway = createMockPluginRegistry([ + { hookName: "before_tool_call", handler: gatewayHook, pluginId: "stateful" }, + ]); + const active = createMockPluginRegistry([ + { hookName: "before_tool_call", handler: activeHook, pluginId: "stateful" }, + ]); + addToolOwner(gateway, "stateful", "stateful_tool"); + addToolOwner(active, "stateful", "stateful_tool"); + + setActivePluginRegistry(gateway); + pinActivePluginChannelRegistry(gateway); + initializeGlobalHookRunner(gateway); + setActivePluginRegistry(active); + initializeGlobalHookRunner(active); + + await runner().runBeforeToolCall( + { toolName: "stateful_tool", params: {} }, + { + agentId: "test-agent", + sessionKey: "test-session", + toolCallId: "test-call", + toolName: "stateful_tool", + }, + ); + + // Plugin tools resolve from the pinned channel registry before the active + // registry. Hooks for that plugin must use the same registration closure. + expect(gatewayHook).toHaveBeenCalledOnce(); + expect(activeHook).not.toHaveBeenCalled(); + }); + + it("uses the active hook when the pinned registry has no matching tool owner", async () => { + const gatewayHook = vi.fn(); + const activeHook = vi.fn(); + const gateway = createMockPluginRegistry([ + { hookName: "before_tool_call", handler: gatewayHook, pluginId: "conditional" }, + ]); + const active = createMockPluginRegistry([ + { hookName: "before_tool_call", handler: activeHook, pluginId: "conditional" }, + ]); + addToolOwner(active, "conditional", "conditional_tool"); + + setActivePluginRegistry(gateway); + pinActivePluginChannelRegistry(gateway); + initializeGlobalHookRunner(gateway); + setActivePluginRegistry(active); + initializeGlobalHookRunner(active); + + await runner().runBeforeToolCall( + { toolName: "conditional_tool", params: {} }, + { + agentId: "test-agent", + sessionKey: "test-session", + toolCallId: "test-call", + toolName: "conditional_tool", + }, + ); + + expect(activeHook).toHaveBeenCalledOnce(); + expect(gatewayHook).not.toHaveBeenCalled(); + }); + + it("does not borrow a pinned hook when the active tool owner registered none", () => { + const gateway = createMockPluginRegistry([ + { hookName: "before_tool_call", handler: vi.fn(), pluginId: "conditional" }, + ]); + const active = createMockPluginRegistry([]); + expectDefined(active.plugins[0], "active.plugins[0] test invariant").id = "conditional"; + addToolOwner(active, "conditional", "conditional_tool"); + + setActivePluginRegistry(gateway); + pinActivePluginChannelRegistry(gateway); + initializeGlobalHookRunner(gateway); + setActivePluginRegistry(active); + initializeGlobalHookRunner(active); + + expect(runner().hasHooks("before_tool_call")).toBe(false); + }); + it("prefers a loaded registration over a failed scoped reload of the same plugin", () => { const boot = createMockPluginRegistry([ { hookName: "before_tool_call", handler: vi.fn(), pluginId: "gate" }, diff --git a/src/plugins/hook-runner-global.ts b/src/plugins/hook-runner-global.ts index 15d20b630a67..b2cc3dcf58b8 100644 --- a/src/plugins/hook-runner-global.ts +++ b/src/plugins/hook-runner-global.ts @@ -5,9 +5,9 @@ * and can be called from anywhere in the codebase. * * The runner is created once and resolves hooks live on every dispatch from a - * composed view of the registries that are currently live: the most recently - * initialized registry, the active registry, and the pinned channel/http-route - * surfaces. Freezing one registry caused scoped mid-run activations (harness + * composed view of the registries that are currently live: an explicitly + * initialized SDK registry, the pinned channel registry, the active registry, + * and other pinned surfaces. Freezing one registry caused scoped mid-run activations (harness * and memory ensures) to rebind the runner to a narrow registry and silently * drop other plugins' tool-call hooks (#91918). Composing live also preserves * the older contract that hooks pushed into a registry after initialization @@ -29,7 +29,8 @@ const getLog = () => createSubsystemLogger("plugins"); * Initialize the global hook runner with a plugin registry. * Called on every plugin registry activation and by SDK consumers. The runner * instance stays stable so references captured mid-run keep seeing current - * hooks; the passed registry becomes the highest-precedence composition source. + * hooks. An isolated SDK registry stays authoritative; runtime registries use + * the gateway surface precedence shared by plugin tool resolution. */ export function initializeGlobalHookRunner(registry: GlobalHookRunnerRegistry): void { const state = getHookRunnerGlobalState(); diff --git a/src/plugins/tools.optional.test.ts b/src/plugins/tools.optional.test.ts index 0a3d0502272f..378a87aaab5c 100644 --- a/src/plugins/tools.optional.test.ts +++ b/src/plugins/tools.optional.test.ts @@ -1025,7 +1025,7 @@ describe("resolvePluginTools optional tools", () => { expectLoaderSelectedOnlyPluginIds(["optional-demo"]); }); - it("does not reuse a partial active registry for wildcard-selected plugin tools", () => { + it("combines a partial active registry with missing wildcard-selected plugin tools", () => { const context = createContext(); const config = context.config; const optionalEntry = createOptionalDemoEntry(); @@ -1099,7 +1099,7 @@ describe("resolvePluginTools optional tools", () => { }; expect(loaderParams.activate).toBe(false); expect(loaderParams.cache).toBe(false); - expect(loaderParams.onlyPluginIds).toEqual(["multi", "optional-demo"]); + expect(loaderParams.onlyPluginIds).toEqual(["optional-demo"]); expect(loaderParams.toolDiscovery).toBe(true); }); @@ -1147,7 +1147,7 @@ describe("resolvePluginTools optional tools", () => { ); }); - it("uses the fresh cold-loaded registry for diagnostics when partial active registries remain incomplete", () => { + it("combines partial active and cold-loaded registries without false diagnostics", () => { const context = createContext(); const config = context.config; const multiEntry: MockRegistryToolEntry = { @@ -1212,15 +1212,73 @@ describe("resolvePluginTools optional tools", () => { }), ); - expectResolvedToolNames(tools, ["optional_tool"]); + expectResolvedToolNames(tools, ["other_tool", "optional_tool"]); expect(getActivePluginRegistry?.()).toBe(staleRegistry); - expectSingleDiagnosticMessage( - freshRegistry.diagnostics, - "plugin tool registry did not include selected plugin tools after cold load (multi)", - ); + expectLoaderSelectedOnlyPluginIds(["optional-demo"]); + expect(freshRegistry.diagnostics).toStrictEqual([]); expect(staleRegistry.diagnostics).toStrictEqual([]); }); + it("keeps missing-owner diagnostics on the request-local cold registry", () => { + const context = createContext(); + const config = context.config; + const multiEntry: MockRegistryToolEntry = { + pluginId: "multi", + optional: false, + source: "/tmp/multi.js", + names: ["other_tool"], + declaredNames: ["other_tool"], + factory: () => makeTool("other_tool"), + }; + installToolManifestSnapshots({ + config, + plugins: [ + { + id: "multi", + origin: "bundled", + enabledByDefault: true, + channels: [], + providers: [], + contracts: { tools: ["other_tool"] }, + }, + { + id: "optional-demo", + origin: "bundled", + enabledByDefault: true, + channels: [], + providers: [], + contracts: { tools: ["optional_tool"] }, + }, + ], + }); + const activeRegistry = createToolRegistry([multiEntry]); + const coldRegistry = createToolRegistry([]); + coldRegistry.plugins.push({ id: "optional-demo", origin: "bundled", status: "loaded" }); + setActivePluginRegistry?.( + activeRegistry as never, + "partial-test-tool-registry", + "gateway-bindable", + "/tmp", + ); + resolveRuntimePluginRegistryMock.mockReturnValue(activeRegistry); + loadOpenClawPluginsMock.mockReturnValue(coldRegistry); + + const tools = resolvePluginTools( + createResolveToolsParams({ + context, + toolAllowlist: ["*", "optional-demo"], + }), + ); + + expectResolvedToolNames(tools, ["other_tool"]); + expectLoaderSelectedOnlyPluginIds(["optional-demo"]); + expectSingleDiagnosticMessage( + coldRegistry.diagnostics, + "plugin tool registry did not include selected plugin tools after cold load (optional-demo)", + ); + expect(activeRegistry.diagnostics).toStrictEqual([]); + }); + it("does not reuse a pinned gateway registry for manifest-unavailable tools", () => { const config = createContext().config; installToolManifestSnapshot({ @@ -3263,21 +3321,16 @@ describe("resolvePluginTools optional tools", () => { toolAllowlist: ["*", "tavily"], allowGatewaySubagentBinding: true, }); - const runtimeRegistryParams = mockCallParams(resolveRuntimePluginRegistryMock) as { - onlyPluginIds?: string[]; - toolDiscovery?: unknown; - }; - expect(runtimeRegistryParams.onlyPluginIds).toContain("tavily"); - expect(runtimeRegistryParams.toolDiscovery).toBe(true); + expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled(); const loaderParams = mockCallParams(loadOpenClawPluginsMock) as { onlyPluginIds?: string[]; toolDiscovery?: unknown; }; - expect(loaderParams.onlyPluginIds).toContain("tavily"); + expect(loaderParams.onlyPluginIds).toEqual(["tavily"]); expect(loaderParams.toolDiscovery).toBe(true); }); - it("reuses the pinned gateway channel registry after provider runtime loads replace active registry", () => { + it("reuses the pinned gateway registry across workspace-scoped provider runtime loads", () => { const gatewayRegistry = createOptionalDemoActiveRegistry(); setActivePluginRegistry( gatewayRegistry as never, @@ -3298,7 +3351,7 @@ describe("resolvePluginTools optional tools", () => { } as never, "provider-runtime", "default", - "/tmp", + "/tmp/provider-workspace", ); resolveRuntimePluginRegistryMock.mockReturnValue(undefined); @@ -3314,6 +3367,233 @@ describe("resolvePluginTools optional tools", () => { expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); }); + it("loads only tool plugins missing from the pinned gateway registry", () => { + const gatewayFactory = vi.fn(() => makeTool("optional_tool")); + const standaloneFactory = vi.fn(() => makeTool("other_tool")); + const gatewayRegistry = createToolRegistry([ + { + pluginId: "optional-demo", + optional: true, + source: "/tmp/optional-demo.js", + names: ["optional_tool"], + factory: gatewayFactory, + }, + ]); + const standaloneRegistry = createToolRegistry([ + { + pluginId: "multi", + optional: false, + source: "/tmp/multi.js", + names: ["other_tool"], + factory: standaloneFactory, + }, + ]); + const config = createContext().config; + installToolManifestSnapshots({ + config, + plugins: [ + { + id: "optional-demo", + origin: "bundled", + enabledByDefault: true, + channels: [], + providers: [], + contracts: { tools: ["optional_tool"] }, + }, + { + id: "multi", + origin: "bundled", + enabledByDefault: true, + channels: [], + providers: [], + contracts: { tools: ["other_tool"] }, + }, + ], + }); + setActivePluginRegistry( + gatewayRegistry as never, + "gateway-startup", + "gateway-bindable", + "/tmp", + ); + pinActivePluginChannelRegistry(gatewayRegistry as never); + setActivePluginRegistry( + createEmptyPluginRegistry(), + "provider-runtime", + "default", + "/tmp/provider-workspace", + ); + resolveRuntimePluginRegistryMock.mockReturnValue(undefined); + loadOpenClawPluginsMock.mockReturnValue(standaloneRegistry); + + const tools = resolvePluginTools( + createResolveToolsParams({ + context: { ...createContext(), config }, + toolAllowlist: ["optional_tool", "other_tool"], + allowGatewaySubagentBinding: true, + }), + ); + + expectResolvedToolNames(tools, ["optional_tool", "other_tool"]); + expect(gatewayFactory).toHaveBeenCalledOnce(); + expect(standaloneFactory).toHaveBeenCalledOnce(); + expectLoaderSelectedOnlyPluginIds(["multi"]); + }); + + it("combines pinned gateway and compatible active tool owners", () => { + const gatewayFactory = vi.fn(() => makeTool("optional_tool")); + const activeFactory = vi.fn(() => makeTool("other_tool")); + const gatewayRegistry = createToolRegistry([ + { + pluginId: "optional-demo", + optional: true, + source: "/tmp/optional-demo.js", + names: ["optional_tool"], + factory: gatewayFactory, + }, + ]); + const activeRegistry = createToolRegistry([ + { + pluginId: "multi", + optional: false, + source: "/tmp/multi.js", + names: ["other_tool"], + factory: activeFactory, + }, + ]); + const config = createContext().config; + installToolManifestSnapshots({ + config, + plugins: [ + { + id: "optional-demo", + origin: "bundled", + enabledByDefault: true, + channels: [], + providers: [], + contracts: { tools: ["optional_tool"] }, + }, + { + id: "multi", + origin: "bundled", + enabledByDefault: true, + channels: [], + providers: [], + contracts: { tools: ["other_tool"] }, + }, + ], + }); + setActivePluginRegistry( + gatewayRegistry as never, + "gateway-startup", + "gateway-bindable", + "/tmp", + ); + pinActivePluginChannelRegistry(gatewayRegistry as never); + setActivePluginRegistry(activeRegistry as never, "provider-runtime", "default", "/tmp"); + resolveRuntimePluginRegistryMock.mockReturnValue(activeRegistry); + + const tools = resolvePluginTools( + createResolveToolsParams({ + context: { ...createContext(), config }, + toolAllowlist: ["optional_tool", "other_tool"], + allowGatewaySubagentBinding: true, + }), + ); + + expectResolvedToolNames(tools, ["optional_tool", "other_tool"]); + expect(gatewayFactory).toHaveBeenCalledOnce(); + expect(activeFactory).toHaveBeenCalledOnce(); + expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); + }); + + it("combines partial active owners before loading only the still-missing plugin", () => { + const gatewayFactory = vi.fn(() => makeTool("optional_tool")); + const activeFactory = vi.fn(() => makeTool("other_tool")); + const standaloneFactory = vi.fn(() => makeTool("third_tool")); + const gatewayRegistry = createToolRegistry([ + { + pluginId: "optional-demo", + optional: true, + source: "/tmp/optional-demo.js", + names: ["optional_tool"], + factory: gatewayFactory, + }, + ]); + const activeRegistry = createToolRegistry([ + { + pluginId: "multi", + optional: false, + source: "/tmp/multi.js", + names: ["other_tool"], + factory: activeFactory, + }, + ]); + const standaloneRegistry = createToolRegistry([ + { + pluginId: "third", + optional: false, + source: "/tmp/third.js", + names: ["third_tool"], + factory: standaloneFactory, + }, + ]); + const config = createContext().config; + installToolManifestSnapshots({ + config, + plugins: [ + { + id: "optional-demo", + origin: "bundled", + enabledByDefault: true, + channels: [], + providers: [], + contracts: { tools: ["optional_tool"] }, + }, + { + id: "multi", + origin: "bundled", + enabledByDefault: true, + channels: [], + providers: [], + contracts: { tools: ["other_tool"] }, + }, + { + id: "third", + origin: "bundled", + enabledByDefault: true, + channels: [], + providers: [], + contracts: { tools: ["third_tool"] }, + }, + ], + }); + setActivePluginRegistry( + gatewayRegistry as never, + "gateway-startup", + "gateway-bindable", + "/tmp", + ); + pinActivePluginChannelRegistry(gatewayRegistry as never); + setActivePluginRegistry(activeRegistry as never, "provider-runtime", "default", "/tmp"); + resolveRuntimePluginRegistryMock.mockReturnValue(activeRegistry); + loadOpenClawPluginsMock.mockReturnValue(standaloneRegistry); + + const tools = resolvePluginTools( + createResolveToolsParams({ + context: { ...createContext(), config }, + toolAllowlist: ["optional_tool", "other_tool", "third_tool"], + allowGatewaySubagentBinding: true, + }), + ); + + expectResolvedToolNames(tools, ["optional_tool", "other_tool", "third_tool"]); + expect(gatewayFactory).toHaveBeenCalledOnce(); + expect(activeFactory).toHaveBeenCalledOnce(); + expect(standaloneFactory).toHaveBeenCalledOnce(); + expectLoaderSelectedOnlyPluginIds(["third"]); + }); + it("reuses the pinned gateway channel registry even when the caller omits gateway binding", () => { const gatewayRegistry = createOptionalDemoActiveRegistry(); setActivePluginRegistry( diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index 2efcb1e54f38..af5b4dc93117 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -25,6 +25,7 @@ import type { PluginManifestRecord } from "./manifest-registry.js"; import { hasManifestToolAvailability } from "./manifest-tool-availability.js"; import type { PluginMetadataManifestView } from "./plugin-metadata-snapshot.types.js"; import type { PluginRegistry, PluginToolRegistration } from "./registry-types.js"; +import { getPluginRegistryState } from "./runtime-state.js"; import { withPluginRuntimePluginScope } from "./runtime/gateway-request-scope.js"; import { buildPluginRuntimeLoadOptions, @@ -999,35 +1000,105 @@ function resolvePluginToolRegistry(params: { retainedRegistry?: PluginRegistry; onRetainRegistry?: (registry: PluginRegistry) => void; }) { - const lookup = { - env: params.loadOptions.env, - loadOptions: params.loadOptions, - workspaceDir: params.loadOptions.workspaceDir, - requiredPluginIds: params.onlyPluginIds, + const requestedPluginIds = params.onlyPluginIds; + // Retained registries belong to one cached descriptor execution. Reusing one + // across a multi-owner request would keep its tools but lose its hook state + // when another owner triggers a fresh scoped load. + const retainedRegistry = + requestedPluginIds === undefined || requestedPluginIds.length === 1 + ? params.retainedRegistry + : undefined; + const registries: PluginRegistry[] = []; + const seenRegistries = new Set(); + const ownerRegistryByPluginId = new Map(); + const addRegistry = (registry: PluginRegistry | undefined) => { + if (!registry || seenRegistries.has(registry)) { + return; + } + seenRegistries.add(registry); + registries.push(registry); + if (requestedPluginIds === undefined) { + return; + } + const toolPluginIds = new Set(registry.tools.map((entry) => entry.pluginId)); + for (const pluginId of requestedPluginIds) { + if (toolPluginIds.has(pluginId) && !ownerRegistryByPluginId.has(pluginId)) { + ownerRegistryByPluginId.set(pluginId, registry); + } + } }; - const channelRegistry = getLoadedRuntimePluginRegistry({ - ...lookup, - surface: "channel", - }); - if (registryHasScopedPluginTools(channelRegistry, params.onlyPluginIds)) { - return channelRegistry; + const missingPluginIds = () => + requestedPluginIds?.filter((pluginId) => !ownerRegistryByPluginId.has(pluginId)); + const composeSelectedRegistries = () => + composePluginToolRegistries({ + registries, + ownerRegistryByPluginId, + requestedPluginIds: requestedPluginIds ?? [], + }); + + // Use the established pinned-Gateway owner; its factories receive request + // context directly. Reapplying active-scope metadata would duplicate the + // registration and split hook/tool closure state. + const runtimeState = getPluginRegistryState(); + const gatewayRegistry = runtimeState?.channel.pinned + ? (runtimeState.channel.registry ?? undefined) + : undefined; + if ( + requestedPluginIds === undefined && + registryHasScopedPluginTools(gatewayRegistry, undefined) + ) { + return gatewayRegistry; + } + addRegistry(gatewayRegistry); + let requiredPluginIds = missingPluginIds(); + if (requiredPluginIds?.length === 0) { + return composeSelectedRegistries(); } - const activeRegistry = getLoadedRuntimePluginRegistry({ - env: lookup.env, - workspaceDir: lookup.workspaceDir, - requiredPluginIds: lookup.requiredPluginIds, - surface: "active", - }); - if (registryHasScopedPluginTools(activeRegistry, params.onlyPluginIds)) { + let activeRegistry: PluginRegistry | undefined; + if (requiredPluginIds === undefined) { + activeRegistry = getLoadedRuntimePluginRegistry({ + env: params.loadOptions.env, + workspaceDir: params.loadOptions.workspaceDir, + surface: "active", + }); + } else { + for (const pluginId of requiredPluginIds) { + activeRegistry = getLoadedRuntimePluginRegistry({ + env: params.loadOptions.env, + workspaceDir: params.loadOptions.workspaceDir, + requiredPluginIds: [pluginId], + surface: "active", + }); + if (activeRegistry) { + break; + } + } + } + if (requestedPluginIds === undefined && registryHasScopedPluginTools(activeRegistry, undefined)) { return activeRegistry; } - - if (registryHasScopedPluginTools(params.retainedRegistry, params.onlyPluginIds)) { - return params.retainedRegistry; + addRegistry(activeRegistry); + requiredPluginIds = missingPluginIds(); + if (requiredPluginIds?.length === 0) { + return composeSelectedRegistries(); } - const forceStandaloneLoad = Boolean(channelRegistry || activeRegistry); + if ( + requestedPluginIds === undefined && + registryHasScopedPluginTools(retainedRegistry, undefined) + ) { + return retainedRegistry; + } + addRegistry(retainedRegistry); + requiredPluginIds = missingPluginIds(); + if (requiredPluginIds?.length === 0) { + return composeSelectedRegistries(); + } + // Partial active/retained registries contribute their matching owners, but + // missing requested owners still force a fresh load. Plugin records alone + // do not prove that the executable tool registrations are available. + const forceStandaloneLoad = Boolean(gatewayRegistry || activeRegistry || retainedRegistry); const shouldRetainColdLoadedToolRegistry = forceStandaloneLoad && params.loadOptions.activate === false && @@ -1037,16 +1108,55 @@ function resolvePluginToolRegistry(params: { surface: "active", forceLoad: forceStandaloneLoad, installRegistry: !forceStandaloneLoad, - requiredPluginIds: params.onlyPluginIds, - loadOptions: params.loadOptions, + requiredPluginIds, + loadOptions: + requestedPluginIds === undefined + ? params.loadOptions + : { ...params.loadOptions, onlyPluginIds: requiredPluginIds }, }); - if (registryHasScopedPluginTools(standaloneRegistry, params.onlyPluginIds)) { - if (shouldRetainColdLoadedToolRegistry) { - params.onRetainRegistry?.(standaloneRegistry); - } - return standaloneRegistry; + if (standaloneRegistry && shouldRetainColdLoadedToolRegistry) { + params.onRetainRegistry?.(standaloneRegistry); } - return standaloneRegistry ?? channelRegistry ?? activeRegistry; + addRegistry(standaloneRegistry); + if (requestedPluginIds === undefined) { + return standaloneRegistry ?? gatewayRegistry ?? activeRegistry; + } + return composeSelectedRegistries(); +} + +function composePluginToolRegistries(params: { + registries: PluginRegistry[]; + ownerRegistryByPluginId: ReadonlyMap; + requestedPluginIds: readonly string[]; +}): PluginRegistry | undefined { + if (params.registries.length === 0) { + return undefined; + } + const contributingRegistries = params.registries.filter((registry) => + params.requestedPluginIds.some( + (pluginId) => params.ownerRegistryByPluginId.get(pluginId) === registry, + ), + ); + const baseRegistry = params.registries.at(-1)!; + if (contributingRegistries.length === 1 && contributingRegistries[0] === baseRegistry) { + return baseRegistry; + } + const selectedPluginIds = new Set(params.requestedPluginIds); + return { + ...baseRegistry, + plugins: contributingRegistries.flatMap((registry) => + registry.plugins.filter( + (plugin) => + selectedPluginIds.has(plugin.id) && + params.ownerRegistryByPluginId.get(plugin.id) === registry, + ), + ), + tools: contributingRegistries.flatMap((registry) => + registry.tools.filter( + (entry) => params.ownerRegistryByPluginId.get(entry.pluginId) === registry, + ), + ), + }; } function registryHasScopedPluginTools(