mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-21 13:44:03 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
170
src/plugins/activation-planner.test.ts
Normal file
170
src/plugins/activation-planner.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
118
src/plugins/activation-planner.ts
Normal file
118
src/plugins/activation-planner.ts
Normal 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) ?? "";
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -31,7 +31,7 @@ export async function registerPluginCliCommandGroups(
|
||||
entries: readonly PluginCliCommandGroupEntry[],
|
||||
params: {
|
||||
mode: PluginCliCommandGroupMode;
|
||||
primary: string | null;
|
||||
primary?: string;
|
||||
existingCommands: Set<string>;
|
||||
logger: PluginLogger;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user