Skip to content

Commit f69e89a

Browse files
committed
feat(plugins): narrow explicit provider loads from manifests
1 parent b22bbf5 commit f69e89a

4 files changed

Lines changed: 186 additions & 31 deletions

File tree

docs/plugins/architecture.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -527,9 +527,12 @@ actual behavior such as hooks, tools, commands, or provider flows.
527527
Optional manifest `activation` and `setup` blocks stay on the control plane.
528528
They are metadata-only descriptors for activation planning and setup discovery;
529529
they do not replace runtime registration, `register(...)`, or `setupEntry`.
530-
The first activation consumer now uses manifest command hints to narrow CLI
531-
plugin loading when a primary command is known, instead of always loading every
532-
CLI-capable plugin up front.
530+
The first live activation consumers now use manifest command and provider hints
531+
to narrow plugin loading before broader registry materialization:
532+
533+
- CLI loading narrows to plugins that own the requested primary command
534+
- explicit provider setup/runtime resolution narrows to plugins that own the
535+
requested provider id
533536

534537
Setup discovery now prefers descriptor-owned ids such as `setup.providers` and
535538
`setup.cliBackends` to narrow candidate plugins before it falls back to

docs/plugins/manifest.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -222,8 +222,8 @@ should activate it later.
222222
This block is metadata only. It does not register runtime behavior, and it does
223223
not replace `register(...)`, `setupEntry`, or other runtime/plugin entrypoints.
224224
Current consumers use it as a narrowing hint before broader plugin loading, so
225-
missing activation metadata only costs performance; it should not change
226-
correctness.
225+
missing activation metadata usually only costs performance; it should not
226+
change correctness while legacy manifest ownership fallbacks still exist.
227227

