fix(plugins): scope tool registry reuse to plugin plan

This commit is contained in:
Peter Steinberger
2026-05-01 13:50:06 +01:00
parent f46871bc74
commit 866be0baae
4 changed files with 133 additions and 77 deletions

View File

@@ -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);
});

View File

@@ -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;
}

View File

@@ -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();

View File

@@ -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<string>();
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 [];
}