From 799a42bd13de586a1a7163cf0bd51673c31de253 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 23 Apr 2026 22:06:07 -0700 Subject: [PATCH] feat(plugins): expose activation plan reasons (#70943) --- docs/plugins/architecture-internals.md | 8 + docs/plugins/architecture.md | 20 ++ docs/plugins/manifest.md | 95 +++++---- docs/plugins/sdk-migration.md | 22 +++ src/plugins/activation-planner.test.ts | 173 ++++++++++++++++- src/plugins/activation-planner.ts | 258 +++++++++++++++++++------ src/plugins/manifest.ts | 16 +- 7 files changed, 481 insertions(+), 111 deletions(-) diff --git a/docs/plugins/architecture-internals.md b/docs/plugins/architecture-internals.md index c98900a057c..e1422b68c16 100644 --- a/docs/plugins/architecture-internals.md +++ b/docs/plugins/architecture-internals.md @@ -61,6 +61,14 @@ to narrow plugin loading before broader registry materialization: - explicit provider setup/runtime resolution narrows to plugins that own the requested provider id +The activation planner exposes both an ids-only API for existing callers and a +plan API for new diagnostics. Plan entries report why a plugin was selected, +separating explicit `activation.*` planner hints from manifest ownership +fallback such as `providers`, `channels`, `commandAliases`, `setup.providers`, +`contracts.tools`, and hooks. That reason split is the compatibility boundary: +existing plugin metadata keeps working, while new code can detect broad hints +or fallback behavior without changing runtime loading semantics. + Setup discovery now prefers descriptor-owned ids such as `setup.providers` and `setup.cliBackends` to narrow candidate plugins before it falls back to `setup-api` for plugins that still need setup-time runtime hooks. If more than diff --git a/docs/plugins/architecture.md b/docs/plugins/architecture.md index 4de8c088eef..071a5a0cc98 100644 --- a/docs/plugins/architecture.md +++ b/docs/plugins/architecture.md @@ -153,6 +153,26 @@ The important design boundary: That split lets OpenClaw validate config, explain missing/disabled plugins, and build UI/schema hints before the full runtime is active. +### Activation planning + +Activation planning is part of the control plane. Callers can ask which plugins +are relevant to a concrete command, provider, channel, route, agent harness, or +capability before loading broader runtime registries. + +The planner keeps current manifest behavior compatible: + +- `activation.*` fields are explicit planner hints +- `providers`, `channels`, `commandAliases`, `setup.providers`, + `contracts.tools`, and hooks remain manifest ownership fallback +- the ids-only planner API stays available for existing callers +- the plan API reports reason labels so diagnostics can distinguish explicit + hints from ownership fallback + +Do not treat `activation` as a lifecycle hook or a replacement for +`register(...)`. It is metadata used to narrow loading. Prefer ownership fields +when they already describe the relationship; use `activation` only for extra +planner hints. + ### Channel plugins and the shared message tool Channel plugins do not need to register a separate send/edit/react tool for diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index e3b4d3202bb..def28efcfeb 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -152,7 +152,7 @@ or npm install metadata. Those belong in your plugin code and `package.json`. | `providerAuthAliases` | No | `Record` | Provider ids that should reuse another provider id for auth lookup, for example a coding provider that shares the base provider API key and auth profiles. | | `channelEnvVars` | No | `Record` | Cheap channel env metadata that OpenClaw can inspect without loading plugin code. Use this for env-driven channel setup or auth surfaces that generic startup/config helpers should see. | | `providerAuthChoices` | No | `object[]` | Cheap auth-choice metadata for onboarding pickers, preferred-provider resolution, and simple CLI flag wiring. | -| `activation` | No | `object` | Cheap activation hints for provider, command, channel, route, and capability-triggered loading. Metadata only; plugin runtime still owns actual behavior. | +| `activation` | No | `object` | Cheap activation planner metadata for provider, command, channel, route, and capability-triggered loading. Metadata only; plugin runtime still owns actual behavior. | | `setup` | No | `object` | Cheap setup/onboarding descriptors that discovery and setup surfaces can inspect without loading plugin runtime. | | `qaRunners` | No | `object[]` | Cheap QA runner descriptors used by the shared `openclaw qa` host before plugin runtime loads. | | `contracts` | No | `object` | Static bundled capability snapshot for external auth hooks, speech, realtime transcription, realtime voice, media-understanding, image-generation, music-generation, video-generation, web-fetch, web search, and tool ownership. | @@ -215,7 +215,62 @@ uses this metadata for diagnostics without importing plugin runtime code. ## activation reference Use `activation` when the plugin can cheaply declare which control-plane events -should activate it later. +should include it in an activation/load plan. + +This block is planner metadata, not a lifecycle API. It does not register +runtime behavior, does not replace `register(...)`, and does not promise that +plugin code has already executed. The activation planner uses these fields to +narrow candidate plugins before falling back to existing manifest ownership +metadata such as `providers`, `channels`, `commandAliases`, `setup.providers`, +`contracts.tools`, and hooks. + +Prefer the narrowest metadata that already describes ownership. Use +`providers`, `channels`, `commandAliases`, setup descriptors, or `contracts` +when those fields express the relationship. Use `activation` for extra planner +hints that cannot be represented by those ownership fields. + +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 usually only costs performance; it should not +change correctness while legacy manifest ownership fallbacks still exist. + +```json +{ + "activation": { + "onProviders": ["openai"], + "onCommands": ["models"], + "onChannels": ["web"], + "onRoutes": ["gateway-webhook"], + "onCapabilities": ["provider", "tool"] + } +} +``` + +| Field | Required | Type | What it means | +| ---------------- | -------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `onProviders` | No | `string[]` | Provider ids that should include this plugin in activation/load plans. | +| `onCommands` | No | `string[]` | Command ids that should include this plugin in activation/load plans. | +| `onChannels` | No | `string[]` | Channel ids that should include this plugin in activation/load plans. | +| `onRoutes` | No | `string[]` | Route kinds that should include this plugin in activation/load plans. | +| `onCapabilities` | No | `Array<"provider" \| "channel" \| "tool" \| "hook">` | Broad capability hints used by control-plane activation planning. Prefer narrower fields when possible. | + +Current live consumers: + +- command-triggered CLI planning falls back to legacy + `commandAliases[].cliCommand` or `commandAliases[].name` +- channel-triggered setup/channel planning falls back to legacy `channels[]` + ownership when explicit channel activation metadata is missing +- provider-triggered setup/runtime planning falls back to legacy + `providers[]` and top-level `cliBackends[]` ownership when explicit provider + activation metadata is missing + +Planner diagnostics can distinguish explicit activation hints from manifest +ownership fallback. For example, `activation-command-hint` means +`activation.onCommands` matched, while `manifest-command-alias` means the +planner used `commandAliases` ownership instead. These reason labels are for +host diagnostics and tests; plugin authors should keep declaring the metadata +that best describes ownership. ## qaRunners reference @@ -240,42 +295,6 @@ runtime still owns actual CLI registration through a lightweight | `commandName` | Yes | `string` | Subcommand mounted beneath `openclaw qa`, for example `matrix`. | | `description` | No | `string` | Fallback help text used when the shared host needs a stub command. | -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 usually only costs performance; it should not -change correctness while legacy manifest ownership fallbacks still exist. - -```json -{ - "activation": { - "onProviders": ["openai"], - "onCommands": ["models"], - "onChannels": ["web"], - "onRoutes": ["gateway-webhook"], - "onCapabilities": ["provider", "tool"] - } -} -``` - -| Field | Required | Type | What it means | -| ---------------- | -------- | ---------------------------------------------------- | ----------------------------------------------------------------- | -| `onProviders` | No | `string[]` | Provider ids that should activate this plugin when requested. | -| `onCommands` | No | `string[]` | Command ids that should activate this plugin. | -| `onChannels` | No | `string[]` | Channel ids that should activate this plugin. | -| `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. | - -Current live consumers: - -- command-triggered CLI planning falls back to legacy - `commandAliases[].cliCommand` or `commandAliases[].name` -- channel-triggered setup/channel planning falls back to legacy `channels[]` - ownership when explicit channel activation metadata is missing -- provider-triggered setup/runtime planning falls back to legacy - `providers[]` and top-level `cliBackends[]` ownership when explicit provider - activation metadata is missing - ## setup reference Use `setup` when setup and onboarding surfaces need cheap plugin-owned metadata diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index f6d18fc4475..59d2ddcff6e 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -28,6 +28,12 @@ Both surfaces are now **deprecated**. They still work at runtime, but new plugins must not use them, and existing plugins should migrate before the next major release removes them. +OpenClaw does not remove or reinterpret documented plugin behavior in the same +change that introduces a replacement. Breaking contract changes must first go +through a compatibility adapter, diagnostics, docs, and a deprecation window. +That applies to SDK imports, manifest fields, setup APIs, hooks, and runtime +registration behavior. + The backwards-compatibility layer will be removed in a future major release. Plugins that still import from these surfaces will break when that happens. @@ -62,6 +68,22 @@ Current bundled provider examples: - OpenRouter keeps provider builder and onboarding/config helpers in its own `api.ts` +## Compatibility policy + +For external plugins, compatibility work follows this order: + +1. add the new contract +2. keep the old behavior wired through a compatibility adapter +3. emit a diagnostic or warning that names the old path and replacement +4. cover both paths in tests +5. document the deprecation and migration path +6. remove only after the announced migration window, usually in a major release + +If a manifest field is still accepted, plugin authors can keep using it until +the docs and diagnostics say otherwise. New code should prefer the documented +replacement, but existing plugins should not break during ordinary minor +releases. + ## How to migrate diff --git a/src/plugins/activation-planner.test.ts b/src/plugins/activation-planner.test.ts index 083a0bbdd5f..b5be07c4b5c 100644 --- a/src/plugins/activation-planner.test.ts +++ b/src/plugins/activation-planner.test.ts @@ -9,10 +9,12 @@ vi.mock("./manifest-registry.js", () => ({ })); let resolveManifestActivationPluginIds: typeof import("./activation-planner.js").resolveManifestActivationPluginIds; +let resolveManifestActivationPlan: typeof import("./activation-planner.js").resolveManifestActivationPlan; -describe("resolveManifestActivationPluginIds", () => { +describe("activation planner", () => { beforeAll(async () => { - ({ resolveManifestActivationPluginIds } = await import("./activation-planner.js")); + ({ resolveManifestActivationPlan, resolveManifestActivationPluginIds } = + await import("./activation-planner.js")); }); beforeEach(() => { @@ -75,7 +77,7 @@ describe("resolveManifestActivationPluginIds", () => { }); }); - it("matches command triggers from activation metadata and legacy command aliases", () => { + it("keeps ids-only command planning stable", () => { expect( resolveManifestActivationPluginIds({ trigger: { @@ -104,7 +106,7 @@ describe("resolveManifestActivationPluginIds", () => { ).toEqual(["demo-channel"]); }); - it("matches provider, agent harness, channel, and route triggers from manifest-owned metadata", () => { + it("keeps ids-only provider, agent harness, channel, and route planning stable", () => { expect( resolveManifestActivationPluginIds({ trigger: { @@ -151,7 +153,7 @@ describe("resolveManifestActivationPluginIds", () => { ).toEqual(["demo-channel"]); }); - it("matches capability triggers from explicit hints or existing manifest ownership", () => { + it("keeps ids-only capability planning stable", () => { expect( resolveManifestActivationPluginIds({ trigger: { @@ -180,6 +182,167 @@ describe("resolveManifestActivationPluginIds", () => { ).toEqual(["demo-channel"]); }); + it("returns a richer activation plan with planner-hint reasons", () => { + expect( + resolveManifestActivationPlan({ + trigger: { + kind: "command", + command: "demo-tools", + }, + }), + ).toMatchObject({ + pluginIds: ["demo-channel"], + entries: [ + { + pluginId: "demo-channel", + origin: "workspace", + reasons: ["activation-command-hint"], + }, + ], + diagnostics: [], + }); + + expect( + resolveManifestActivationPlan({ + trigger: { + kind: "agentHarness", + runtime: "codex", + }, + }).entries, + ).toEqual([ + { + pluginId: "openai", + origin: "bundled", + reasons: ["activation-agent-harness-hint"], + }, + ]); + + expect( + resolveManifestActivationPlan({ + trigger: { + kind: "route", + route: "webhook", + }, + }).entries, + ).toEqual([ + { + pluginId: "demo-channel", + origin: "workspace", + reasons: ["activation-route-hint"], + }, + ]); + }); + + it("returns manifest-owner reasons when activation hints are absent", () => { + expect( + resolveManifestActivationPlan({ + trigger: { + kind: "provider", + provider: "openai", + }, + }).entries, + ).toEqual([ + { + pluginId: "openai", + origin: "bundled", + reasons: ["manifest-provider-owner"], + }, + ]); + + expect( + resolveManifestActivationPlan({ + trigger: { + kind: "provider", + provider: "openai-codex", + }, + }).entries, + ).toEqual([ + { + pluginId: "openai", + origin: "bundled", + reasons: ["manifest-setup-provider-owner"], + }, + ]); + + expect( + resolveManifestActivationPlan({ + trigger: { + kind: "channel", + channel: "telegram", + }, + }).entries, + ).toEqual([ + { + pluginId: "demo-channel", + origin: "workspace", + reasons: ["manifest-channel-owner"], + }, + ]); + }); + + it("returns capability reasons from explicit hints and manifest ownership", () => { + mocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "explicit-provider", + providers: [], + channels: [], + cliBackends: [], + skills: [], + hooks: [], + activation: { + onCapabilities: ["provider"], + onProviders: ["custom-provider"], + }, + origin: "workspace", + }, + { + id: "owned-tool", + providers: [], + channels: [], + cliBackends: [], + skills: [], + hooks: [], + contracts: { + tools: ["custom-tool"], + }, + origin: "workspace", + }, + ], + diagnostics: [], + }); + + expect( + resolveManifestActivationPlan({ + trigger: { + kind: "capability", + capability: "provider", + }, + }).entries, + ).toEqual([ + { + pluginId: "explicit-provider", + origin: "workspace", + reasons: ["activation-capability-hint", "activation-provider-hint"], + }, + ]); + + expect( + resolveManifestActivationPlan({ + trigger: { + kind: "capability", + capability: "tool", + }, + }).entries, + ).toEqual([ + { + pluginId: "owned-tool", + origin: "workspace", + reasons: ["manifest-tool-contract"], + }, + ]); + }); + it("treats explicit empty plugin scopes as scoped-empty", () => { expect( resolveManifestActivationPluginIds({ diff --git a/src/plugins/activation-planner.ts b/src/plugins/activation-planner.ts index 2206c689a7e..f163d053754 100644 --- a/src/plugins/activation-planner.ts +++ b/src/plugins/activation-planner.ts @@ -2,6 +2,7 @@ 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 { PluginDiagnostic } from "./manifest-types.js"; import type { PluginManifestActivationCapability } from "./manifest.js"; import type { PluginOrigin } from "./plugin-origin.types.js"; import { createPluginIdScopeSet, normalizePluginIdScope } from "./plugin-scope.js"; @@ -14,7 +15,40 @@ export type PluginActivationPlannerTrigger = | { kind: "route"; route: string } | { kind: "capability"; capability: PluginManifestActivationCapability }; -export function resolveManifestActivationPluginIds(params: { +export type PluginActivationPlannerHintReason = + | "activation-agent-harness-hint" + | "activation-capability-hint" + | "activation-channel-hint" + | "activation-command-hint" + | "activation-provider-hint" + | "activation-route-hint"; + +export type PluginActivationPlannerManifestReason = + | "manifest-channel-owner" + | "manifest-command-alias" + | "manifest-hook-owner" + | "manifest-provider-owner" + | "manifest-setup-provider-owner" + | "manifest-tool-contract"; + +export type PluginActivationPlannerReason = + | PluginActivationPlannerHintReason + | PluginActivationPlannerManifestReason; + +export type PluginActivationPlanEntry = { + pluginId: string; + origin: PluginOrigin; + reasons: readonly PluginActivationPlannerReason[]; +}; + +export type PluginActivationPlan = { + trigger: PluginActivationPlannerTrigger; + pluginIds: readonly string[]; + entries: readonly PluginActivationPlanEntry[]; + diagnostics: readonly PluginDiagnostic[]; +}; + +type ResolveManifestActivationPlanParams = { trigger: PluginActivationPlannerTrigger; config?: OpenClawConfig; workspaceDir?: string; @@ -22,104 +56,208 @@ export function resolveManifestActivationPluginIds(params: { cache?: boolean; origin?: PluginOrigin; onlyPluginIds?: readonly string[]; -}): string[] { - const onlyPluginIdSet = createPluginIdScopeSet(normalizePluginIdScope(params.onlyPluginIds)); +}; - return [ - ...new Set( - loadPluginManifestRegistry({ - config: params.config, - workspaceDir: params.workspaceDir, - env: params.env, - cache: params.cache, - }) - .plugins.filter( - (plugin) => - (!params.origin || plugin.origin === params.origin) && - (!onlyPluginIdSet || onlyPluginIdSet.has(plugin.id)) && - matchesManifestActivationTrigger(plugin, params.trigger), - ) - .map((plugin) => plugin.id), - ), - ].toSorted((left, right) => left.localeCompare(right)); +export function resolveManifestActivationPlan( + params: ResolveManifestActivationPlanParams, +): PluginActivationPlan { + const onlyPluginIdSet = createPluginIdScopeSet(normalizePluginIdScope(params.onlyPluginIds)); + const registry = loadPluginManifestRegistry({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + cache: params.cache, + }); + const entries = registry.plugins + .flatMap((plugin) => { + if (params.origin && plugin.origin !== params.origin) { + return []; + } + if (onlyPluginIdSet && !onlyPluginIdSet.has(plugin.id)) { + return []; + } + const reasons = listManifestActivationTriggerReasons(plugin, params.trigger); + if (reasons.length === 0) { + return []; + } + return [ + { + pluginId: plugin.id, + origin: plugin.origin, + reasons, + } satisfies PluginActivationPlanEntry, + ]; + }) + .toSorted((left, right) => left.pluginId.localeCompare(right.pluginId)); + + return { + trigger: params.trigger, + pluginIds: [...new Set(entries.map((entry) => entry.pluginId))], + entries, + diagnostics: registry.diagnostics, + }; } -function matchesManifestActivationTrigger( +export function resolveManifestActivationPluginIds( + params: ResolveManifestActivationPlanParams, +): string[] { + return [...resolveManifestActivationPlan(params).pluginIds]; +} + +function listManifestActivationTriggerReasons( plugin: PluginManifestRecord, trigger: PluginActivationPlannerTrigger, -): boolean { +): PluginActivationPlannerReason[] { switch (trigger.kind) { case "command": - return listActivationCommandIds(plugin).includes(normalizeCommandId(trigger.command)); + return listCommandTriggerReasons(plugin, normalizeCommandId(trigger.command)); case "provider": - return listActivationProviderIds(plugin).includes(normalizeProviderId(trigger.provider)); + return listProviderTriggerReasons(plugin, normalizeProviderId(trigger.provider)); case "agentHarness": - return listActivationAgentHarnessIds(plugin).includes(normalizeCommandId(trigger.runtime)); + return listAgentHarnessTriggerReasons(plugin, normalizeCommandId(trigger.runtime)); case "channel": - return listActivationChannelIds(plugin).includes(normalizeCommandId(trigger.channel)); + return listChannelTriggerReasons(plugin, normalizeCommandId(trigger.channel)); case "route": - return listActivationRouteIds(plugin).includes(normalizeCommandId(trigger.route)); + return listRouteTriggerReasons(plugin, normalizeCommandId(trigger.route)); case "capability": - return hasActivationCapability(plugin, trigger.capability); + return listCapabilityTriggerReasons(plugin, trigger.capability); } const unreachableTrigger: never = trigger; return unreachableTrigger; } -function listActivationAgentHarnessIds(plugin: PluginManifestRecord): string[] { - return [...(plugin.activation?.onAgentHarnesses ?? [])].map(normalizeCommandId).filter(Boolean); +function listAgentHarnessTriggerReasons( + plugin: PluginManifestRecord, + runtime: string, +): PluginActivationPlannerReason[] { + return listHasNormalizedValue(plugin.activation?.onAgentHarnesses, runtime, normalizeCommandId) + ? ["activation-agent-harness-hint"] + : []; } -function listActivationCommandIds(plugin: PluginManifestRecord): string[] { - return [ - ...(plugin.activation?.onCommands ?? []), - ...(plugin.commandAliases ?? []).flatMap((alias) => alias.cliCommand ?? alias.name), - ] - .map(normalizeCommandId) - .filter(Boolean); +function listCommandTriggerReasons( + plugin: PluginManifestRecord, + command: string, +): PluginActivationPlannerReason[] { + return dedupeReasons([ + listHasNormalizedValue(plugin.activation?.onCommands, command, normalizeCommandId) + ? "activation-command-hint" + : null, + listHasNormalizedValue( + (plugin.commandAliases ?? []).flatMap((alias) => alias.cliCommand ?? alias.name), + command, + normalizeCommandId, + ) + ? "manifest-command-alias" + : null, + ]); } -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 listProviderTriggerReasons( + plugin: PluginManifestRecord, + provider: string, +): PluginActivationPlannerReason[] { + return dedupeReasons([ + listHasNormalizedValue(plugin.activation?.onProviders, provider, normalizeProviderId) + ? "activation-provider-hint" + : null, + listHasNormalizedValue(plugin.providers, provider, normalizeProviderId) + ? "manifest-provider-owner" + : null, + listHasNormalizedValue( + plugin.setup?.providers?.map((setupProvider) => setupProvider.id), + provider, + normalizeProviderId, + ) + ? "manifest-setup-provider-owner" + : null, + ]); } -function listActivationChannelIds(plugin: PluginManifestRecord): string[] { - return [...(plugin.activation?.onChannels ?? []), ...plugin.channels] - .map(normalizeCommandId) - .filter(Boolean); +function listChannelTriggerReasons( + plugin: PluginManifestRecord, + channel: string, +): PluginActivationPlannerReason[] { + return dedupeReasons([ + listHasNormalizedValue(plugin.activation?.onChannels, channel, normalizeCommandId) + ? "activation-channel-hint" + : null, + listHasNormalizedValue(plugin.channels, channel, normalizeCommandId) + ? "manifest-channel-owner" + : null, + ]); } -function listActivationRouteIds(plugin: PluginManifestRecord): string[] { - return (plugin.activation?.onRoutes ?? []).map(normalizeCommandId).filter(Boolean); +function listRouteTriggerReasons( + plugin: PluginManifestRecord, + route: string, +): PluginActivationPlannerReason[] { + return listHasNormalizedValue(plugin.activation?.onRoutes, route, normalizeCommandId) + ? ["activation-route-hint"] + : []; } -function hasActivationCapability( +function listCapabilityTriggerReasons( plugin: PluginManifestRecord, capability: PluginManifestActivationCapability, -): boolean { - if (plugin.activation?.onCapabilities?.includes(capability)) { - return true; - } +): PluginActivationPlannerReason[] { switch (capability) { case "provider": - return listActivationProviderIds(plugin).length > 0; + return dedupeReasons([ + plugin.activation?.onCapabilities?.includes(capability) + ? "activation-capability-hint" + : null, + hasValues(plugin.activation?.onProviders) ? "activation-provider-hint" : null, + hasValues(plugin.providers) ? "manifest-provider-owner" : null, + hasValues(plugin.setup?.providers) ? "manifest-setup-provider-owner" : null, + ]); case "channel": - return listActivationChannelIds(plugin).length > 0; + return dedupeReasons([ + plugin.activation?.onCapabilities?.includes(capability) + ? "activation-capability-hint" + : null, + hasValues(plugin.activation?.onChannels) ? "activation-channel-hint" : null, + hasValues(plugin.channels) ? "manifest-channel-owner" : null, + ]); case "tool": - return (plugin.contracts?.tools?.length ?? 0) > 0; + return dedupeReasons([ + plugin.activation?.onCapabilities?.includes(capability) + ? "activation-capability-hint" + : null, + hasValues(plugin.contracts?.tools) ? "manifest-tool-contract" : null, + ]); case "hook": - return plugin.hooks.length > 0; + return dedupeReasons([ + plugin.activation?.onCapabilities?.includes(capability) + ? "activation-capability-hint" + : null, + hasValues(plugin.hooks) ? "manifest-hook-owner" : null, + ]); } const unreachableCapability: never = capability; return unreachableCapability; } +function listHasNormalizedValue( + values: readonly string[] | undefined, + expected: string, + normalize: (value: string) => string, +): boolean { + return values?.some((value) => normalize(value) === expected) ?? false; +} + +function hasValues(values: readonly unknown[] | undefined): boolean { + return (values?.length ?? 0) > 0; +} + +function dedupeReasons( + reasons: readonly (PluginActivationPlannerReason | null)[], +): PluginActivationPlannerReason[] { + return [ + ...new Set(reasons.filter((reason): reason is PluginActivationPlannerReason => !!reason)), + ]; +} + function normalizeCommandId(value: string | undefined): string { return normalizeOptionalLowercaseString(value) ?? ""; } diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index eaaa01eed5c..b5aa4e430cd 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -57,19 +57,19 @@ export type PluginManifestActivationCapability = "provider" | "channel" | "tool" export type PluginManifestActivation = { /** - * Provider ids that should activate this plugin when explicitly requested. - * This is metadata only; runtime loading still happens through the loader. + * Provider ids that should include this plugin in activation/load plans. + * This is planner metadata only; runtime behavior still comes from register(). */ onProviders?: string[]; - /** Agent harness runtime ids that should activate this plugin. */ + /** Agent harness runtime ids that should include this plugin in activation/load plans. */ onAgentHarnesses?: string[]; - /** Command ids that should activate this plugin. */ + /** Command ids that should include this plugin in activation/load plans. */ onCommands?: string[]; - /** Channel ids that should activate this plugin. */ + /** Channel ids that should include this plugin in activation/load plans. */ onChannels?: string[]; - /** Route kinds that should activate this plugin. */ + /** Route kinds that should include this plugin in activation/load plans. */ onRoutes?: string[]; - /** Cheap capability hints used by future activation planning. */ + /** Broad capability hints for activation/load plans. Prefer narrower ownership metadata. */ onCapabilities?: PluginManifestActivationCapability[]; }; @@ -205,7 +205,7 @@ export type PluginManifest = { * and non-runtime auth-choice routing before provider runtime loads. */ providerAuthChoices?: PluginManifestProviderAuthChoice[]; - /** Cheap activation hints exposed before plugin runtime loads. */ + /** Cheap activation planner metadata exposed before plugin runtime loads. */ activation?: PluginManifestActivation; /** Cheap setup/onboarding metadata exposed before plugin runtime loads. */ setup?: PluginManifestSetup;