From a9c7c2e1edf56dcdbc9c6b31c3410cbbaa6f1d60 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 12 Apr 2026 09:07:47 +0100 Subject: [PATCH] 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 --- docs/plugins/architecture.md | 3 + docs/plugins/manifest.md | 7 + src/plugins/activation-planner.test.ts | 170 ++++++++++++++++++ src/plugins/activation-planner.ts | 118 ++++++++++++ src/plugins/cli-registry-loader.ts | 47 ++++- src/plugins/cli.test.ts | 30 ++++ src/plugins/cli.ts | 9 +- .../register-plugin-cli-command-groups.ts | 2 +- 8 files changed, 379 insertions(+), 7 deletions(-) create mode 100644 src/plugins/activation-planner.test.ts create mode 100644 src/plugins/activation-planner.ts diff --git a/docs/plugins/architecture.md b/docs/plugins/architecture.md index 406e450a3e2..468e498f2aa 100644 --- a/docs/plugins/architecture.md +++ b/docs/plugins/architecture.md @@ -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 diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index f1a6d424cd5..86fa25a102c 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -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 diff --git a/src/plugins/activation-planner.test.ts b/src/plugins/activation-planner.test.ts new file mode 100644 index 00000000000..0ae57bc9b11 --- /dev/null +++ b/src/plugins/activation-planner.test.ts @@ -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"]); + }); +}); diff --git a/src/plugins/activation-planner.ts b/src/plugins/activation-planner.ts new file mode 100644 index 00000000000..567dcef61b0 --- /dev/null +++ b/src/plugins/activation-planner.ts @@ -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) ?? ""; +} diff --git a/src/plugins/cli-registry-loader.ts b/src/plugins/cli-registry-loader.ts index 598552c5abe..4c6deb0ca40 100644 --- a/src/plugins/cli-registry-loader.ts +++ b/src/plugins/cli-registry-loader.ts @@ -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 { 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 { 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 { 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, }); diff --git a/src/plugins/cli.test.ts b/src/plugins/cli.test.ts index 4f5bf13ade9..d99efcd7d90 100644 --- a/src/plugins/cli.test.ts +++ b/src/plugins/cli.test.ts @@ -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, diff --git a/src/plugins/cli.ts b/src/plugins/cli.ts index 32200c143ce..ca75f7cd58f 100644 --- a/src/plugins/cli.ts +++ b/src/plugins/cli.ts @@ -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, diff --git a/src/plugins/register-plugin-cli-command-groups.ts b/src/plugins/register-plugin-cli-command-groups.ts index f09d6860718..31224081a12 100644 --- a/src/plugins/register-plugin-cli-command-groups.ts +++ b/src/plugins/register-plugin-cli-command-groups.ts @@ -31,7 +31,7 @@ export async function registerPluginCliCommandGroups( entries: readonly PluginCliCommandGroupEntry[], params: { mode: PluginCliCommandGroupMode; - primary: string | null; + primary?: string; existingCommands: Set; logger: PluginLogger; },