From 0997deb0e9b253b0eaff291091277c249a35e9d5 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Wed, 11 Mar 2026 23:24:03 -0500 Subject: [PATCH] feat: add plugin capability slots and diagnostics --- extensions/tavily-search/openclaw.plugin.json | 1 + src/agents/tools/web-search.ts | 29 +++- src/commands/doctor-config-flow.test.ts | 53 ++++++- src/commands/onboard-search.ts | 131 +++++++----------- src/commands/provider-management.ts | 78 +++++++++++ src/config/config.plugin-validation.test.ts | 98 +++++++++++++ src/config/validation.ts | 57 +++++++- src/plugins/capabilities.ts | 115 +++++++++++++++ src/plugins/capability-slots.test.ts | 54 ++++++++ src/plugins/capability-slots.ts | 10 ++ src/plugins/loader.test.ts | 115 ++++++++++++++- src/plugins/loader.ts | 123 ++++++++++++++++ src/plugins/manifest-registry.ts | 6 + src/plugins/manifest.ts | 9 ++ src/plugins/registry.search-provider.test.ts | 16 +++ src/plugins/registry.ts | 88 ++++++++++++ src/plugins/slots.ts | 37 +++-- src/plugins/types.ts | 7 + src/wizard/onboarding.finalize.ts | 3 +- 19 files changed, 927 insertions(+), 103 deletions(-) create mode 100644 src/commands/provider-management.ts create mode 100644 src/plugins/capabilities.ts create mode 100644 src/plugins/capability-slots.test.ts create mode 100644 src/plugins/capability-slots.ts diff --git a/extensions/tavily-search/openclaw.plugin.json b/extensions/tavily-search/openclaw.plugin.json index 6983311cfab..a400ff5eec6 100644 --- a/extensions/tavily-search/openclaw.plugin.json +++ b/extensions/tavily-search/openclaw.plugin.json @@ -1,5 +1,6 @@ { "id": "tavily-search", + "provides": ["providers.search.tavily"], "uiHints": { "apiKey": { "label": "Tavily API key", diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 1af29765adc..b96f67dead1 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -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, }), diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index a4d361df22e..08c51060acb 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -28,7 +28,7 @@ async function collectDoctorWarnings(config: Record): Promise 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: { diff --git a/src/commands/onboard-search.ts b/src/commands/onboard-search.ts index 415cb940738..0638f517f35 100644 --- a/src/commands/onboard-search.ts +++ b/src/commands/onboard-search.ts @@ -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 { - 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, }, }, diff --git a/src/commands/provider-management.ts b/src/commands/provider-management.ts new file mode 100644 index 00000000000..358e974fd25 --- /dev/null +++ b/src/commands/provider-management.ts @@ -0,0 +1,78 @@ +import type { WizardPrompter } from "../wizard/prompts.js"; + +export type ProviderManagementIntent = "switch-active" | "configure-provider"; + +export type ProviderManagementOption = { + 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 { + if (params.configuredCount <= 1) { + return "switch-active"; + } + return await params.prompter.select({ + 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(params: { + intent: ProviderManagementIntent; + options: Array>; + activeValue?: string; + activePrefix?: string; + hiddenValues?: Iterable; +}): Array> { + 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, + ); +} diff --git a/src/config/config.plugin-validation.test.ts b/src/config/config.plugin-validation.test.ts index d7e6ae46aca..b6f4cb35d4c 100644 --- a/src/config/config.plugin-validation.test.ts +++ b/src/config/config.plugin-validation.test.ts @@ -22,6 +22,7 @@ async function writePluginFixture(params: { id: string; schema: Record; channels?: string[]; + manifest?: Record; }) { await mkdirSafe(params.dir); await fs.writeFile( @@ -32,6 +33,7 @@ async function writePluginFixture(params: { const manifest: Record = { 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" }] }, diff --git a/src/config/validation.ts b/src/config/validation.ts index a1c96e4dba6..582669a54cd 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -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; } diff --git a/src/plugins/capabilities.ts b/src/plugins/capabilities.ts new file mode 100644 index 00000000000..5b41754561d --- /dev/null +++ b/src/plugins/capabilities.ts @@ -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 = { + "search-provider": { + capabilityPrefix: "providers.search", + slot: "providers.search", + slotMode: "multi", + }, +}; + +const CAPABILITY_SLOT_DEFINITIONS: Record = { + "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); +} diff --git a/src/plugins/capability-slots.test.ts b/src/plugins/capability-slots.test.ts new file mode 100644 index 00000000000..ddd9b666121 --- /dev/null +++ b/src/plugins/capability-slots.test.ts @@ -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"); + }); +}); diff --git a/src/plugins/capability-slots.ts b/src/plugins/capability-slots.ts new file mode 100644 index 00000000000..f188e0f4950 --- /dev/null +++ b/src/plugins/capability-slots.ts @@ -0,0 +1,10 @@ +export { + applyCapabilitySlotSelection, + resolveCapabilitySlotConfigPath, + resolveCapabilitySlotForKind, + resolveCapabilitySlotModeForKind, + resolveCapabilitySlotSelection, + type CapabilitySlotId, + type PluginCapabilityKind, + type PluginCapabilitySlotMode, +} from "./capabilities.js"; diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 6622c09bd23..b4be46d6c94 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -97,6 +97,7 @@ function writePlugin(params: { body: string; dir?: string; filename?: string; + manifest?: Record; }): 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" })]), + ); + }); }); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 4a7e670e30c..0f1388583c8 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -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 { + return new Set([...plugin.declaredCapabilities, ...plugin.capabilityIds]); +} + +function evaluateCapabilityRelationships(params: { + activePlugins: PluginRecord[]; + candidatePlugin?: PluginRecord; +}): PluginDiagnostic[] { + const diagnostics: PluginDiagnostic[] = []; + const activeCapabilityOwners = new Map(); + + 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, diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 7b6a0ca4bfb..b522e5691d4 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -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, diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 3a3abe0a620..d935450dfcf 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -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 | undefined; if (isRecord(raw.uiHints)) { @@ -109,6 +115,9 @@ export function loadPluginManifest( channels, providers, skills, + provides, + requires, + conflicts, name, description, version, diff --git a/src/plugins/registry.search-provider.test.ts b/src/plugins/registry.search-provider.test.ts index 4eb9ccde914..517269acced 100644 --- a/src/plugins/registry.search-provider.test.ts +++ b/src/plugins/registry.search-provider.test.ts @@ -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({ diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 21cca57076b..0d5aca723db 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -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 = { + 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 = (params: { + record: PluginRecord; + kind: PluginCapabilityKind; + id: string; + value: T; + slotMode?: PluginCapabilitySlotMode; + duplicateMessage: string; + }): PluginCapabilityRegistration | 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 = { + 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, diff --git a/src/plugins/slots.ts b/src/plugins/slots.ts index bcbbdd44a03..fd6ff962ea9 100644 --- a/src/plugins/slots.ts +++ b/src/plugins/slots.ts @@ -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, }, }, diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 99b214eb096..d69bffc83d1 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -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; }; // ============================================================================ diff --git a/src/wizard/onboarding.finalize.ts b/src/wizard/onboarding.finalize.ts index d30470b0e93..4a7b305460b 100644 --- a/src/wizard/onboarding.finalize.ts +++ b/src/wizard/onboarding.finalize.ts @@ -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 {