feat: add plugin capability slots and diagnostics

This commit is contained in:
Tak Hoffman 2026-03-11 23:24:03 -05:00
parent 034798b101
commit 0997deb0e9
19 changed files with 927 additions and 103 deletions

View File

@ -1,5 +1,6 @@
{
"id": "tavily-search",
"provides": ["providers.search.tavily"],
"uiHints": {
"apiKey": {
"label": "Tavily API key",

View File

@ -3,6 +3,7 @@ import { formatCliCommand } from "../../cli/command-format.js";
import type { OpenClawConfig } from "../../config/config.js";
import { normalizeResolvedSecretInputString } from "../../config/types.secrets.js";
import { logVerbose } from "../../globals.js";
import { resolveCapabilitySlotSelection } from "../../plugins/capability-slots.js";
import { getActivePluginRegistry } from "../../plugins/runtime.js";
import type {
SearchProviderContext,
@ -2464,12 +2465,32 @@ function getPluginSearchProviders(): SearchProviderPlugin[] {
return getActivePluginRegistry()?.searchProviders.map((entry) => entry.provider) ?? [];
}
function resolveConfiguredSearchProviderId(params: {
config?: OpenClawConfig;
search?: WebSearchConfig;
}): string | null | undefined {
if (params.config) {
return resolveCapabilitySlotSelection(params.config, "providers.search");
}
if (!params.search) {
return undefined;
}
return resolveCapabilitySlotSelection(
{ tools: { web: { search: params.search } } } as OpenClawConfig,
"providers.search",
);
}
function resolvePreferredBuiltinSearchProvider(params: {
search?: WebSearchConfig;
runtimeWebSearch?: RuntimeWebSearchMetadata;
config?: OpenClawConfig;
}): BuiltinWebSearchProviderId {
const configuredProviderId = normalizeSearchProviderId(
typeof params.search?.provider === "string" ? params.search.provider : undefined,
resolveConfiguredSearchProviderId({
config: params.config,
search: params.search,
}) ?? undefined,
);
if (isBuiltinSearchProviderId(configuredProviderId)) {
return configuredProviderId;
@ -2498,7 +2519,10 @@ function resolveRegisteredSearchProvider(params: {
runtimeWebSearch?: RuntimeWebSearchMetadata;
}): SearchProviderPlugin {
const configuredProviderId = normalizeSearchProviderId(
typeof params.search?.provider === "string" ? params.search.provider : undefined,
resolveConfiguredSearchProviderId({
config: params.config,
search: params.search,
}) ?? undefined,
);
const builtinProviders = new Map(
getBuiltinSearchProviders(params.search).map((provider) => [provider.id, provider]),
@ -2545,6 +2569,7 @@ function resolveRegisteredSearchProvider(params: {
return (
builtinProviders.get(
resolvePreferredBuiltinSearchProvider({
config: params.config,
search: params.search,
runtimeWebSearch: params.runtimeWebSearch,
}),

View File

@ -28,7 +28,7 @@ async function collectDoctorWarnings(config: Record<string, unknown>): Promise<s
run: loadAndMaybeMigrateDoctorConfig,
});
return noteSpy.mock.calls
.filter((call) => call[1] === "Doctor warnings")
.filter((call) => call[1] === "Doctor warnings" || call[1] === "Config warnings")
.map((call) => String(call[0]));
} finally {
noteSpy.mockRestore();
@ -127,6 +127,56 @@ describe("doctor config flow", () => {
).toBe(true);
});
it("surfaces missing required plugin capabilities as doctor warnings", async () => {
const temp = await withTempHome(async (homeDir) => {
const providerDir = path.join(homeDir, "embedding-provider");
await fs.mkdir(providerDir, { recursive: true });
await fs.writeFile(
path.join(providerDir, "index.js"),
'export default { id: "embedding-provider", register() {} };',
"utf-8",
);
await fs.writeFile(
path.join(providerDir, "openclaw.plugin.json"),
JSON.stringify({
id: "embedding-provider",
configSchema: { type: "object" },
provides: ["providers.embedding.fixture"],
}),
"utf-8",
);
const consumerDir = path.join(homeDir, "embedding-consumer");
await fs.mkdir(consumerDir, { recursive: true });
await fs.writeFile(
path.join(consumerDir, "index.js"),
'export default { id: "embedding-consumer", register() {} };',
"utf-8",
);
await fs.writeFile(
path.join(consumerDir, "openclaw.plugin.json"),
JSON.stringify({
id: "embedding-consumer",
configSchema: { type: "object" },
requires: ["providers.embedding.fixture"],
}),
"utf-8",
);
return collectDoctorWarnings({
plugins: {
enabled: true,
load: { paths: [consumerDir] },
},
});
});
expect(
temp.some((line) =>
line.includes("missing required capability: providers.embedding.fixture"),
),
).toBe(true);
});
it("does not warn on mutable Zalouser group entries when dangerous name matching is enabled", async () => {
const doctorWarnings = await collectDoctorWarnings({
channels: {
@ -141,7 +191,6 @@ describe("doctor config flow", () => {
expect(doctorWarnings.some((line) => line.includes("channels.zalouser.groups"))).toBe(false);
});
it("warns when imessage group allowlist is empty even if allowFrom is set", async () => {
const doctorWarnings = await collectDoctorWarnings({
channels: {

View File

@ -12,6 +12,10 @@ import {
hasConfiguredSecretInput,
normalizeSecretInputString,
} from "../config/types.secrets.js";
import {
applyCapabilitySlotSelection,
resolveCapabilitySlotSelection,
} from "../plugins/capability-slots.js";
import { enablePluginInConfig } from "../plugins/enable.js";
import { loadOpenClawPlugins } from "../plugins/loader.js";
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
@ -24,6 +28,11 @@ import {
ensureOnboardingPluginInstalled,
reloadOnboardingPluginRegistry,
} from "./onboarding/plugin-install.js";
import {
buildProviderSelectionOptions,
promptProviderManagementIntent,
type ProviderManagementIntent,
} from "./provider-management.js";
import {
SEARCH_PROVIDER_PLUGIN_INSTALL_CATALOG,
type InstallableSearchProviderPluginCatalogEntry,
@ -59,7 +68,7 @@ export type SearchProviderPickerEntry =
| PluginSearchProviderEntry;
type SearchProviderPickerChoice = string;
type SearchProviderFlowIntent = "switch-active" | "configure-provider";
type SearchProviderFlowIntent = ProviderManagementIntent;
type PluginPromptableField =
| {
@ -661,7 +670,7 @@ export function buildSearchProviderPickerModel(
params: SearchProviderPickerModelParams,
): SearchProviderPickerModel {
const { config, providerEntries, includeSkipOption, skipHint } = params;
const existingProvider = config.tools?.web?.search?.provider;
const existingProvider = resolveCapabilitySlotSelection(config, "providers.search");
const existingPluginProvider =
typeof existingProvider === "string" &&
existingProvider.trim() &&
@ -875,19 +884,11 @@ export async function configureSearchProviderSelection(
return preserveSearchProviderIntent(
config,
{
...config,
tools: {
...config.tools,
web: {
...config.tools?.web,
search: {
...config.tools?.web?.search,
provider: builtinChoice,
},
},
},
},
applyCapabilitySlotSelection({
config,
slot: "providers.search",
selectedId: builtinChoice,
}),
intent,
builtinChoice,
);
@ -903,63 +904,18 @@ function preserveSearchProviderIntent(
return preserveDisabledState(original, result);
}
const currentProvider = original.tools?.web?.search?.provider;
const currentProvider = resolveCapabilitySlotSelection(original, "providers.search");
let next = result;
if (currentProvider && currentProvider !== selectedProvider) {
next = {
...next,
tools: {
...next.tools,
web: {
...next.tools?.web,
search: {
...next.tools?.web?.search,
provider: currentProvider,
},
},
},
};
next = applyCapabilitySlotSelection({
config: next,
slot: "providers.search",
selectedId: currentProvider,
});
}
return preserveDisabledState(original, next);
}
async function promptSearchProviderIntent(params: {
prompter: WizardPrompter;
includeSkipOption: boolean;
configuredCount: number;
}): Promise<SearchProviderFlowIntent | typeof SEARCH_PROVIDER_SKIP_SENTINEL> {
if (params.configuredCount <= 1) {
return "switch-active";
}
return await params.prompter.select<
SearchProviderFlowIntent | typeof SEARCH_PROVIDER_SKIP_SENTINEL
>({
message: "Web search setup",
options: [
{
value: SEARCH_PROVIDER_CONFIGURE_SENTINEL,
label: "Configure a provider",
hint: "Update keys or plugin settings without changing the active provider",
},
{
value: SEARCH_PROVIDER_SWITCH_ACTIVE_SENTINEL,
label: "Switch active provider",
hint: "Change which provider web_search uses right now",
},
...(params.includeSkipOption
? [
{
value: SEARCH_PROVIDER_SKIP_SENTINEL,
label: "Skip for now",
hint: "Configure later with openclaw configure --section web",
},
]
: []),
],
initialValue: SEARCH_PROVIDER_CONFIGURE_SENTINEL,
});
}
export async function promptSearchProviderFlow(params: {
config: OpenClawConfig;
runtime: RuntimeEnv;
@ -978,10 +934,19 @@ export async function promptSearchProviderFlow(params: {
includeSkipOption: params.includeSkipOption,
skipHint: params.skipHint,
});
const action = await promptSearchProviderIntent({
const action = await promptProviderManagementIntent({
prompter: params.prompter,
message: "Web search setup",
includeSkipOption: params.includeSkipOption,
configuredCount: pickerModel.configuredCount,
configureValue: SEARCH_PROVIDER_CONFIGURE_SENTINEL,
switchValue: SEARCH_PROVIDER_SWITCH_ACTIVE_SENTINEL,
skipValue: SEARCH_PROVIDER_SKIP_SENTINEL,
configureLabel: "Configure a provider",
configureHint: "Update keys or plugin settings without changing the active provider",
switchLabel: "Switch active provider",
switchHint: "Change which provider web_search uses right now",
skipHint: "Configure later with openclaw configure --section web",
});
if (action === SEARCH_PROVIDER_SKIP_SENTINEL) {
return params.config;
@ -993,18 +958,12 @@ export async function promptSearchProviderFlow(params: {
intent === "switch-active"
? "Choose active web search provider"
: "Choose provider to configure",
options: pickerModel.options
.filter((option) => {
if (intent === "configure-provider") {
return option.value !== SEARCH_PROVIDER_KEEP_CURRENT_SENTINEL;
}
return true;
})
.map((option) =>
intent === "switch-active" && option.value === pickerModel.activeProvider
? { ...option, label: `[Active] ${option.label}` }
: option,
),
options: buildProviderSelectionOptions({
intent,
options: pickerModel.options,
activeValue: pickerModel.activeProvider,
hiddenValues: intent === "configure-provider" ? [SEARCH_PROVIDER_KEEP_CURRENT_SENTINEL] : [],
}),
initialValue:
intent === "switch-active"
? pickerModel.initialValue
@ -1114,15 +1073,19 @@ export function applySearchKey(
}
function applyProviderOnly(config: OpenClawConfig, provider: SearchProvider): OpenClawConfig {
const next = applyCapabilitySlotSelection({
config,
slot: "providers.search",
selectedId: provider,
});
return {
...config,
...next,
tools: {
...config.tools,
...next.tools,
web: {
...config.tools?.web,
...next.tools?.web,
search: {
...config.tools?.web?.search,
provider,
...next.tools?.web?.search,
enabled: true,
},
},

View File

@ -0,0 +1,78 @@
import type { WizardPrompter } from "../wizard/prompts.js";
export type ProviderManagementIntent = "switch-active" | "configure-provider";
export type ProviderManagementOption<T extends string = string> = {
value: T;
label: string;
hint?: string;
};
type PromptProviderManagementIntentParams = {
prompter: WizardPrompter;
message: string;
includeSkipOption: boolean;
configuredCount: number;
configureValue: string;
switchValue: string;
skipValue: string;
configureLabel: string;
configureHint?: string;
switchLabel: string;
switchHint?: string;
skipLabel?: string;
skipHint?: string;
};
export async function promptProviderManagementIntent(
params: PromptProviderManagementIntentParams,
): Promise<string> {
if (params.configuredCount <= 1) {
return "switch-active";
}
return await params.prompter.select<string>({
message: params.message,
options: [
{
value: params.configureValue,
label: params.configureLabel,
hint: params.configureHint,
},
{
value: params.switchValue,
label: params.switchLabel,
hint: params.switchHint,
},
...(params.includeSkipOption
? [
{
value: params.skipValue,
label: params.skipLabel ?? "Skip for now",
hint: params.skipHint,
},
]
: []),
],
initialValue: params.configureValue,
});
}
export function buildProviderSelectionOptions<T extends string>(params: {
intent: ProviderManagementIntent;
options: Array<ProviderManagementOption<T>>;
activeValue?: string;
activePrefix?: string;
hiddenValues?: Iterable<string>;
}): Array<ProviderManagementOption<T>> {
const hiddenValues = new Set(params.hiddenValues ?? []);
return params.options
.filter((option) => !hiddenValues.has(option.value))
.map((option) =>
params.intent === "switch-active" && option.value === params.activeValue
? {
...option,
label: `${params.activePrefix ?? "[Active] "} ${option.label}`.trim(),
}
: option,
);
}

View File

@ -22,6 +22,7 @@ async function writePluginFixture(params: {
id: string;
schema: Record<string, unknown>;
channels?: string[];
manifest?: Record<string, unknown>;
}) {
await mkdirSafe(params.dir);
await fs.writeFile(
@ -32,6 +33,7 @@ async function writePluginFixture(params: {
const manifest: Record<string, unknown> = {
id: params.id,
configSchema: params.schema,
...params.manifest,
};
if (params.channels) {
manifest.channels = params.channels;
@ -60,6 +62,10 @@ describe("config plugin validation", () => {
CLAWDBOT_STATE_DIR: undefined,
OPENCLAW_PLUGIN_MANIFEST_CACHE_MS: "10000",
}) satisfies NodeJS.ProcessEnv;
let capabilityProviderDir = "";
let capabilityConsumerDir = "";
let capabilityConflictADir = "";
let capabilityConflictBDir = "";
const validateInSuite = (raw: unknown) =>
validateConfigObjectWithPlugins(raw, { env: suiteEnv() });
@ -104,6 +110,43 @@ describe("config plugin validation", () => {
channels: ["bluebubbles"],
schema: { type: "object" },
});
capabilityProviderDir = path.join(suiteHome, "capability-provider");
await writePluginFixture({
dir: capabilityProviderDir,
id: "capability-provider",
schema: { type: "object" },
manifest: {
provides: ["providers.embedding.fixture"],
},
});
capabilityConsumerDir = path.join(suiteHome, "capability-consumer");
await writePluginFixture({
dir: capabilityConsumerDir,
id: "capability-consumer",
schema: { type: "object" },
manifest: {
requires: ["providers.embedding.fixture"],
},
});
capabilityConflictADir = path.join(suiteHome, "capability-conflict-a");
await writePluginFixture({
dir: capabilityConflictADir,
id: "capability-conflict-a",
schema: { type: "object" },
manifest: {
provides: ["memory.backend.fixtureA"],
},
});
capabilityConflictBDir = path.join(suiteHome, "capability-conflict-b");
await writePluginFixture({
dir: capabilityConflictBDir,
id: "capability-conflict-b",
schema: { type: "object" },
manifest: {
provides: ["memory.backend.fixtureB"],
conflicts: ["memory.backend.*"],
},
});
voiceCallSchemaPluginDir = path.join(suiteHome, "voice-call-schema-plugin");
const voiceCallManifestPath = path.join(
process.cwd(),
@ -236,6 +279,61 @@ describe("config plugin validation", () => {
}
});
it("warns when a plugin is missing a declared required capability", async () => {
const res = validateInSuite({
agents: { list: [{ id: "pi" }] },
plugins: {
enabled: true,
load: { paths: [capabilityConsumerDir] },
},
});
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.warnings).toContainEqual({
path: "plugins.entries.capability-consumer",
message:
"plugin capability-consumer: missing required capability: providers.embedding.fixture",
});
}
});
it("does not warn when a declared required capability is present", async () => {
const res = validateInSuite({
agents: { list: [{ id: "pi" }] },
plugins: {
enabled: true,
load: { paths: [capabilityProviderDir, capabilityConsumerDir] },
},
});
expect(res.ok).toBe(true);
if (res.ok) {
expect(
res.warnings.some((warning) => warning.message.includes("missing required capability")),
).toBe(false);
}
});
it("errors when a plugin declares a conflicting capability pattern", async () => {
const res = validateInSuite({
agents: { list: [{ id: "pi" }] },
plugins: {
enabled: true,
load: { paths: [capabilityConflictADir, capabilityConflictBDir] },
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues).toContainEqual({
path: "plugins.entries.capability-conflict-b",
message:
"plugin capability-conflict-b: conflicting capability present: memory.backend.* (capability-conflict-a)",
});
}
});
it("surfaces allowed enum values for plugin config diagnostics", async () => {
const res = validateInSuite({
agents: { list: [{ id: "pi" }] },

View File

@ -2,6 +2,11 @@ import path from "node:path";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { isBuiltinWebSearchProviderId } from "../agents/tools/web-search-provider-catalog.js";
import { CHANNEL_IDS, normalizeChatChannelId } from "../channels/registry.js";
import {
resolveCapabilitySlotConfigPath,
resolveCapabilitySlotSelection,
type CapabilitySlotId,
} from "../plugins/capability-slots.js";
import {
normalizePluginsConfig,
resolveEffectiveEnableState,
@ -35,6 +40,24 @@ type AllowedValuesCollection = {
hasValues: boolean;
};
function resolvePluginDiagnosticPath(diag: {
pluginId?: string;
message: string;
code?: string;
slot?: string;
}): string {
if (diag.message.includes("plugin path not found")) {
return "plugins.load.paths";
}
if (diag.code === "capability_slot_conflict" && diag.slot) {
return resolveCapabilitySlotConfigPath(diag.slot as CapabilitySlotId);
}
if (diag.pluginId) {
return `plugins.entries.${diag.pluginId}`;
}
return "plugins";
}
function toIssueRecord(value: unknown): UnknownIssueRecord | null {
if (!value || typeof value !== "object") {
return null;
@ -357,10 +380,36 @@ function validateConfigObjectWithPluginsBase(
});
for (const diag of registry.diagnostics) {
let path = diag.pluginId ? `plugins.entries.${diag.pluginId}` : "plugins";
if (!diag.pluginId && diag.message.includes("plugin path not found")) {
path = "plugins.load.paths";
const path = resolvePluginDiagnosticPath(diag);
const pluginLabel = diag.pluginId ? `plugin ${diag.pluginId}` : "plugin";
const message = `${pluginLabel}: ${diag.message}`;
if (diag.level === "error") {
issues.push({ path, message });
} else {
warnings.push({ path, message });
}
}
const capabilityRegistry = loadOpenClawPlugins({
config,
workspaceDir: workspaceDir ?? undefined,
cache: false,
mode: "validate",
logger: {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
},
});
for (const diag of capabilityRegistry.diagnostics) {
if (diag.message.startsWith("invalid config:")) {
continue;
}
if (!diag.code?.startsWith("capability_")) {
continue;
}
const path = resolvePluginDiagnosticPath(diag);
const pluginLabel = diag.pluginId ? `plugin ${diag.pluginId}` : "plugin";
const message = `${pluginLabel}: ${diag.message}`;
if (diag.level === "error") {
@ -391,7 +440,7 @@ function validateConfigObjectWithPluginsBase(
};
const validateWebSearchProvider = () => {
const provider = config.tools?.web?.search?.provider;
const provider = resolveCapabilitySlotSelection(config, "providers.search");
if (typeof provider !== "string") {
return;
}

115
src/plugins/capabilities.ts Normal file
View File

@ -0,0 +1,115 @@
import type { OpenClawConfig } from "../config/config.js";
export type PluginCapabilityKind = "search-provider";
export type PluginCapabilitySlotMode = "multi" | "exclusive";
export type CapabilitySlotId = "providers.search" | "memory.backend";
type CapabilityKindDefinition = {
capabilityPrefix: string;
slot: CapabilitySlotId;
slotMode: PluginCapabilitySlotMode;
};
type CapabilitySlotDefinition = {
configPath: string;
read: (config: OpenClawConfig | undefined) => string | null | undefined;
write: (config: OpenClawConfig, selectedId: string | null) => OpenClawConfig;
};
const DEFAULT_MEMORY_BACKEND = "memory-core";
function normalizeSelection(value: unknown): string | null | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
if (trimmed.toLowerCase() === "none") {
return null;
}
return trimmed;
}
const CAPABILITY_KIND_DEFINITIONS: Record<PluginCapabilityKind, CapabilityKindDefinition> = {
"search-provider": {
capabilityPrefix: "providers.search",
slot: "providers.search",
slotMode: "multi",
},
};
const CAPABILITY_SLOT_DEFINITIONS: Record<CapabilitySlotId, CapabilitySlotDefinition> = {
"providers.search": {
configPath: "tools.web.search.provider",
read: (config) => normalizeSelection(config?.tools?.web?.search?.provider),
write: (config, selectedId) => ({
...config,
tools: {
...config.tools,
web: {
...config.tools?.web,
search: {
...config.tools?.web?.search,
provider: selectedId ?? undefined,
},
},
},
}),
},
"memory.backend": {
configPath: "plugins.slots.memory",
read: (config) => {
const configured = normalizeSelection(config?.plugins?.slots?.memory);
return configured === undefined ? DEFAULT_MEMORY_BACKEND : configured;
},
write: (config, selectedId) => ({
...config,
plugins: {
...config.plugins,
slots: {
...config.plugins?.slots,
memory: selectedId ?? "none",
},
},
}),
},
};
export function buildCapabilityName(kind: PluginCapabilityKind, id: string): string {
const definition = CAPABILITY_KIND_DEFINITIONS[kind];
return `${definition.capabilityPrefix}.${id}`;
}
export function resolveCapabilitySlotForKind(kind: PluginCapabilityKind): CapabilitySlotId {
return CAPABILITY_KIND_DEFINITIONS[kind].slot;
}
export function resolveCapabilitySlotModeForKind(
kind: PluginCapabilityKind,
): PluginCapabilitySlotMode {
return CAPABILITY_KIND_DEFINITIONS[kind].slotMode;
}
export function resolveCapabilitySlotConfigPath(slot: CapabilitySlotId): string {
return CAPABILITY_SLOT_DEFINITIONS[slot].configPath;
}
export function resolveCapabilitySlotSelection(
config: OpenClawConfig | undefined,
slot: CapabilitySlotId,
): string | null | undefined {
return CAPABILITY_SLOT_DEFINITIONS[slot].read(config);
}
export function applyCapabilitySlotSelection(params: {
config: OpenClawConfig;
slot: CapabilitySlotId;
selectedId: string | null;
}): OpenClawConfig {
const selectedId =
params.selectedId === null ? null : (normalizeSelection(params.selectedId) ?? undefined);
return CAPABILITY_SLOT_DEFINITIONS[params.slot].write(params.config, selectedId ?? null);
}

View File

@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
applyCapabilitySlotSelection,
resolveCapabilitySlotSelection,
} from "./capability-slots.js";
describe("capability slot selection", () => {
it("resolves the configured search provider from web search config", () => {
const config: OpenClawConfig = {
tools: { web: { search: { provider: "tavily" } } },
};
expect(resolveCapabilitySlotSelection(config, "providers.search")).toBe("tavily");
});
it("applies search slot selection through the web search provider field", () => {
const config: OpenClawConfig = {
tools: { web: { search: { provider: "brave" } } },
};
const next = applyCapabilitySlotSelection({
config,
slot: "providers.search",
selectedId: "tavily",
});
expect(next.tools?.web?.search?.provider).toBe("tavily");
});
it("resolves the effective memory backend selection with default fallback", () => {
expect(resolveCapabilitySlotSelection({}, "memory.backend")).toBe("memory-core");
});
it("applies memory backend selection through plugins.slots.memory", () => {
const next = applyCapabilitySlotSelection({
config: {},
slot: "memory.backend",
selectedId: "memory-alt",
});
expect(next.plugins?.slots?.memory).toBe("memory-alt");
});
it("supports disabling the memory backend slot", () => {
const next = applyCapabilitySlotSelection({
config: {},
slot: "memory.backend",
selectedId: null,
});
expect(next.plugins?.slots?.memory).toBe("none");
});
});

View File

@ -0,0 +1,10 @@
export {
applyCapabilitySlotSelection,
resolveCapabilitySlotConfigPath,
resolveCapabilitySlotForKind,
resolveCapabilitySlotModeForKind,
resolveCapabilitySlotSelection,
type CapabilitySlotId,
type PluginCapabilityKind,
type PluginCapabilitySlotMode,
} from "./capabilities.js";

View File

@ -97,6 +97,7 @@ function writePlugin(params: {
body: string;
dir?: string;
filename?: string;
manifest?: Record<string, unknown>;
}): TempPlugin {
const dir = params.dir ?? makeTempDir();
const filename = params.filename ?? `${params.id}.cjs`;
@ -106,7 +107,7 @@ function writePlugin(params: {
fs.writeFileSync(
path.join(dir, "openclaw.plugin.json"),
JSON.stringify(
{
params.manifest ?? {
id: params.id,
configSchema: EMPTY_PLUGIN_SCHEMA,
},
@ -2143,4 +2144,116 @@ describe("loadOpenClawPlugins", () => {
);
expect(resolved).toBe(srcFile);
});
it("emits diagnostics for duplicate declared capabilities", () => {
useNoBundledPlugins();
const first = writePlugin({
id: "search-one",
body: `module.exports = { id: "search-one", register(api) { api.registerSearchProvider({ id: "alpha", name: "Alpha", search: async () => ({ content: "alpha" }) }); } };`,
manifest: {
id: "search-one",
configSchema: EMPTY_PLUGIN_SCHEMA,
provides: ["providers.search.shared"],
},
});
const second = writePlugin({
id: "search-two",
body: `module.exports = { id: "search-two", register(api) { api.registerSearchProvider({ id: "beta", name: "Beta", search: async () => ({ content: "beta" }) }); } };`,
manifest: {
id: "search-two",
configSchema: EMPTY_PLUGIN_SCHEMA,
provides: ["providers.search.shared"],
},
});
const registry = loadOpenClawPlugins({
cache: false,
config: {
plugins: {
load: { paths: [first.file, second.file] },
},
},
});
expect(
registry.diagnostics.filter((diag) =>
diag.message.includes("declared capability already provided by another plugin"),
),
).toEqual(expect.arrayContaining([expect.objectContaining({ pluginId: "search-two" })]));
expect(registry.searchProviders.map((entry) => entry.provider.id)).toEqual(["alpha"]);
expect(registry.plugins).toEqual(
expect.arrayContaining([expect.objectContaining({ id: "search-two", status: "error" })]),
);
});
it("warns when a declared required capability is missing", () => {
useNoBundledPlugins();
const plugin = writePlugin({
id: "memory-ui",
body: `module.exports = { id: "memory-ui", register() {} };`,
manifest: {
id: "memory-ui",
configSchema: EMPTY_PLUGIN_SCHEMA,
requires: ["memory.backend.*"],
},
});
const registry = loadRegistryFromSinglePlugin({ plugin });
expect(registry.diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({
level: "warn",
pluginId: "memory-ui",
message: "missing required capability: memory.backend.*",
}),
]),
);
});
it("errors when a declared conflicting capability is present", () => {
useNoBundledPlugins();
const first = writePlugin({
id: "memory-a",
body: `module.exports = { id: "memory-a", register(api) { api.registerSearchProvider({ id: "alpha", name: "Alpha", search: async () => ({ content: "alpha" }) }); } };`,
manifest: {
id: "memory-a",
configSchema: EMPTY_PLUGIN_SCHEMA,
provides: ["memory.backend.a"],
},
});
const second = writePlugin({
id: "memory-b",
body: `module.exports = { id: "memory-b", register(api) { api.registerSearchProvider({ id: "beta", name: "Beta", search: async () => ({ content: "beta" }) }); } };`,
manifest: {
id: "memory-b",
configSchema: EMPTY_PLUGIN_SCHEMA,
provides: ["memory.backend.b"],
conflicts: ["memory.backend.*"],
},
});
const registry = loadOpenClawPlugins({
cache: false,
config: {
plugins: {
load: { paths: [first.file, second.file] },
},
},
});
expect(registry.diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({
level: "error",
pluginId: "memory-b",
message: "conflicting capability present: memory.backend.* (memory-a)",
}),
]),
);
expect(registry.searchProviders.map((entry) => entry.provider.id)).toEqual(["alpha"]);
expect(registry.plugins).toEqual(
expect.arrayContaining([expect.objectContaining({ id: "memory-b", status: "error" })]),
);
});
});

View File

@ -307,6 +307,10 @@ function createPluginRecord(params: {
channelIds: [],
providerIds: [],
searchProviderIds: [],
capabilityIds: [],
declaredCapabilities: [],
requiredCapabilities: [],
conflictingCapabilities: [],
gatewayMethods: [],
cliCommands: [],
services: [],
@ -319,6 +323,100 @@ function createPluginRecord(params: {
};
}
function capabilityPatternMatches(params: { pattern: string; capability: string }): boolean {
const pattern = params.pattern.trim();
const capability = params.capability.trim();
if (!pattern || !capability) {
return false;
}
if (pattern.endsWith(".*")) {
const prefix = pattern.slice(0, -2);
return capability === prefix || capability.startsWith(`${prefix}.`);
}
return capability === pattern;
}
function collectDeclaredCapabilities(plugin: PluginRecord): Set<string> {
return new Set([...plugin.declaredCapabilities, ...plugin.capabilityIds]);
}
function evaluateCapabilityRelationships(params: {
activePlugins: PluginRecord[];
candidatePlugin?: PluginRecord;
}): PluginDiagnostic[] {
const diagnostics: PluginDiagnostic[] = [];
const activeCapabilityOwners = new Map<string, string[]>();
for (const plugin of params.activePlugins) {
for (const capability of collectDeclaredCapabilities(plugin)) {
const owners = activeCapabilityOwners.get(capability) ?? [];
owners.push(plugin.id);
activeCapabilityOwners.set(capability, owners);
}
}
const pluginsToEvaluate = params.candidatePlugin
? [params.candidatePlugin]
: params.activePlugins;
for (const plugin of pluginsToEvaluate) {
const pluginCapabilities = collectDeclaredCapabilities(plugin);
for (const capability of pluginCapabilities) {
const owners = activeCapabilityOwners.get(capability) ?? [];
const conflictingOwners = params.candidatePlugin
? owners
: owners.filter((owner) => owner !== plugin.id);
if (conflictingOwners.length > 0) {
diagnostics.push({
level: "error",
pluginId: plugin.id,
source: plugin.source,
code: "capability_declared_duplicate",
capability,
slot: capability.includes(".") ? capability.split(".").slice(0, -1).join(".") : undefined,
message: `declared capability already provided by another plugin: ${capability} (${Array.from(new Set(conflictingOwners)).join(", ")})`,
});
}
}
for (const requirement of plugin.requiredCapabilities) {
const satisfied = Array.from(activeCapabilityOwners.keys()).some((capability) =>
capabilityPatternMatches({ pattern: requirement, capability }),
);
if (satisfied) {
continue;
}
diagnostics.push({
level: "warn",
pluginId: plugin.id,
source: plugin.source,
code: "capability_missing_requirement",
capability: requirement,
message: `missing required capability: ${requirement}`,
});
}
for (const conflict of plugin.conflictingCapabilities) {
const conflictingOwners = Array.from(activeCapabilityOwners.entries())
.filter(([capability]) => capabilityPatternMatches({ pattern: conflict, capability }))
.flatMap(([, owners]) => owners)
.filter((ownerId) => ownerId !== plugin.id);
if (conflictingOwners.length === 0) {
continue;
}
diagnostics.push({
level: "error",
pluginId: plugin.id,
source: plugin.source,
code: "capability_conflict_present",
capability: conflict,
message: `conflicting capability present: ${conflict} (${Array.from(new Set(conflictingOwners)).join(", ")})`,
});
}
}
return diagnostics;
}
function recordPluginError(params: {
logger: PluginLogger;
registry: PluginRegistry;
@ -703,6 +801,9 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
record.kind = manifestRecord.kind;
record.configUiHints = manifestRecord.configUiHints;
record.configJsonSchema = manifestRecord.configSchema;
record.declaredCapabilities = [...manifestRecord.provides];
record.requiredCapabilities = [...manifestRecord.requires];
record.conflictingCapabilities = [...manifestRecord.conflicts];
const pushPluginLoadError = (message: string) => {
record.status = "error";
record.error = message;
@ -743,6 +844,21 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
}
}
const preflightCapabilityDiagnostics = evaluateCapabilityRelationships({
activePlugins: registry.plugins.filter((plugin) => plugin.status === "loaded"),
candidatePlugin: record,
});
if (preflightCapabilityDiagnostics.some((diag) => diag.level === "error")) {
record.status = "error";
record.error =
preflightCapabilityDiagnostics.find((diag) => diag.level === "error")?.message ??
"plugin capability relationship error";
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
pushDiagnostics(registry.diagnostics, preflightCapabilityDiagnostics);
continue;
}
if (!manifestRecord.configSchema) {
pushPluginLoadError("missing config schema");
continue;
@ -895,6 +1011,13 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
});
}
pushDiagnostics(
registry.diagnostics,
evaluateCapabilityRelationships({
activePlugins: registry.plugins.filter((plugin) => plugin.status === "loaded"),
}),
);
warnAboutUntrackedLoadedPlugins({
registry,
provenance,

View File

@ -29,6 +29,9 @@ export type PluginManifestRecord = {
channels: string[];
providers: string[];
skills: string[];
provides: string[];
requires: string[];
conflicts: string[];
origin: PluginOrigin;
workspaceDir?: string;
rootDir: string;
@ -124,6 +127,9 @@ function buildRecord(params: {
channels: params.manifest.channels ?? [],
providers: params.manifest.providers ?? [],
skills: params.manifest.skills ?? [],
provides: params.manifest.provides ?? [],
requires: params.manifest.requires ?? [],
conflicts: params.manifest.conflicts ?? [],
origin: params.candidate.origin,
workspaceDir: params.candidate.workspaceDir,
rootDir: params.candidate.rootDir,

View File

@ -15,6 +15,9 @@ export type PluginManifest = {
channels?: string[];
providers?: string[];
skills?: string[];
provides?: string[];
requires?: string[];
conflicts?: string[];
name?: string;
description?: string;
version?: string;
@ -94,6 +97,9 @@ export function loadPluginManifest(
const channels = normalizeStringList(raw.channels);
const providers = normalizeStringList(raw.providers);
const skills = normalizeStringList(raw.skills);
const provides = normalizeStringList(raw.provides);
const requires = normalizeStringList(raw.requires);
const conflicts = normalizeStringList(raw.conflicts);
let uiHints: Record<string, PluginConfigUiHint> | undefined;
if (isRecord(raw.uiHints)) {
@ -109,6 +115,9 @@ export function loadPluginManifest(
channels,
providers,
skills,
provides,
requires,
conflicts,
name,
description,
version,

View File

@ -14,6 +14,10 @@ function createRecord(id: string): PluginRecord {
channelIds: [],
providerIds: [],
searchProviderIds: [],
capabilityIds: [],
declaredCapabilities: [],
requiredCapabilities: [],
conflictingCapabilities: [],
gatewayMethods: [],
cliCommands: [],
services: [],
@ -53,6 +57,18 @@ describe("search provider registration", () => {
expect(registry.searchProviders).toHaveLength(1);
expect(registry.searchProviders[0]?.provider.id).toBe("tavily");
expect(registry.searchProviders[0]?.provider.pluginId).toBe("first-plugin");
expect(registry.capabilities).toEqual(
expect.arrayContaining([
expect.objectContaining({
pluginId: "first-plugin",
kind: "search-provider",
capability: "providers.search.tavily",
id: "tavily",
slot: "providers.search",
slotMode: "multi",
}),
]),
);
expect(registry.diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({

View File

@ -10,6 +10,13 @@ import type {
import { registerInternalHook } from "../hooks/internal-hooks.js";
import type { HookEntry } from "../hooks/types.js";
import { resolveUserPath } from "../utils.js";
import {
buildCapabilityName,
resolveCapabilitySlotForKind,
resolveCapabilitySlotModeForKind,
type PluginCapabilityKind,
type PluginCapabilitySlotMode,
} from "./capabilities.js";
import { registerPluginCommand } from "./commands.js";
import { normalizePluginHttpPath } from "./http-path.js";
import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js";
@ -88,6 +95,17 @@ export type PluginSearchProviderRegistration = {
source: string;
};
export type PluginCapabilityRegistration<T = unknown> = {
pluginId: string;
kind: PluginCapabilityKind;
capability: string;
id: string;
slot: string;
slotMode: PluginCapabilitySlotMode;
value: T;
source: string;
};
export type PluginHookRegistration = {
pluginId: string;
entry: HookEntry;
@ -124,6 +142,10 @@ export type PluginRecord = {
channelIds: string[];
providerIds: string[];
searchProviderIds: string[];
capabilityIds: string[];
declaredCapabilities: string[];
requiredCapabilities: string[];
conflictingCapabilities: string[];
gatewayMethods: string[];
cliCommands: string[];
services: string[];
@ -143,6 +165,7 @@ export type PluginRegistry = {
channels: PluginChannelRegistration[];
providers: PluginProviderRegistration[];
searchProviders: PluginSearchProviderRegistration[];
capabilities: PluginCapabilityRegistration[];
gatewayHandlers: GatewayRequestHandlers;
httpRoutes: PluginHttpRouteRegistration[];
cliRegistrars: PluginCliRegistration[];
@ -184,6 +207,7 @@ export function createEmptyPluginRegistry(): PluginRegistry {
channels: [],
providers: [],
searchProviders: [],
capabilities: [],
gatewayHandlers: {},
httpRoutes: [],
cliRegistrars: [],
@ -201,6 +225,60 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registry.diagnostics.push(diag);
};
const registerCapability = <T>(params: {
record: PluginRecord;
kind: PluginCapabilityKind;
id: string;
value: T;
slotMode?: PluginCapabilitySlotMode;
duplicateMessage: string;
}): PluginCapabilityRegistration<T> | undefined => {
const slotMode = params.slotMode ?? resolveCapabilitySlotModeForKind(params.kind);
const capability = buildCapabilityName(params.kind, params.id);
const slot = resolveCapabilitySlotForKind(params.kind);
const existing = registry.capabilities.find((entry) => entry.capability === capability);
if (existing) {
pushDiagnostic({
level: "error",
pluginId: params.record.id,
source: params.record.source,
code: "capability_declared_duplicate",
capability,
slot,
message: params.duplicateMessage,
});
return undefined;
}
if (slotMode === "exclusive") {
const existingSlotOwner = registry.capabilities.find((entry) => entry.slot === slot);
if (existingSlotOwner) {
pushDiagnostic({
level: "error",
pluginId: params.record.id,
source: params.record.source,
code: "capability_slot_conflict",
capability,
slot,
message: `exclusive capability slot already registered: ${slot} (${existingSlotOwner.pluginId})`,
});
return undefined;
}
}
const registration: PluginCapabilityRegistration<T> = {
pluginId: params.record.id,
kind: params.kind,
capability,
id: params.id,
slot,
slotMode,
value: params.value,
source: params.record.source,
};
params.record.capabilityIds.push(capability);
registry.capabilities.push(registration);
return registration;
};
const registerTool = (
record: PluginRecord,
tool: AnyAgentTool | OpenClawPluginToolFactory,
@ -504,6 +582,16 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
id,
pluginId: record.id,
};
const registeredCapability = registerCapability({
record,
kind: "search-provider",
id,
value: normalizedProvider,
duplicateMessage: `search provider already registered: ${id} (${registry.capabilities.find((entry) => entry.capability === buildCapabilityName("search-provider", id))?.pluginId ?? "unknown"})`,
});
if (!registeredCapability) {
return;
}
record.searchProviderIds.push(id);
registry.searchProviders.push({
pluginId: record.id,

View File

@ -1,5 +1,9 @@
import type { OpenClawConfig } from "../config/config.js";
import type { PluginSlotsConfig } from "../config/types.plugins.js";
import {
applyCapabilitySlotSelection,
resolveCapabilitySlotSelection,
} from "./capability-slots.js";
import type { PluginKind } from "./types.js";
export type PluginSlotKey = keyof PluginSlotsConfig;
@ -50,12 +54,10 @@ export function applyExclusiveSlotSelection(params: {
const warnings: string[] = [];
const pluginsConfig = params.config.plugins ?? {};
const prevSlot = pluginsConfig.slots?.[slotKey];
const slots = {
...pluginsConfig.slots,
[slotKey]: params.selectedId,
};
const inferredPrevSlot = prevSlot ?? defaultSlotIdForKey(slotKey);
const inferredPrevSlot =
slotKey === "memory"
? resolveCapabilitySlotSelection(params.config, "memory.backend")
: (prevSlot ?? defaultSlotIdForKey(slotKey));
if (inferredPrevSlot && inferredPrevSlot !== params.selectedId) {
warnings.push(
`Exclusive slot "${slotKey}" switched from "${inferredPrevSlot}" to "${params.selectedId}".`,
@ -95,12 +97,29 @@ export function applyExclusiveSlotSelection(params: {
return { config: params.config, warnings: [], changed: false };
}
const baseConfig =
slotKey === "memory"
? applyCapabilitySlotSelection({
config: params.config,
slot: "memory.backend",
selectedId: params.selectedId,
})
: {
...params.config,
plugins: {
...pluginsConfig,
slots: {
...pluginsConfig.slots,
[slotKey]: params.selectedId,
},
},
};
return {
config: {
...params.config,
...baseConfig,
plugins: {
...pluginsConfig,
slots,
...baseConfig.plugins,
entries,
},
},

View File

@ -485,6 +485,13 @@ export type PluginDiagnostic = {
message: string;
pluginId?: string;
source?: string;
code?:
| "capability_declared_duplicate"
| "capability_missing_requirement"
| "capability_conflict_present"
| "capability_slot_conflict";
capability?: string;
slot?: string;
};
// ============================================================================

View File

@ -26,6 +26,7 @@ import type { OpenClawConfig } from "../config/config.js";
import { describeGatewayServiceRestart, resolveGatewayService } from "../daemon/service.js";
import { isSystemdUserServiceAvailable } from "../daemon/systemd.js";
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
import { resolveCapabilitySlotSelection } from "../plugins/capability-slots.js";
import type { RuntimeEnv } from "../runtime.js";
import { restoreTerminalState } from "../terminal/restore.js";
import { runTui } from "../tui/tui.js";
@ -481,7 +482,7 @@ export async function finalizeOnboardingWizard(
);
}
const webSearchProvider = nextConfig.tools?.web?.search?.provider;
const webSearchProvider = resolveCapabilitySlotSelection(nextConfig, "providers.search");
const webSearchEnabled = nextConfig.tools?.web?.search?.enabled;
if (webSearchProvider) {
const {