diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 5f3447f4e8b..25b5cae0f59 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -113,6 +113,7 @@ export function createOpenClawTools( const webSearchTool = createWebSearchTool({ config: options?.config, sandboxed: options?.sandboxed, + runtimeWebSearch: runtimeWebTools?.search, }); const webFetchTool = createWebFetchTool({ config: options?.config, diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 082e9ae31eb..91bcd9d99c4 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -11,6 +11,7 @@ import type { SearchProviderRequest, SearchProviderSuccessResult, } from "../../plugins/types.js"; +import type { RuntimeWebSearchMetadata } from "../../secrets/runtime-web-tools.js"; import { wrapWebContent } from "../../security/external-content.js"; import { normalizeSecretInput } from "../../utils/normalize-secret-input.js"; import type { AnyAgentTool } from "./common.js"; @@ -2476,6 +2477,7 @@ function getPluginSearchProviders(): SearchProviderPlugin[] { function resolvePreferredBuiltinSearchProvider(params: { search?: WebSearchConfig; + runtimeWebSearch?: RuntimeWebSearchMetadata; }): BuiltinSearchProviderId { const configuredProviderId = normalizeSearchProviderId( typeof params.search?.provider === "string" ? params.search.provider : undefined, @@ -2484,12 +2486,27 @@ function resolvePreferredBuiltinSearchProvider(params: { return configuredProviderId; } + if ( + params.runtimeWebSearch?.providerConfigured && + params.runtimeWebSearch.providerConfigured === configuredProviderId + ) { + return params.runtimeWebSearch.providerConfigured; + } + + if ( + params.runtimeWebSearch?.selectedProvider && + params.runtimeWebSearch.providerSource !== "none" + ) { + return params.runtimeWebSearch.selectedProvider; + } + return resolveBuiltinSearchProvider(params.search); } function resolveRegisteredSearchProvider(params: { search?: WebSearchConfig; config?: OpenClawConfig; + runtimeWebSearch?: RuntimeWebSearchMetadata; }): SearchProviderPlugin { const configuredProviderId = normalizeSearchProviderId( typeof params.search?.provider === "string" ? params.search.provider : undefined, @@ -2511,7 +2528,16 @@ function resolveRegisteredSearchProvider(params: { } } else { for (const provider of pluginProviders.values()) { - if (provider.isAvailable?.(params.config)) { + let isAvailable = false; + try { + isAvailable = provider.isAvailable?.(params.config) ?? false; + } catch (error) { + logVerbose( + `web_search: plugin provider "${provider.id}" auto-detect failed during isAvailable: ${error instanceof Error ? error.message : String(error)}`, + ); + continue; + } + if (isAvailable) { logVerbose( `web_search: no provider configured, auto-detected plugin provider "${provider.id}"`, ); @@ -2531,6 +2557,7 @@ function resolveRegisteredSearchProvider(params: { builtinProviders.get( resolvePreferredBuiltinSearchProvider({ search: params.search, + runtimeWebSearch: params.runtimeWebSearch, }), ) ?? builtinProviders.get(DEFAULT_PROVIDER)! ); @@ -2539,11 +2566,14 @@ function resolveRegisteredSearchProvider(params: { function createSearchProviderSchema(params: { provider: SearchProviderPlugin; search?: WebSearchConfig; + runtimeWebSearch?: RuntimeWebSearchMetadata; }) { const providerId = normalizeSearchProviderId(params.provider.id); if (!params.provider.pluginId && isBuiltinSearchProviderId(providerId)) { - const perplexityConfig = resolvePerplexityConfig(params.search); - const perplexityTransport = resolvePerplexitySchemaTransportHint(perplexityConfig); + const perplexityTransport = + params.runtimeWebSearch?.selectedProvider === "perplexity" + ? params.runtimeWebSearch.perplexityTransport + : resolvePerplexitySchemaTransportHint(resolvePerplexityConfig(params.search)); return createWebSearchSchema({ provider: providerId, perplexityTransport: providerId === "perplexity" ? perplexityTransport : undefined, @@ -2605,6 +2635,7 @@ function resolveSearchProviderPluginConfig( export function createWebSearchTool(options?: { config?: OpenClawConfig; sandboxed?: boolean; + runtimeWebSearch?: RuntimeWebSearchMetadata; }): AnyAgentTool | null { const search = resolveSearchConfig(options?.config); if (!resolveSearchEnabled({ search, sandboxed: options?.sandboxed })) { @@ -2614,10 +2645,12 @@ export function createWebSearchTool(options?: { const provider = resolveRegisteredSearchProvider({ search, config: options?.config, + runtimeWebSearch: options?.runtimeWebSearch, }); const parameters = createSearchProviderSchema({ provider, search, + runtimeWebSearch: options?.runtimeWebSearch, }); const description = provider.description ?? diff --git a/src/commands/configure.wizard.test.ts b/src/commands/configure.wizard.test.ts index 034a3fdf505..d4e0c2b3501 100644 --- a/src/commands/configure.wizard.test.ts +++ b/src/commands/configure.wizard.test.ts @@ -158,4 +158,58 @@ describe("runConfigureWizard", () => { expect(runtime.exit).toHaveBeenCalledWith(1); }); + + it("preserves an existing plugin web search provider when keeping the current provider", async () => { + mocks.readConfigFileSnapshot.mockResolvedValue({ + exists: true, + valid: true, + config: { + tools: { + web: { + search: { + provider: "searxng", + enabled: true, + }, + }, + }, + }, + issues: [], + }); + mocks.resolveGatewayPort.mockReturnValue(18789); + mocks.probeGatewayReachable.mockResolvedValue({ ok: false }); + mocks.resolveControlUiLinks.mockReturnValue({ wsUrl: "ws://127.0.0.1:18789" }); + mocks.summarizeExistingConfig.mockReturnValue(""); + mocks.createClackPrompter.mockReturnValue({}); + mocks.ensureControlUiAssetsBuilt.mockResolvedValue({ ok: true }); + mocks.clackIntro.mockResolvedValue(undefined); + mocks.clackOutro.mockResolvedValue(undefined); + mocks.clackConfirm + .mockResolvedValueOnce(true) // enable web_search + .mockResolvedValueOnce(true); // enable web_fetch + mocks.clackSelect.mockResolvedValue("__keep_current__"); + mocks.clackText.mockResolvedValue(""); + + await runConfigureWizard( + { command: "configure", sections: ["web"] }, + { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }, + ); + + expect(mocks.clackText).not.toHaveBeenCalled(); + expect(mocks.writeConfigFile).toHaveBeenCalledWith( + expect.objectContaining({ + tools: expect.objectContaining({ + web: expect.objectContaining({ + search: expect.objectContaining({ + provider: "searxng", + enabled: true, + }), + }), + }), + }), + ); + }); }); diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index 80af67043ab..6a91648b11b 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -183,6 +183,13 @@ async function promptWebToolsConfig( return hasExistingKey(nextConfig, provider as SP) || hasKeyInEnv(entry); }; + const existingPluginProvider = + typeof existingSearch?.provider === "string" && + existingSearch.provider.trim() && + !SEARCH_PROVIDER_OPTIONS.some((e) => e.value === existingSearch.provider) + ? existingSearch.provider + : undefined; + const existingProvider: string = (() => { const stored = existingSearch?.provider; if (stored && SEARCH_PROVIDER_OPTIONS.some((e) => e.value === stored)) { @@ -227,53 +234,69 @@ async function promptWebToolsConfig( }; }); + type ProviderChoice = SP | "__keep_current__"; const providerChoice = guardCancel( - await select({ + await select({ message: "Choose web search provider", - options: providerOptions, - initialValue: existingProvider, + options: [ + ...(existingPluginProvider + ? [ + { + value: "__keep_current__" as const, + label: `Keep current provider (${existingPluginProvider})`, + hint: "Leave the current plugin-managed web_search provider unchanged", + }, + ] + : []), + ...providerOptions, + ], + initialValue: existingPluginProvider ? "__keep_current__" : existingProvider, }), runtime, ); - nextSearch = { ...nextSearch, provider: providerChoice }; - - const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === providerChoice)!; - const existingKey = resolveExistingKey(nextConfig, providerChoice as SP); - const keyConfigured = hasExistingKey(nextConfig, providerChoice as SP); - const envAvailable = entry.envKeys.some((k) => Boolean(process.env[k]?.trim())); - const envVarNames = entry.envKeys.join(" / "); - - const keyInput = guardCancel( - await text({ - message: keyConfigured - ? envAvailable - ? `${entry.label} API key (leave blank to keep current or use ${envVarNames})` - : `${entry.label} API key (leave blank to keep current)` - : envAvailable - ? `${entry.label} API key (paste it here; leave blank to use ${envVarNames})` - : `${entry.label} API key`, - placeholder: keyConfigured ? "Leave blank to keep current" : entry.placeholder, - }), - runtime, - ); - const key = String(keyInput ?? "").trim(); - - if (key || existingKey) { - const applied = applySearchKey(nextConfig, providerChoice as SP, (key || existingKey)!); - nextSearch = { ...applied.tools?.web?.search }; - } else if (keyConfigured || envAvailable) { - nextSearch = { ...nextSearch }; + if (providerChoice === "__keep_current__") { + nextSearch = { ...nextSearch, provider: existingPluginProvider }; } else { - note( - [ - "No key stored yet — web_search won't work until a key is available.", - `Store a key here or set ${envVarNames} in the Gateway environment.`, - `Get your API key at: ${entry.signupUrl}`, - "Docs: https://docs.openclaw.ai/tools/web", - ].join("\n"), - "Web search", + nextSearch = { ...nextSearch, provider: providerChoice }; + + const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === providerChoice)!; + const existingKey = resolveExistingKey(nextConfig, providerChoice as SP); + const keyConfigured = hasExistingKey(nextConfig, providerChoice as SP); + const envAvailable = entry.envKeys.some((k) => Boolean(process.env[k]?.trim())); + const envVarNames = entry.envKeys.join(" / "); + + const keyInput = guardCancel( + await text({ + message: keyConfigured + ? envAvailable + ? `${entry.label} API key (leave blank to keep current or use ${envVarNames})` + : `${entry.label} API key (leave blank to keep current)` + : envAvailable + ? `${entry.label} API key (paste it here; leave blank to use ${envVarNames})` + : `${entry.label} API key`, + placeholder: keyConfigured ? "Leave blank to keep current" : entry.placeholder, + }), + runtime, ); + const key = String(keyInput ?? "").trim(); + + if (key || existingKey) { + const applied = applySearchKey(nextConfig, providerChoice as SP, (key || existingKey)!); + nextSearch = { ...applied.tools?.web?.search }; + } else if (keyConfigured || envAvailable) { + nextSearch = { ...nextSearch }; + } else { + note( + [ + "No key stored yet — web_search won't work until a key is available.", + `Store a key here or set ${envVarNames} in the Gateway environment.`, + `Get your API key at: ${entry.signupUrl}`, + "Docs: https://docs.openclaw.ai/tools/web", + ].join("\n"), + "Web search", + ); + } } } diff --git a/src/commands/onboard-search.test.ts b/src/commands/onboard-search.test.ts index 10e2df9f81b..b3e22aec53f 100644 --- a/src/commands/onboard-search.test.ts +++ b/src/commands/onboard-search.test.ts @@ -80,6 +80,34 @@ describe("setupSearch", () => { expect(result).toBe(cfg); }); + it("preserves an existing plugin provider when user keeps current provider", async () => { + const cfg: OpenClawConfig = { + tools: { + web: { + search: { + provider: "searxng", + enabled: true, + }, + }, + }, + }; + const { prompter } = createPrompter({ selectValue: "__keep_current__" }); + const result = await setupSearch(cfg, runtime, prompter); + expect(result).toBe(cfg); + expect(prompter.text).not.toHaveBeenCalled(); + expect(prompter.select).toHaveBeenCalledWith( + expect.objectContaining({ + initialValue: "__keep_current__", + options: expect.arrayContaining([ + expect.objectContaining({ + value: "__keep_current__", + label: "Keep current provider (searxng)", + }), + ]), + }), + ); + }); + it("sets provider and key for perplexity", async () => { const cfg: OpenClawConfig = {}; const { prompter } = createPrompter({ diff --git a/src/commands/onboard-search.ts b/src/commands/onboard-search.ts index 86a085f6d0c..939c3a4d408 100644 --- a/src/commands/onboard-search.ts +++ b/src/commands/onboard-search.ts @@ -64,6 +64,10 @@ export const SEARCH_PROVIDER_OPTIONS: readonly SearchProviderEntry[] = [ }, ] as const; +function isBuiltinSearchProvider(value: string): value is SearchProvider { + return SEARCH_PROVIDER_OPTIONS.some((entry) => entry.value === value); +} + export function hasKeyInEnv(entry: SearchProviderEntry): boolean { return entry.envKeys.some((k) => Boolean(process.env[k]?.trim())); } @@ -205,6 +209,12 @@ export async function setupSearch( ); const existingProvider = config.tools?.web?.search?.provider; + const existingPluginProvider = + typeof existingProvider === "string" && + existingProvider.trim() && + !isBuiltinSearchProvider(existingProvider) + ? existingProvider + : undefined; const options = SEARCH_PROVIDER_OPTIONS.map((entry) => { const configured = hasExistingKey(config, entry.value) || hasKeyInEnv(entry); @@ -225,10 +235,19 @@ export async function setupSearch( return SEARCH_PROVIDER_OPTIONS[0].value; })(); - type PickerValue = SearchProvider | "__skip__"; + type PickerValue = SearchProvider | "__skip__" | "__keep_current__"; const choice = await prompter.select({ message: "Search provider", options: [ + ...(existingPluginProvider + ? [ + { + value: "__keep_current__" as const, + label: `Keep current provider (${existingPluginProvider})`, + hint: "Leave the current plugin-managed web_search provider unchanged", + }, + ] + : []), ...options, { value: "__skip__" as const, @@ -236,10 +255,10 @@ export async function setupSearch( hint: "Configure later with openclaw configure --section web", }, ], - initialValue: defaultProvider as PickerValue, + initialValue: (existingPluginProvider ? "__keep_current__" : defaultProvider) as PickerValue, }); - if (choice === "__skip__") { + if (choice === "__skip__" || choice === "__keep_current__") { return config; } diff --git a/src/config/config.web-search-provider.test.ts b/src/config/config.web-search-provider.test.ts index d99398e2df2..450548853eb 100644 --- a/src/config/config.web-search-provider.test.ts +++ b/src/config/config.web-search-provider.test.ts @@ -67,6 +67,28 @@ describe("web search provider config", () => { expect(res.ok).toBe(true); }); + it("surfaces plugin loading failures during plugin-aware validation", () => { + loadOpenClawPlugins.mockImplementation(() => { + throw new Error("plugin import failed"); + }); + + const res = validateConfigObjectWithPlugins( + buildWebSearchProviderConfig({ + provider: "searxng", + }), + ); + + expect(res.ok).toBe(false); + expect( + res.issues.some( + (issue) => + issue.path === "tools.web.search.provider" && + issue.message.includes("plugin loading failed") && + issue.message.includes("plugin import failed"), + ), + ).toBe(true); + }); + it("rejects invalid custom plugin provider ids", () => { const res = validateConfigObject( buildWebSearchProviderConfig({ diff --git a/src/config/validation.ts b/src/config/validation.ts index 457476b27fd..f9c67c57257 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -422,8 +422,13 @@ function validateConfigObjectWithPluginsBase( if (registered) { return; } - } catch { - // Fall through and surface the unknown provider issue below. + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + issues.push({ + path: "tools.web.search.provider", + message: `could not validate web search provider "${provider}" because plugin loading failed: ${detail}`, + }); + return; } if (provider.trim()) {