228228
```json
229229
{
@@ -245,9 +245,13 @@ correctness.
245245
| `onRoutes` | No | `string[]` | Route kinds that should activate this plugin. |
246246
| `onCapabilities` | No | `Array<"provider" \| "channel" \| "tool" \| "hook">` | Broad capability hints used by control-plane activation planning. |
247247

248-
For command-triggered planning specifically, OpenClaw still falls back to
249-
legacy `commandAliases[].cliCommand` or `commandAliases[].name` when a plugin
250-
has not added explicit `activation.onCommands` metadata yet.
248+
Current live consumers:
249+
250+
- command-triggered CLI planning falls back to legacy
251+
`commandAliases[].cliCommand` or `commandAliases[].name`
252+
- provider-triggered setup/runtime planning falls back to legacy
253+
`providers[]` and top-level `cliBackends[]` ownership when explicit provider
254+
activation metadata is missing
251255

252256
## setup reference
253257

src/plugins/providers.runtime.ts

Lines changed: 75 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { withActivatedPluginIds } from "./activation-context.js";
22
import { resolveBundledPluginCompatibleActivationInputs } from "./activation-context.js";
3+
import { resolveManifestActivationPluginIds } from "./activation-planner.js";
34
import {
45
isPluginRegistryLoadInFlight,
56
loadOpenClawPlugins,
@@ -21,6 +22,54 @@ import {
2122
} from "./runtime/load-context.js";
2223
import type { ProviderPlugin } from "./types.js";
2324

25+
function dedupeSortedPluginIds(values: Iterable<string>): string[] {
26+
return [...new Set(values)].toSorted((left, right) => left.localeCompare(right));
27+
}
28+
29+
function resolveExplicitProviderOwnerPluginIds(params: {
30+
providerRefs: readonly string[];
31+
config?: PluginLoadOptions["config"];
32+
workspaceDir?: string;
33+
env?: PluginLoadOptions["env"];
34+
}): string[] {
35+
return dedupeSortedPluginIds(
36+
params.providerRefs.flatMap((provider) => {
37+
const plannedPluginIds = resolveManifestActivationPluginIds({
38+
trigger: {
39+
kind: "provider",
40+
provider,
41+
},
42+
config: params.config,
43+
workspaceDir: params.workspaceDir,
44+
env: params.env,
45+
});
46+
if (plannedPluginIds.length > 0) {
47+
return plannedPluginIds;
48+
}
49+
// Keep legacy provider/CLI-backend ownership working until every owner is
50+
// expressible through activation descriptors.
51+
return (
52+
resolveOwningPluginIdsForProvider({
53+
provider,
54+
config: params.config,
55+
workspaceDir: params.workspaceDir,
56+
env: params.env,
57+
}) ?? []
58+
);
59+
}),
60+
);
61+
}
62+
63+
function mergeExplicitOwnerPluginIds(
64+
providerPluginIds: readonly string[],
65+
explicitOwnerPluginIds: readonly string[],
66+
): string[] {
67+
if (explicitOwnerPluginIds.length === 0) {
68+
return [...providerPluginIds];
69+
}
70+
return dedupeSortedPluginIds([...providerPluginIds, ...explicitOwnerPluginIds]);
71+
}
72+
2473
function resolvePluginProviderLoadBase(params: {
2574
config?: PluginLoadOptions["config"];
2675
workspaceDir?: string;
@@ -32,19 +81,12 @@ function resolvePluginProviderLoadBase(params: {
3281
const env = params.env ?? process.env;
3382
const workspaceDir = params.workspaceDir ?? getActivePluginRegistryWorkspaceDir();
3483
const providerOwnedPluginIds = params.providerRefs?.length
35-
? [
36-
...new Set(
37-
params.providerRefs.flatMap(
38-
(provider) =>
39-
resolveOwningPluginIdsForProvider({
40-
provider,
41-
config: params.config,
42-
workspaceDir,
43-
env,
44-
}) ?? [],
45-
),
46-
),
47-
]
84+
? resolveExplicitProviderOwnerPluginIds({
85+
providerRefs: params.providerRefs,
86+
config: params.config,
87+
workspaceDir,
88+
env,
89+
})
4890
: [];
4991
const modelOwnedPluginIds = params.modelRefs?.length
5092
? resolveOwningPluginIdsForModelRefs({
@@ -68,14 +110,19 @@ function resolvePluginProviderLoadBase(params: {
68110
]),
69111
].toSorted((left, right) => left.localeCompare(right))
70112
: undefined;
113+
const explicitOwnerPluginIds = dedupeSortedPluginIds([
114+
...providerOwnedPluginIds,
115+
...modelOwnedPluginIds,
116+
]);
71117
const runtimeConfig = withActivatedPluginIds({
72118
config: params.config,
73-
pluginIds: [...providerOwnedPluginIds, ...modelOwnedPluginIds],
119+
pluginIds: explicitOwnerPluginIds,
74120
});
75121
return {
76122
env,
77123
workspaceDir,
78124
requestedPluginIds,
125+
explicitOwnerPluginIds,
79126
runtimeConfig,
80127
};
81128
}
@@ -92,13 +139,15 @@ function resolveSetupProviderPluginLoadState(
92139
includeUntrustedWorkspacePlugins: params.includeUntrustedWorkspacePlugins,
93140
});
94141
if (providerPluginIds.length === 0) {
95-
return undefined;
142+
if (base.explicitOwnerPluginIds.length === 0) {
143+
return undefined;
144+
}
96145
}
97146
const loadOptions = buildPluginRuntimeLoadOptionsFromValues(
98147
{
99148
config: withActivatedPluginIds({
100149
config: base.runtimeConfig,
101-
pluginIds: providerPluginIds,
150+
pluginIds: mergeExplicitOwnerPluginIds(providerPluginIds, base.explicitOwnerPluginIds),
102151
}),
103152
activationSourceConfig: base.runtimeConfig,
104153
autoEnabledReasons: {},
@@ -107,7 +156,7 @@ function resolveSetupProviderPluginLoadState(
107156
logger: createPluginRuntimeLoaderLogger(),
108157
},
109158
{
110-
onlyPluginIds: providerPluginIds,
159+
onlyPluginIds: mergeExplicitOwnerPluginIds(providerPluginIds, base.explicitOwnerPluginIds),
111160
pluginSdkResolution: params.pluginSdkResolution,
112161
cache: params.cache ?? false,
113162
activate: params.activate ?? false,
@@ -140,12 +189,15 @@ function resolveRuntimeProviderPluginLoadState(
140189
env: base.env,
141190
})
142191
: activation.config;
143-
const providerPluginIds = resolveEnabledProviderPluginIds({
144-
config,
145-
workspaceDir: base.workspaceDir,
146-
env: base.env,
147-
onlyPluginIds: base.requestedPluginIds,
148-
});
192+
const providerPluginIds = mergeExplicitOwnerPluginIds(
193+
resolveEnabledProviderPluginIds({
194+
config,
195+
workspaceDir: base.workspaceDir,
196+
env: base.env,
197+
onlyPluginIds: base.requestedPluginIds,
198+
}),
199+
base.explicitOwnerPluginIds,
200+
);
149201
const loadOptions = buildPluginRuntimeLoadOptionsFromValues(
150202
{
151203
config,

src/plugins/providers.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ function createManifestProviderPlugin(params: {
3232
origin?: "bundled" | "workspace";
3333
enabledByDefault?: boolean;
3434
modelSupport?: { modelPrefixes?: string[]; modelPatterns?: string[] };
35+
activation?: PluginManifestRecord["activation"];
36+
setup?: PluginManifestRecord["setup"];
3537
}): PluginManifestRecord {
3638
return {
3739
id: params.id,
@@ -40,6 +42,8 @@ function createManifestProviderPlugin(params: {
4042
providers: params.providerIds,
4143
cliBackends: params.cliBackends ?? [],
4244
modelSupport: params.modelSupport,
45+
activation: params.activation,
46+
setup: params.setup,
4347
skills: [],
4448
hooks: [],
4549
origin: params.origin ?? "bundled",
@@ -709,6 +713,98 @@ describe("resolvePluginProviders", () => {
709713
}),
710714
);
711715
});
716+
717+
it("uses activation.onProviders to keep explicit provider owners on the runtime path", () => {
718+
setManifestPlugins([
719+
createManifestProviderPlugin({
720+
id: "activation-owned-provider",
721+
providerIds: [],
722+
activation: {
723+
onProviders: ["activation-owned"],
724+
},
725+
}),
726+
]);
727+
728+
resolvePluginProviders({
729+
config: {},
730+
providerRefs: ["activation-owned"],
731+
activate: true,
732+
});
733+
734+
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledWith(
735+
expect.objectContaining({
736+
onlyPluginIds: ["activation-owned-provider"],
737+
activate: true,
738+
config: expect.objectContaining({
739+
plugins: expect.objectContaining({
740+
allow: ["activation-owned-provider"],
741+
entries: {
742+
"activation-owned-provider": { enabled: true },
743+
},
744+
}),
745+
}),
746+
}),
747+
);
748+
});
749+
750+
it("uses setup.providers to keep explicit provider owners on the setup path", () => {
751+
setManifestPlugins([
752+
createManifestProviderPlugin({
753+
id: "setup-owned-provider",
754+
providerIds: [],
755+
setup: {
756+
providers: [{ id: "setup-owned" }],
757+
},
758+
}),
759+
]);
760+
761+
resolvePluginProviders({
762+
config: {},
763+
providerRefs: ["setup-owned"],
764+
activate: true,
765+
mode: "setup",
766+
});
767+
768+
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
769+
expect.objectContaining({
770+
onlyPluginIds: ["setup-owned-provider"],
771+
activate: true,
772+
config: expect.objectContaining({
773+
plugins: expect.objectContaining({
774+
allow: ["setup-owned-provider"],
775+
entries: {
776+
"setup-owned-provider": { enabled: true },
777+
},
778+
}),
779+
}),
780+
}),
781+
);
782+
});
783+
784+
it("keeps legacy CLI backend ownership as the explicit provider fallback", () => {
785+
setOwningProviderManifestPlugins();
786+
787+
resolvePluginProviders({
788+
config: {},
789+
providerRefs: ["claude-cli"],
790+
activate: true,
791+
});
792+
793+
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledWith(
794+
expect.objectContaining({
795+
onlyPluginIds: ["anthropic"],
796+
activate: true,
797+
config: expect.objectContaining({
798+
plugins: expect.objectContaining({
799+
allow: ["anthropic"],
800+
entries: {
801+
anthropic: { enabled: true },
802+
},
803+
}),
804+
}),
805+
}),
806+
);
807+
});
712808
it.each([
713809
{
714810
provider: "minimax-portal",

0 commit comments

Comments
 (0)