Plugins: reuse compatible registries for runtime providers

This commit is contained in:
Gustavo Madeira Santana 2026-03-28 00:07:15 -04:00
parent fd0aac297c
commit a00127bf5b
No known key found for this signature in database
7 changed files with 125 additions and 82 deletions

View File

@ -2,12 +2,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createEmptyPluginRegistry } from "../plugins/registry.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
const { loadOpenClawPluginsMock } = vi.hoisted(() => ({
const { loadOpenClawPluginsMock, getCompatibleActivePluginRegistryMock } = vi.hoisted(() => ({
loadOpenClawPluginsMock: vi.fn(() => createEmptyPluginRegistry()),
getCompatibleActivePluginRegistryMock: vi.fn<
(params?: unknown) => ReturnType<typeof createEmptyPluginRegistry> | undefined
>(() => undefined),
}));
vi.mock("../plugins/loader.js", () => ({
loadOpenClawPlugins: loadOpenClawPluginsMock,
getCompatibleActivePluginRegistry: getCompatibleActivePluginRegistryMock,
}));
let getImageGenerationProvider: typeof import("./provider-registry.js").getImageGenerationProvider;
@ -17,6 +21,8 @@ describe("image-generation provider registry", () => {
afterEach(() => {
loadOpenClawPluginsMock.mockReset();
loadOpenClawPluginsMock.mockReturnValue(createEmptyPluginRegistry());
getCompatibleActivePluginRegistryMock.mockReset();
getCompatibleActivePluginRegistryMock.mockReturnValue(undefined);
resetPluginRuntimeStateForTest();
});
@ -50,6 +56,7 @@ describe("image-generation provider registry", () => {
},
});
setActivePluginRegistry(registry);
getCompatibleActivePluginRegistryMock.mockReturnValue(registry);
const provider = getImageGenerationProvider("custom-image");
@ -94,6 +101,7 @@ describe("image-generation provider registry", () => {
},
);
setActivePluginRegistry(registry);
getCompatibleActivePluginRegistryMock.mockReturnValue(registry);
expect(listImageGenerationProviders().map((provider) => provider.id)).toEqual(["safe-image"]);
expect(getImageGenerationProvider("__proto__")).toBeUndefined();

View File

@ -2,7 +2,6 @@ import { normalizeProviderId } from "../agents/model-selection.js";
import type { OpenClawConfig } from "../config/config.js";
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
import { resolvePluginCapabilityProviders } from "../plugins/capability-provider-runtime.js";
import { getActivePluginRegistryKey } from "../plugins/runtime.js";
import type { ImageGenerationProviderPlugin } from "../plugins/types.js";
const BUILTIN_IMAGE_GENERATION_PROVIDERS: readonly ImageGenerationProviderPlugin[] = [];
@ -26,8 +25,6 @@ function resolvePluginImageGenerationProviders(
return resolvePluginCapabilityProviders({
key: "imageGenerationProviders",
cfg,
useActiveRegistryWhen: (active) =>
(active?.imageGenerationProviders?.length ?? 0) > 0 || Boolean(getActivePluginRegistryKey()),
});
}

View File

@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { createEmptyPluginRegistry } from "./registry.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "./runtime.js";
import { resetPluginRuntimeStateForTest } from "./runtime.js";
type MockManifestRegistry = {
plugins: Array<Record<string, unknown>>;
@ -14,6 +14,9 @@ function createEmptyMockManifestRegistry(): MockManifestRegistry {
const mocks = vi.hoisted(() => ({
loadOpenClawPlugins: vi.fn(() => createEmptyPluginRegistry()),
getCompatibleActivePluginRegistry: vi.fn<
(params?: unknown) => ReturnType<typeof createEmptyPluginRegistry> | undefined
>(() => undefined),
loadPluginManifestRegistry: vi.fn<() => MockManifestRegistry>(() =>
createEmptyMockManifestRegistry(),
),
@ -24,6 +27,7 @@ const mocks = vi.hoisted(() => ({
vi.mock("./loader.js", () => ({
loadOpenClawPlugins: mocks.loadOpenClawPlugins,
getCompatibleActivePluginRegistry: mocks.getCompatibleActivePluginRegistry,
}));
vi.mock("./manifest-registry.js", () => ({
@ -104,33 +108,14 @@ function setBundledCapabilityFixture(contractKey: string) {
});
}
function setActiveSpeechCapabilityRegistry(providerId: string) {
const active = createEmptyPluginRegistry();
active.speechProviders.push({
pluginId: providerId,
pluginName: "OpenAI",
source: "test",
provider: {
id: providerId,
label: "OpenAI",
isConfigured: () => true,
synthesize: async () => ({
audioBuffer: Buffer.from("x"),
outputFormat: "mp3",
voiceCompatible: false,
fileExtension: ".mp3",
}),
},
});
setActivePluginRegistry(active);
}
describe("resolvePluginCapabilityProviders", () => {
beforeEach(async () => {
vi.resetModules();
resetPluginRuntimeStateForTest();
mocks.loadOpenClawPlugins.mockReset();
mocks.loadOpenClawPlugins.mockReturnValue(createEmptyPluginRegistry());
mocks.getCompatibleActivePluginRegistry.mockReset();
mocks.getCompatibleActivePluginRegistry.mockReturnValue(undefined);
mocks.loadPluginManifestRegistry.mockReset();
mocks.loadPluginManifestRegistry.mockReturnValue(createEmptyMockManifestRegistry());
mocks.withBundledPluginAllowlistCompat.mockReset();
@ -143,7 +128,24 @@ describe("resolvePluginCapabilityProviders", () => {
});
it("uses the active registry when capability providers are already loaded", () => {
setActiveSpeechCapabilityRegistry("openai");
const active = createEmptyPluginRegistry();
active.speechProviders.push({
pluginId: "openai",
pluginName: "OpenAI",
source: "test",
provider: {
id: "openai",
label: "OpenAI",
isConfigured: () => true,
synthesize: async () => ({
audioBuffer: Buffer.from("x"),
outputFormat: "mp3",
voiceCompatible: false,
fileExtension: ".mp3",
}),
},
});
mocks.getCompatibleActivePluginRegistry.mockReturnValue(active);
const providers = resolvePluginCapabilityProviders({ key: "speechProviders" });
@ -171,4 +173,17 @@ describe("resolvePluginCapabilityProviders", () => {
enablementCompat,
});
});
it("reuses a compatible active registry even when the capability list is empty", () => {
const active = createEmptyPluginRegistry();
mocks.getCompatibleActivePluginRegistry.mockReturnValue(active);
const providers = resolvePluginCapabilityProviders({
key: "mediaUnderstandingProviders",
cfg: {} as OpenClawConfig,
});
expect(providers).toEqual([]);
expect(mocks.loadOpenClawPlugins).not.toHaveBeenCalled();
});
});

View File

@ -4,10 +4,9 @@ import {
withBundledPluginEnablementCompat,
withBundledPluginVitestCompat,
} from "./bundled-compat.js";
import { loadOpenClawPlugins } from "./loader.js";
import { getCompatibleActivePluginRegistry, loadOpenClawPlugins } from "./loader.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
import type { PluginRegistry } from "./registry.js";
import { getActivePluginRegistry } from "./runtime.js";
type CapabilityProviderRegistryKey =
| "speechProviders"
@ -67,17 +66,17 @@ function resolveCapabilityProviderConfig(params: {
export function resolvePluginCapabilityProviders<K extends CapabilityProviderRegistryKey>(params: {
key: K;
cfg?: OpenClawConfig;
useActiveRegistryWhen?: (active: PluginRegistry | undefined) => boolean;
}): CapabilityProviderForKey<K>[] {
const active = getActivePluginRegistry() ?? undefined;
const shouldUseActive =
params.useActiveRegistryWhen?.(active) ?? (active?.[params.key].length ?? 0) > 0;
const registry =
shouldUseActive || !params.cfg
? active
: loadOpenClawPlugins({
const loadOptions =
params.cfg === undefined
? undefined
: {
config: resolveCapabilityProviderConfig({ key: params.key, cfg: params.cfg }),
});
};
const registry =
(loadOptions ? getCompatibleActivePluginRegistry(loadOptions) : undefined) ??
(loadOptions ? loadOpenClawPlugins(loadOptions) : undefined) ??
getCompatibleActivePluginRegistry();
return (registry?.[params.key] ?? []).map(
(entry) => entry.provider,
) as CapabilityProviderForKey<K>[];

View File

@ -8,10 +8,13 @@ type MockRegistryToolEntry = {
};
const loadOpenClawPluginsMock = vi.fn();
const getCompatibleActivePluginRegistryMock = vi.fn();
const applyPluginAutoEnableMock = vi.fn();
vi.mock("./loader.js", () => ({
loadOpenClawPlugins: (params: unknown) => loadOpenClawPluginsMock(params),
getCompatibleActivePluginRegistry: (params: unknown) =>
getCompatibleActivePluginRegistryMock(params),
}));
vi.mock("../config/plugin-auto-enable.js", () => ({
@ -20,7 +23,6 @@ vi.mock("../config/plugin-auto-enable.js", () => ({
let resolvePluginTools: typeof import("./tools.js").resolvePluginTools;
let resetPluginRuntimeStateForTest: typeof import("./runtime.js").resetPluginRuntimeStateForTest;
let setActivePluginRegistry: typeof import("./runtime.js").setActivePluginRegistry;
function makeTool(name: string) {
return {
@ -135,13 +137,14 @@ describe("resolvePluginTools optional tools", () => {
beforeEach(async () => {
vi.resetModules();
loadOpenClawPluginsMock.mockClear();
getCompatibleActivePluginRegistryMock.mockReset();
getCompatibleActivePluginRegistryMock.mockReturnValue(undefined);
applyPluginAutoEnableMock.mockReset();
applyPluginAutoEnableMock.mockImplementation(({ config }: { config: unknown }) => ({
config,
changes: [],
}));
({ resetPluginRuntimeStateForTest } = await import("./runtime.js"));
({ setActivePluginRegistry } = await import("./runtime.js"));
resetPluginRuntimeStateForTest();
({ resolvePluginTools } = await import("./tools.js"));
});
@ -289,31 +292,6 @@ describe("resolvePluginTools optional tools", () => {
},
};
applyPluginAutoEnableMock.mockReturnValue({ config: autoEnabledConfig, changes: [] });
setActivePluginRegistry(
{
plugins: [],
tools: [],
hooks: [],
typedHooks: [],
channels: [],
channelSetups: [],
providers: [],
cliBackends: [],
speechProviders: [],
mediaUnderstandingProviders: [],
imageGenerationProviders: [],
webSearchProviders: [],
gatewayHandlers: {},
gatewayMethodScopes: {},
httpRoutes: [],
cliRegistrars: [],
services: [],
commands: [],
conversationBindingResolvedHandlers: [],
diagnostics: [],
} as never,
"stale-registry",
);
const tools = resolvePluginTools({
context: {
@ -326,4 +304,28 @@ describe("resolvePluginTools optional tools", () => {
expectResolvedToolNames(tools, ["optional_tool"]);
expectLoaderCall({ config: autoEnabledConfig });
});
it("reuses a compatible active registry instead of loading again", () => {
const activeRegistry = {
tools: [
{
pluginId: "optional-demo",
optional: true,
source: "/tmp/optional-demo.js",
factory: () => makeTool("optional_tool"),
},
],
diagnostics: [],
};
getCompatibleActivePluginRegistryMock.mockReturnValue(activeRegistry);
const tools = resolvePluginTools(
createResolveToolsParams({
toolAllowlist: ["optional_tool"],
}),
);
expectResolvedToolNames(tools, ["optional_tool"]);
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
});

View File

@ -3,9 +3,8 @@ import type { AnyAgentTool } from "../agents/tools/common.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { applyTestPluginDefaults, normalizePluginsConfig } from "./config-state.js";
import { loadOpenClawPlugins } from "./loader.js";
import { getCompatibleActivePluginRegistry, loadOpenClawPlugins } from "./loader.js";
import { createPluginLoaderLogger } from "./logger.js";
import { getActivePluginRegistry, getActivePluginRegistryKey } from "./runtime.js";
import type { OpenClawPluginToolContext } from "./types.js";
const log = createSubsystemLogger("plugins");
@ -69,21 +68,19 @@ export function resolvePluginTools(params: {
return [];
}
const activeRegistry = getActivePluginRegistry();
const loadOptions = {
config: effectiveConfig,
workspaceDir: params.context.workspaceDir,
runtimeOptions: params.allowGatewaySubagentBinding
? {
allowGatewaySubagentBinding: true,
}
: undefined,
env,
logger: createPluginLoaderLogger(log),
};
const registry =
getActivePluginRegistryKey() && activeRegistry && effectiveConfig === baseConfig
? activeRegistry
: loadOpenClawPlugins({
config: effectiveConfig,
workspaceDir: params.context.workspaceDir,
runtimeOptions: params.allowGatewaySubagentBinding
? {
allowGatewaySubagentBinding: true,
}
: undefined,
env,
logger: createPluginLoaderLogger(log),
});
getCompatibleActivePluginRegistry(loadOptions) ?? loadOpenClawPlugins(loadOptions);
const tools: AnyAgentTool[] = [];
const existing = params.existingToolNames ?? new Set<string>();

View File

@ -5,10 +5,14 @@ import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plug
import type { SpeechProviderPlugin } from "../plugins/types.js";
const loadOpenClawPluginsMock = vi.fn();
const getCompatibleActivePluginRegistryMock = vi.fn();
vi.mock("../plugins/loader.js", () => ({
loadOpenClawPlugins: (...args: Parameters<typeof loadOpenClawPluginsMock>) =>
loadOpenClawPluginsMock(...args),
getCompatibleActivePluginRegistry: (
...args: Parameters<typeof getCompatibleActivePluginRegistryMock>
) => getCompatibleActivePluginRegistryMock(...args),
}));
let getSpeechProvider: typeof import("./provider-registry.js").getSpeechProvider;
@ -37,6 +41,8 @@ describe("speech provider registry", () => {
resetPluginRuntimeStateForTest();
loadOpenClawPluginsMock.mockReset();
loadOpenClawPluginsMock.mockReturnValue(createEmptyPluginRegistry());
getCompatibleActivePluginRegistryMock.mockReset();
getCompatibleActivePluginRegistryMock.mockReturnValue(undefined);
({
getSpeechProvider,
listSpeechProviders,
@ -60,7 +66,16 @@ describe("speech provider registry", () => {
},
],
});
getCompatibleActivePluginRegistryMock.mockReturnValue({
...createEmptyPluginRegistry(),
speechProviders: [
{
pluginId: "test-demo-speech",
source: "test",
provider: createSpeechProvider("demo-speech"),
},
],
});
const providers = listSpeechProviders();
expect(providers.map((provider) => provider.id)).toEqual(["demo-speech"]);
@ -112,6 +127,16 @@ describe("speech provider registry", () => {
},
],
});
getCompatibleActivePluginRegistryMock.mockReturnValue({
...createEmptyPluginRegistry(),
speechProviders: [
{
pluginId: "test-microsoft",
source: "test",
provider: createSpeechProvider("microsoft", ["edge"]),
},
],
});
expect(normalizeSpeechProviderId("edge")).toBe("edge");
expect(canonicalizeSpeechProviderId("edge")).toBe("microsoft");