fix: stateful plugin hooks and tools stay aligned after scoped loads (#108110)

* fix(plugins): align hook and tool registrations

Reuse gateway-owned plugin registrations for matching hooks and tools while loading only missing tool owners from narrower runtime scopes.\n\nCo-authored-by: w33d <w33d@steadholme.local>

* test(plugins): cover mixed registry tool owners

Verify gateway-pinned and compatible active registrations compose without another plugin load.

Co-authored-by: w33d <w33d@steadholme.local>

* refactor(plugins): keep pinned registry lookup internal

Reuse the existing runtime-state contract so hook and tool ownership does not expand the Plugin SDK surface or treat unpinned active registries as Gateway owners.

* test(plugins): complete hook context fixture

Supply the required tool name in the stateful hook ownership regression context.

* chore: leave contributor release note in PR

Normal contributor PRs do not modify the release-owned changelog; the PR body retains the release note and attribution.

* fix(plugins): align partial registry owners

* fix(plugins): preserve scoped tool diagnostics

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: w33d <w33d@steadholme.local>
This commit is contained in:
w33d
2026-07-15 22:12:43 +08:00
committed by GitHub
parent 4e78d91c0f
commit dd149f01ba
5 changed files with 576 additions and 57 deletions
+40 -5
View File
@@ -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
+94 -1
View File
@@ -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" },
+5 -4
View File
@@ -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();
+297 -17
View File
@@ -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(
+140 -30
View File
@@ -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<PluginRegistry>();
const ownerRegistryByPluginId = new Map<string, PluginRegistry>();
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<string, PluginRegistry>;
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(