feat(plugins): narrow CLI loading via activation planning (#65120)

* feat(plugins): narrow cli loading via activation planning

* fix(plugins): normalize primary CLI command nullability

* fix(plugins): enforce activation planner exhaustiveness
This commit is contained in:
Vincent Koc
2026-04-12 09:07:47 +01:00
committed by GitHub
parent 24051ddf38
commit a9c7c2e1ed
8 changed files with 379 additions and 7 deletions

View File

@@ -527,6 +527,9 @@ actual behavior such as hooks, tools, commands, or provider flows.
Optional manifest `activation` and `setup` blocks stay on the control plane.
They are metadata-only descriptors for activation planning and setup discovery;
they do not replace runtime registration, `register(...)`, or `setupEntry`.
The first activation consumer now uses manifest command hints to narrow CLI
plugin loading when a primary command is known, instead of always loading every
CLI-capable plugin up front.
Setup discovery now prefers descriptor-owned ids such as `setup.providers` and
`setup.cliBackends` to narrow candidate plugins before it falls back to

View File

@@ -221,6 +221,9 @@ should activate it later.
This block is metadata only. It does not register runtime behavior, and it does
not replace `register(...)`, `setupEntry`, or other runtime/plugin entrypoints.
Current consumers use it as a narrowing hint before broader plugin loading, so
missing activation metadata only costs performance; it should not change
correctness.
```json
{
@@ -242,6 +245,10 @@ not replace `register(...)`, `setupEntry`, or other runtime/plugin entrypoints.
| `onRoutes` | No | `string[]` | Route kinds that should activate this plugin. |
| `onCapabilities` | No | `Array<"provider" \| "channel" \| "tool" \| "hook">` | Broad capability hints used by control-plane activation planning. |
For command-triggered planning specifically, OpenClaw still falls back to
legacy `commandAliases[].cliCommand` or `commandAliases[].name` when a plugin
has not added explicit `activation.onCommands` metadata yet.
## setup reference
Use `setup` when setup and onboarding surfaces need cheap plugin-owned metadata

View File

@@ -0,0 +1,170 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
loadPluginManifestRegistry: vi.fn(),
}));
vi.mock("./manifest-registry.js", () => ({
loadPluginManifestRegistry: (...args: unknown[]) => mocks.loadPluginManifestRegistry(...args),
}));
let resolveManifestActivationPluginIds: typeof import("./activation-planner.js").resolveManifestActivationPluginIds;
describe("resolveManifestActivationPluginIds", () => {
beforeAll(async () => {
({ resolveManifestActivationPluginIds } = await import("./activation-planner.js"));
});
beforeEach(() => {
mocks.loadPluginManifestRegistry.mockReset();
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "memory-core",
commandAliases: [{ name: "dreaming", kind: "runtime-slash", cliCommand: "memory" }],
providers: [],
channels: [],
cliBackends: [],
skills: [],
hooks: [],
origin: "bundled",
},
{
id: "device-pair",
commandAliases: [{ name: "pair", kind: "runtime-slash" }],
providers: [],
channels: [],
cliBackends: [],
skills: [],
hooks: [],
origin: "bundled",
},
{
id: "openai",
providers: ["openai"],
setup: {
providers: [{ id: "openai-codex" }],
},
channels: [],
cliBackends: [],
skills: [],
hooks: [],
origin: "bundled",
},
{
id: "demo-channel",
channels: ["telegram"],
providers: [],
cliBackends: [],
skills: [],
hooks: ["before-agent-start"],
contracts: {
tools: ["web-search"],
},
activation: {
onRoutes: ["webhook"],
onCommands: ["demo-tools"],
},
origin: "workspace",
},
],
diagnostics: [],
});
});
it("matches command triggers from activation metadata and legacy command aliases", () => {
expect(
resolveManifestActivationPluginIds({
trigger: {
kind: "command",
command: "memory",
},
}),
).toEqual(["memory-core"]);
expect(
resolveManifestActivationPluginIds({
trigger: {
kind: "command",
command: "pair",
},
}),
).toEqual(["device-pair"]);
expect(
resolveManifestActivationPluginIds({
trigger: {
kind: "command",
command: "demo-tools",
},
}),
).toEqual(["demo-channel"]);
});
it("matches provider, channel, and route triggers from manifest-owned metadata", () => {
expect(
resolveManifestActivationPluginIds({
trigger: {
kind: "provider",
provider: "openai",
},
}),
).toEqual(["openai"]);
expect(
resolveManifestActivationPluginIds({
trigger: {
kind: "provider",
provider: "openai-codex",
},
}),
).toEqual(["openai"]);
expect(
resolveManifestActivationPluginIds({
trigger: {
kind: "channel",
channel: "telegram",
},
}),
).toEqual(["demo-channel"]);
expect(
resolveManifestActivationPluginIds({
trigger: {
kind: "route",
route: "webhook",
},
}),
).toEqual(["demo-channel"]);
});
it("matches capability triggers from explicit hints or existing manifest ownership", () => {
expect(
resolveManifestActivationPluginIds({
trigger: {
kind: "capability",
capability: "provider",
},
}),
).toEqual(["openai"]);
expect(
resolveManifestActivationPluginIds({
trigger: {
kind: "capability",
capability: "tool",
},
}),
).toEqual(["demo-channel"]);
expect(
resolveManifestActivationPluginIds({
trigger: {
kind: "capability",
capability: "hook",
},
}),
).toEqual(["demo-channel"]);
});
});

View File

@@ -0,0 +1,118 @@
import { normalizeProviderId } from "../agents/provider-id.js";
import type { OpenClawConfig } from "../config/types.js";
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
import { loadPluginManifestRegistry, type PluginManifestRecord } from "./manifest-registry.js";
import type { PluginManifestActivationCapability } from "./manifest.js";
import type { PluginOrigin } from "./plugin-origin.types.js";
export type PluginActivationPlannerTrigger =
| { kind: "command"; command: string }
| { kind: "provider"; provider: string }
| { kind: "channel"; channel: string }
| { kind: "route"; route: string }
| { kind: "capability"; capability: PluginManifestActivationCapability };
export function resolveManifestActivationPluginIds(params: {
trigger: PluginActivationPlannerTrigger;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
origin?: PluginOrigin;
onlyPluginIds?: readonly string[];
}): string[] {
const onlyPluginIds =
params.onlyPluginIds && params.onlyPluginIds.length > 0
? new Set(params.onlyPluginIds.map((pluginId) => pluginId.trim()).filter(Boolean))
: null;
return [
...new Set(
loadPluginManifestRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
})
.plugins.filter(
(plugin) =>
(!params.origin || plugin.origin === params.origin) &&
(!onlyPluginIds || onlyPluginIds.has(plugin.id)) &&
matchesManifestActivationTrigger(plugin, params.trigger),
)
.map((plugin) => plugin.id),
),
].toSorted((left, right) => left.localeCompare(right));
}
function matchesManifestActivationTrigger(
plugin: PluginManifestRecord,
trigger: PluginActivationPlannerTrigger,
): boolean {
switch (trigger.kind) {
case "command":
return listActivationCommandIds(plugin).includes(normalizeCommandId(trigger.command));
case "provider":
return listActivationProviderIds(plugin).includes(normalizeProviderId(trigger.provider));
case "channel":
return listActivationChannelIds(plugin).includes(normalizeCommandId(trigger.channel));
case "route":
return listActivationRouteIds(plugin).includes(normalizeCommandId(trigger.route));
case "capability":
return hasActivationCapability(plugin, trigger.capability);
}
const unreachableTrigger: never = trigger;
return unreachableTrigger;
}
function listActivationCommandIds(plugin: PluginManifestRecord): string[] {
return [
...(plugin.activation?.onCommands ?? []),
...(plugin.commandAliases ?? []).flatMap((alias) => alias.cliCommand ?? alias.name),
]
.map(normalizeCommandId)
.filter(Boolean);
}
function listActivationProviderIds(plugin: PluginManifestRecord): string[] {
return [
...(plugin.activation?.onProviders ?? []),
...plugin.providers,
...(plugin.setup?.providers?.map((provider) => provider.id) ?? []),
]
.map((value) => normalizeProviderId(value))
.filter(Boolean);
}
function listActivationChannelIds(plugin: PluginManifestRecord): string[] {
return [...(plugin.activation?.onChannels ?? []), ...plugin.channels]
.map(normalizeCommandId)
.filter(Boolean);
}
function listActivationRouteIds(plugin: PluginManifestRecord): string[] {
return (plugin.activation?.onRoutes ?? []).map(normalizeCommandId).filter(Boolean);
}
function hasActivationCapability(
plugin: PluginManifestRecord,
capability: PluginManifestActivationCapability,
): boolean {
if (plugin.activation?.onCapabilities?.includes(capability)) {
return true;
}
switch (capability) {
case "provider":
return listActivationProviderIds(plugin).length > 0;
case "channel":
return listActivationChannelIds(plugin).length > 0;
case "tool":
return (plugin.contracts?.tools?.length ?? 0) > 0;
case "hook":
return plugin.hooks.length > 0;
}
const unreachableCapability: never = capability;
return unreachableCapability;
}
function normalizeCommandId(value: string | undefined): string {
return normalizeOptionalLowercaseString(value) ?? "";
}

View File

@@ -1,5 +1,6 @@
import { collectUniqueCommandDescriptors } from "../cli/program/command-descriptor-utils.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveManifestActivationPluginIds } from "./activation-planner.js";
import type { PluginLoadOptions } from "./loader.js";
import { loadOpenClawPluginCliRegistry, loadOpenClawPlugins } from "./loader.js";
import type { PluginRegistry } from "./registry.js";
@@ -22,6 +23,7 @@ export type PluginCliPublicLoadParams = {
env?: NodeJS.ProcessEnv;
loaderOptions?: PluginCliLoaderOptions;
logger?: PluginLogger;
primaryCommand?: string;
};
export type PluginCliLoadContext = PluginRuntimeLoadContext;
@@ -69,9 +71,33 @@ function mergeCliRegistrars(params: {
function buildPluginCliLoaderParams(
context: PluginCliLoadContext,
params?: { primaryCommand?: string },
loaderOptions?: PluginCliLoaderOptions,
) {
return buildPluginRuntimeLoadOptions(context, loaderOptions);
const onlyPluginIds = resolvePrimaryCommandPluginIds(context, params?.primaryCommand);
return buildPluginRuntimeLoadOptions(context, {
...loaderOptions,
...(onlyPluginIds.length > 0 ? { onlyPluginIds } : {}),
});
}
function resolvePrimaryCommandPluginIds(
context: PluginCliLoadContext,
primaryCommand: string | undefined,
): string[] {
const normalizedPrimary = primaryCommand?.trim();
if (!normalizedPrimary) {
return [];
}
return resolveManifestActivationPluginIds({
trigger: {
kind: "command",
command: normalizedPrimary,
},
config: context.activationSourceConfig,
workspaceDir: context.workspaceDir,
env: context.env,
});
}
export function resolvePluginCliLoadContext(params: {
@@ -88,23 +114,29 @@ export function resolvePluginCliLoadContext(params: {
export async function loadPluginCliMetadataRegistryWithContext(
context: PluginCliLoadContext,
params?: { primaryCommand?: string },
loaderOptions?: PluginCliLoaderOptions,
): Promise<PluginCliRegistryLoadResult> {
return {
...context,
registry: await loadOpenClawPluginCliRegistry(
buildPluginCliLoaderParams(context, loaderOptions),
buildPluginCliLoaderParams(context, params, loaderOptions),
),
};
}
export async function loadPluginCliCommandRegistryWithContext(params: {
context: PluginCliLoadContext;
primaryCommand?: string;
loaderOptions?: PluginCliLoaderOptions;
onMetadataFallbackError: (error: unknown) => void;
}): Promise<PluginCliRegistryLoadResult> {
const runtimeRegistry = loadOpenClawPlugins(
buildPluginCliLoaderParams(params.context, params.loaderOptions),
buildPluginCliLoaderParams(
params.context,
{ primaryCommand: params.primaryCommand },
params.loaderOptions,
),
);
if (!hasIgnoredAsyncPluginRegistration(runtimeRegistry)) {
@@ -116,7 +148,11 @@ export async function loadPluginCliCommandRegistryWithContext(params: {
try {
const metadataRegistry = await loadOpenClawPluginCliRegistry(
buildPluginCliLoaderParams(params.context, params.loaderOptions),
buildPluginCliLoaderParams(
params.context,
{ primaryCommand: params.primaryCommand },
params.loaderOptions,
),
);
return {
...params.context,
@@ -174,6 +210,7 @@ export async function loadPluginCliDescriptors(
});
const { registry } = await loadPluginCliMetadataRegistryWithContext(
context,
{ primaryCommand: params.primaryCommand },
params.loaderOptions,
);
return collectUniqueCommandDescriptors(
@@ -189,6 +226,7 @@ export async function loadPluginCliRegistrationEntries(params: {
env?: NodeJS.ProcessEnv;
loaderOptions?: PluginCliLoaderOptions;
logger?: PluginLogger;
primaryCommand?: string;
onMetadataFallbackError: (error: unknown) => void;
}): Promise<PluginCliCommandGroupEntry[]> {
const resolvedLogger = resolvePluginCliLogger(params.logger);
@@ -199,6 +237,7 @@ export async function loadPluginCliRegistrationEntries(params: {
});
const { config, workspaceDir, logger, registry } = await loadPluginCliCommandRegistryWithContext({
context,
primaryCommand: params.primaryCommand,
loaderOptions: params.loaderOptions,
onMetadataFallbackError: params.onMetadataFallbackError,
});

View File

@@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({
memoryListAction: vi.fn(),
loadOpenClawPluginCliRegistry: vi.fn(),
loadOpenClawPlugins: vi.fn(),
resolveManifestActivationPluginIds: vi.fn(),
applyPluginAutoEnable: vi.fn(),
loadConfig: vi.fn(),
readConfigFileSnapshot: vi.fn(),
@@ -19,6 +20,11 @@ vi.mock("./loader.js", () => ({
loadOpenClawPlugins: (...args: unknown[]) => mocks.loadOpenClawPlugins(...args),
}));
vi.mock("./activation-planner.js", () => ({
resolveManifestActivationPluginIds: (...args: unknown[]) =>
mocks.resolveManifestActivationPluginIds(...args),
}));
vi.mock("../config/plugin-auto-enable.js", () => ({
applyPluginAutoEnable: (...args: unknown[]) => mocks.applyPluginAutoEnable(...args),
}));
@@ -144,6 +150,8 @@ describe("registerPluginCliCommands", () => {
...createCliRegistry(),
diagnostics: [],
});
mocks.resolveManifestActivationPluginIds.mockReset();
mocks.resolveManifestActivationPluginIds.mockReturnValue([]);
mocks.applyPluginAutoEnable.mockReset();
mocks.applyPluginAutoEnable.mockImplementation(({ config }) => ({
config,
@@ -393,6 +401,7 @@ describe("registerPluginCliCommands", () => {
it("registers a selected plugin primary eagerly during lazy startup", async () => {
const program = createProgram();
program.exitOverride();
mocks.resolveManifestActivationPluginIds.mockReturnValue(["memory-core"]);
await registerPluginCliCommands(program, {} as OpenClawConfig, undefined, undefined, {
mode: "lazy",
@@ -400,6 +409,11 @@ describe("registerPluginCliCommands", () => {
});
expect(program.commands.filter((command) => command.name() === "memory")).toHaveLength(1);
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["memory-core"],
}),
);
await program.parseAsync(["memory", "list"], { from: "user" });
@@ -407,6 +421,22 @@ describe("registerPluginCliCommands", () => {
expect(mocks.memoryListAction).toHaveBeenCalledTimes(1);
});
it("keeps full CLI loading when primary command planning finds no plugin match", async () => {
const program = createProgram();
program.exitOverride();
await registerPluginCliCommands(program, {} as OpenClawConfig, undefined, undefined, {
mode: "lazy",
primary: "memory",
});
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith(
expect.not.objectContaining({
onlyPluginIds: expect.anything(),
}),
);
});
it("returns null for validated plugin CLI config when the snapshot is invalid", async () => {
mocks.readConfigFileSnapshot.mockResolvedValueOnce({
valid: false,

View File

@@ -44,11 +44,16 @@ export async function registerPluginCliCommands(
options?: RegisterPluginCliOptions,
) {
const mode = options?.mode ?? "eager";
const primary = options?.primary ?? null;
const primary = options?.primary ?? undefined;
await registerPluginCliCommandGroups(
program,
await loadPluginCliRegistrationEntriesWithDefaults({ cfg, env, loaderOptions }),
await loadPluginCliRegistrationEntriesWithDefaults({
cfg,
env,
loaderOptions,
primaryCommand: primary,
}),
{
mode,
primary,

View File

@@ -31,7 +31,7 @@ export async function registerPluginCliCommandGroups(
entries: readonly PluginCliCommandGroupEntry[],
params: {
mode: PluginCliCommandGroupMode;
primary: string | null;
primary?: string;
existingCommands: Set<string>;
logger: PluginLogger;
},