feat: add plugin capability slots and diagnostics
This commit is contained in:
parent
034798b101
commit
0997deb0e9
@ -1,5 +1,6 @@
|
||||
{
|
||||
"id": "tavily-search",
|
||||
"provides": ["providers.search.tavily"],
|
||||
"uiHints": {
|
||||
"apiKey": {
|
||||
"label": "Tavily API key",
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
78
src/commands/provider-management.ts
Normal file
78
src/commands/provider-management.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
@ -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" }] },
|
||||
|
||||
@ -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
115
src/plugins/capabilities.ts
Normal 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);
|
||||
}
|
||||
54
src/plugins/capability-slots.test.ts
Normal file
54
src/plugins/capability-slots.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
10
src/plugins/capability-slots.ts
Normal file
10
src/plugins/capability-slots.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export {
|
||||
applyCapabilitySlotSelection,
|
||||
resolveCapabilitySlotConfigPath,
|
||||
resolveCapabilitySlotForKind,
|
||||
resolveCapabilitySlotModeForKind,
|
||||
resolveCapabilitySlotSelection,
|
||||
type CapabilitySlotId,
|
||||
type PluginCapabilityKind,
|
||||
type PluginCapabilitySlotMode,
|
||||
} from "./capabilities.js";
|
||||
@ -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" })]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user