diff --git a/extensions/diffs/index.test.ts b/extensions/diffs/index.test.ts index c38da12bfcd..fe290c1cdba 100644 --- a/extensions/diffs/index.test.ts +++ b/extensions/diffs/index.test.ts @@ -80,6 +80,18 @@ describe("diffs plugin registration", () => { registerHttpRoute(params: RegisteredHttpRouteParams) { registeredHttpRouteHandler = params.handler; }, + registerChannel() {}, + registerGatewayMethod() {}, + registerCli() {}, + registerService() {}, + registerProvider() {}, + registerSearchProvider() {}, + registerCommand() {}, + registerContextEngine() {}, + resolvePath(input: string) { + return input; + }, + on() {}, }); plugin.register?.(api as unknown as OpenClawPluginApi); diff --git a/extensions/lobster/src/lobster-tool.test.ts b/extensions/lobster/src/lobster-tool.test.ts index 40e9a0b64e8..a34e28a21f6 100644 --- a/extensions/lobster/src/lobster-tool.test.ts +++ b/extensions/lobster/src/lobster-tool.test.ts @@ -43,6 +43,7 @@ function fakeApi(overrides: Partial = {}): OpenClawPluginApi registerCli() {}, registerService() {}, registerProvider() {}, + registerSearchProvider() {}, registerHook() {}, registerHttpRoute() {}, registerCommand() {}, diff --git a/extensions/phone-control/index.test.ts b/extensions/phone-control/index.test.ts index 2c3462c82a9..f0813b7b821 100644 --- a/extensions/phone-control/index.test.ts +++ b/extensions/phone-control/index.test.ts @@ -31,6 +31,17 @@ function createApi(params: { writeConfigFile: (next: Record) => params.writeConfig(next), }, } as OpenClawPluginApi["runtime"], + logger: { info() {}, warn() {}, error() {} }, + registerTool() {}, + registerHook() {}, + registerHttpRoute() {}, + registerChannel() {}, + registerGatewayMethod() {}, + registerCli() {}, + registerService() {}, + registerProvider() {}, + registerSearchProvider() {}, + registerContextEngine() {}, registerCommand: params.registerCommand, }) as OpenClawPluginApi; } diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 25b5cae0f59..5f3447f4e8b 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -113,7 +113,6 @@ 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 6e9518f1ede..082e9ae31eb 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -3,7 +3,14 @@ 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 type { RuntimeWebSearchMetadata } from "../../secrets/runtime-web-tools.js"; +import { getActivePluginRegistry } from "../../plugins/runtime.js"; +import type { + SearchProviderContext, + SearchProviderErrorResult, + SearchProviderPlugin, + SearchProviderRequest, + SearchProviderSuccessResult, +} from "../../plugins/types.js"; import { wrapWebContent } from "../../security/external-content.js"; import { normalizeSecretInput } from "../../utils/normalize-secret-input.js"; import type { AnyAgentTool } from "./common.js"; @@ -25,6 +32,7 @@ import { const SEARCH_PROVIDERS = ["brave", "gemini", "grok", "kimi", "perplexity"] as const; const DEFAULT_SEARCH_COUNT = 5; const MAX_SEARCH_COUNT = 10; +const DEFAULT_PROVIDER = "brave"; const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search"; const BRAVE_LLM_CONTEXT_ENDPOINT = "https://api.search.brave.com/res/v1/llm/context"; @@ -127,6 +135,78 @@ const RECENCY_TO_FRESHNESS: Record = { const ISO_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/; const PERPLEXITY_DATE_PATTERN = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/; +type BuiltinSearchProviderId = (typeof SEARCH_PROVIDERS)[number]; + +const SEARCH_QUERY_SCHEMA_FIELDS = { + query: Type.String({ description: "Search query string." }), + count: Type.Optional( + Type.Number({ + description: "Number of results to return (1-10).", + minimum: 1, + maximum: MAX_SEARCH_COUNT, + }), + ), +} as const; + +const SEARCH_FILTER_SCHEMA_FIELDS = { + country: Type.Optional( + Type.String({ + description: + "2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.", + }), + ), + language: Type.Optional( + Type.String({ + description: "ISO 639-1 language code for results (e.g., 'en', 'de', 'fr').", + }), + ), + freshness: Type.Optional( + Type.String({ + description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'.", + }), + ), + date_after: Type.Optional( + Type.String({ + description: "Only results published after this date (YYYY-MM-DD).", + }), + ), + date_before: Type.Optional( + Type.String({ + description: "Only results published before this date (YYYY-MM-DD).", + }), + ), +} as const; + +const SEARCH_PLUGIN_EXTENSION_FIELDS = { + search_lang: Type.Optional( + Type.String({ + description: "Optional provider-specific search language override.", + }), + ), + ui_lang: Type.Optional( + Type.String({ + description: "Optional provider-specific UI locale override.", + }), + ), + domain_filter: Type.Optional( + Type.Array(Type.String(), { + description: "Optional provider-specific domain allow/deny filter.", + }), + ), + max_tokens: Type.Optional( + Type.Number({ + description: "Optional provider-specific content budget.", + minimum: 1, + }), + ), + max_tokens_per_page: Type.Optional( + Type.Number({ + description: "Optional provider-specific per-page content budget.", + minimum: 1, + }), + ), +} as const; + function isoToPerplexityDate(iso: string): string | undefined { const match = iso.match(ISO_DATE_PATTERN); if (!match) { @@ -154,46 +234,6 @@ function createWebSearchSchema(params: { provider: (typeof SEARCH_PROVIDERS)[number]; perplexityTransport?: PerplexityTransport; }) { - const querySchema = { - query: Type.String({ description: "Search query string." }), - count: Type.Optional( - Type.Number({ - description: "Number of results to return (1-10).", - minimum: 1, - maximum: MAX_SEARCH_COUNT, - }), - ), - } as const; - - const filterSchema = { - country: Type.Optional( - Type.String({ - description: - "2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.", - }), - ), - language: Type.Optional( - Type.String({ - description: "ISO 639-1 language code for results (e.g., 'en', 'de', 'fr').", - }), - ), - freshness: Type.Optional( - Type.String({ - description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'.", - }), - ), - date_after: Type.Optional( - Type.String({ - description: "Only results published after this date (YYYY-MM-DD).", - }), - ), - date_before: Type.Optional( - Type.String({ - description: "Only results published before this date (YYYY-MM-DD).", - }), - ), - } as const; - const perplexityStructuredFilterSchema = { country: Type.Optional( Type.String({ @@ -223,8 +263,8 @@ function createWebSearchSchema(params: { if (params.provider === "brave") { return Type.Object({ - ...querySchema, - ...filterSchema, + ...SEARCH_QUERY_SCHEMA_FIELDS, + ...SEARCH_FILTER_SCHEMA_FIELDS, search_lang: Type.Optional( Type.String({ description: @@ -243,13 +283,13 @@ function createWebSearchSchema(params: { if (params.provider === "perplexity") { if (params.perplexityTransport === "chat_completions") { return Type.Object({ - ...querySchema, - freshness: filterSchema.freshness, + ...SEARCH_QUERY_SCHEMA_FIELDS, + freshness: SEARCH_FILTER_SCHEMA_FIELDS.freshness, }); } return Type.Object({ - ...querySchema, - freshness: filterSchema.freshness, + ...SEARCH_QUERY_SCHEMA_FIELDS, + freshness: SEARCH_FILTER_SCHEMA_FIELDS.freshness, ...perplexityStructuredFilterSchema, domain_filter: Type.Optional( Type.Array(Type.String(), { @@ -277,8 +317,8 @@ function createWebSearchSchema(params: { // grok, gemini, kimi, etc. return Type.Object({ - ...querySchema, - ...filterSchema, + ...SEARCH_QUERY_SCHEMA_FIELDS, + ...SEARCH_FILTER_SCHEMA_FIELDS, }); } @@ -601,7 +641,7 @@ function missingSearchKeyPayload(provider: (typeof SEARCH_PROVIDERS)[number]) { }; } -function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDERS)[number] { +function resolveBuiltinSearchProvider(search?: WebSearchConfig): BuiltinSearchProviderId { const raw = search && "provider" in search && typeof search.provider === "string" ? search.provider.trim().toLowerCase() @@ -1615,10 +1655,37 @@ async function runWebSearch(params: { : params.provider === "kimi" ? `${params.kimiBaseUrl ?? DEFAULT_KIMI_BASE_URL}:${params.kimiModel ?? DEFAULT_KIMI_MODEL}` : ""; + const requestCacheIdentity = buildSearchRequestCacheIdentity({ + query: params.query, + count: params.count, + country: params.country, + language: params.language, + search_lang: params.search_lang, + ui_lang: params.ui_lang, + freshness: params.freshness, + dateAfter: params.dateAfter, + dateBefore: params.dateBefore, + domainFilter: params.searchDomainFilter, + maxTokens: params.maxTokens, + maxTokensPerPage: params.maxTokensPerPage, + }); const cacheKey = normalizeCacheKey( params.provider === "brave" && effectiveBraveMode === "llm-context" - ? `${params.provider}:llm-context:${params.query}:${params.country || "default"}:${params.search_lang || params.language || "default"}:${params.freshness || "default"}` - : `${params.provider}:${effectiveBraveMode}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || params.language || "default"}:${params.ui_lang || "default"}:${params.freshness || "default"}:${params.dateAfter || "default"}:${params.dateBefore || "default"}:${params.searchDomainFilter?.join(",") || "default"}:${params.maxTokens || "default"}:${params.maxTokensPerPage || "default"}:${providerSpecificKey}`, + ? `${params.provider}:llm-context:${buildSearchRequestCacheIdentity({ + query: params.query, + count: params.count, + country: params.country, + language: params.language, + search_lang: params.search_lang, + ui_lang: undefined, + freshness: params.freshness, + dateAfter: undefined, + dateBefore: undefined, + domainFilter: undefined, + maxTokens: undefined, + maxTokensPerPage: undefined, + })}` + : `${params.provider}:${effectiveBraveMode}:${requestCacheIdentity}:${providerSpecificKey}`, ); const cached = readCache(SEARCH_CACHE, cacheKey); if (cached) { @@ -1888,174 +1955,684 @@ async function runWebSearch(params: { return payload; } -export function createWebSearchTool(options?: { - config?: OpenClawConfig; - sandboxed?: boolean; - runtimeWebSearch?: RuntimeWebSearchMetadata; -}): AnyAgentTool | null { - const search = resolveSearchConfig(options?.config); - if (!resolveSearchEnabled({ search, sandboxed: options?.sandboxed })) { - return null; +function normalizeSearchProviderId(value: string | undefined): string { + return value?.trim().toLowerCase() ?? ""; +} + +function isBuiltinSearchProviderId(value: string): value is BuiltinSearchProviderId { + return SEARCH_PROVIDERS.includes(value as BuiltinSearchProviderId); +} + +function stableSerializeForCache(value: unknown): string { + if (value === null || value === undefined) { + return String(value); + } + if (typeof value === "string") { + return JSON.stringify(value); + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + if (Array.isArray(value)) { + return `[${value.map((entry) => stableSerializeForCache(entry)).join(",")}]`; + } + if (typeof value === "object") { + const entries = Object.entries(value as Record).toSorted(([a], [b]) => + a.localeCompare(b), + ); + return `{${entries + .map(([key, entryValue]) => `${JSON.stringify(key)}:${stableSerializeForCache(entryValue)}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +function buildSearchRequestCacheIdentity(params: { + query: string; + count: number; + country?: string; + language?: string; + search_lang?: string; + ui_lang?: string; + freshness?: string; + dateAfter?: string; + dateBefore?: string; + domainFilter?: string[]; + maxTokens?: number; + maxTokensPerPage?: number; +}): string { + return [ + params.query, + params.count, + params.country || "default", + params.language || "default", + params.search_lang || "default", + params.ui_lang || "default", + params.freshness || "default", + params.dateAfter || "default", + params.dateBefore || "default", + params.domainFilter?.join(",") || "default", + params.maxTokens || "default", + params.maxTokensPerPage || "default", + ].join(":"); +} + +function createExtensibleWebSearchSchema() { + return Type.Object({ + ...SEARCH_QUERY_SCHEMA_FIELDS, + ...SEARCH_FILTER_SCHEMA_FIELDS, + ...SEARCH_PLUGIN_EXTENSION_FIELDS, + }); +} + +function sanitizeSearchUrl(url: unknown): string | undefined { + if (typeof url !== "string" || url.trim() === "") { + return undefined; + } + try { + const parsed = new URL(url); + if (parsed.protocol === "http:" || parsed.protocol === "https:") { + return parsed.href; + } + } catch { + // Ignore invalid URLs from plugin providers. + } + return undefined; +} + +function createMissingSearchProviderPlugin(providerId: string): SearchProviderPlugin { + return { + id: providerId, + name: providerId, + description: `Search provider "${providerId}" is configured but not registered.`, + search: async () => ({ + error: "unknown_search_provider", + message: `Configured web search provider "${providerId}" is not registered.`, + docs: "https://docs.openclaw.ai/tools/web", + }), + }; +} + +function executePluginSearchProvider(params: { + provider: SearchProviderPlugin; + request: SearchProviderRequest; + context: SearchProviderContext; +}): Promise> { + const pluginConfigKey = params.context.pluginConfig + ? stableSerializeForCache(params.context.pluginConfig) + : "no-plugin-config"; + const cacheKey = normalizeCacheKey( + `${params.provider.id}:${params.provider.pluginId || "builtin"}:${pluginConfigKey}:${buildSearchRequestCacheIdentity( + { + query: params.request.query, + count: params.request.count, + country: params.request.country, + language: params.request.language, + search_lang: params.request.search_lang, + ui_lang: params.request.ui_lang, + freshness: params.request.freshness, + dateAfter: params.request.dateAfter, + dateBefore: params.request.dateBefore, + domainFilter: params.request.domainFilter, + maxTokens: params.request.maxTokens, + maxTokensPerPage: params.request.maxTokensPerPage, + }, + )}`, + ); + const cached = readCache(SEARCH_CACHE, cacheKey); + if (cached) { + return Promise.resolve({ ...cached.value, cached: true }); } - const provider = - options?.runtimeWebSearch?.selectedProvider ?? - options?.runtimeWebSearch?.providerConfigured ?? - resolveSearchProvider(search); + const startedAt = Date.now(); + return params.provider + .search(params.request, params.context) + .then((result) => { + if ("error" in result && typeof result.error === "string") { + const errorResult: SearchProviderErrorResult = result; + return { + ...errorResult, + provider: params.provider.id, + ...(typeof errorResult.message === "string" + ? {} + : { + message: `Search provider "${params.provider.id}" returned error "${errorResult.error}".`, + }), + }; + } + + const successResult: SearchProviderSuccessResult = result; + const rawResults = Array.isArray(successResult.results) + ? successResult.results.filter((entry) => entry && typeof entry === "object") + : []; + const normalizedResults = rawResults + .map((entry) => { + const value = entry as Record; + const url = sanitizeSearchUrl(value.url); + if (!url) { + return undefined; + } + const title = + typeof value.title === "string" ? wrapWebContent(value.title, "web_search") : ""; + const description = + typeof value.description === "string" + ? wrapWebContent(value.description, "web_search") + : undefined; + const published = typeof value.published === "string" ? value.published : undefined; + return { title, url, description, published }; + }) + .filter((entry): entry is NonNullable => Boolean(entry)); + + const rawCitations = Array.isArray(successResult.citations) ? successResult.citations : []; + const normalizedCitations = rawCitations + .map((citation) => { + if (typeof citation === "string") { + return sanitizeSearchUrl(citation); + } + if (!citation || typeof citation !== "object") { + return undefined; + } + const value = citation as Record; + const url = sanitizeSearchUrl(value.url); + if (!url) { + return undefined; + } + const title = + typeof value.title === "string" ? wrapWebContent(value.title, "web_search") : undefined; + return title ? { url, title } : { url }; + }) + .filter((entry): entry is NonNullable => Boolean(entry)); + + const payload: Record = { + query: params.request.query, + provider: params.provider.id, + tookMs: + typeof successResult.tookMs === "number" && Number.isFinite(successResult.tookMs) + ? successResult.tookMs + : Date.now() - startedAt, + externalContent: { + untrusted: true, + source: "web_search", + provider: params.provider.id, + wrapped: true, + }, + }; + if (normalizedResults.length > 0) { + payload.results = normalizedResults; + payload.count = normalizedResults.length; + } + if (typeof successResult.content === "string") { + payload.content = wrapWebContent(successResult.content, "web_search"); + } + if (normalizedCitations.length > 0) { + payload.citations = normalizedCitations; + } + writeCache(SEARCH_CACHE, cacheKey, payload, params.context.cacheTtlMs); + return payload; + }) + .catch((error) => ({ + error: "search_failed", + provider: params.provider.id, + message: error instanceof Error ? error.message : String(error), + })); +} + +function executeBuiltinSearchProvider(params: { + provider: BuiltinSearchProviderId; + request: SearchProviderRequest; + context: SearchProviderContext; +}): Promise> { + const search = params.request.providerConfig as WebSearchConfig | undefined; + const provider = params.provider; const perplexityConfig = resolvePerplexityConfig(search); - const perplexitySchemaTransportHint = - options?.runtimeWebSearch?.perplexityTransport ?? - resolvePerplexitySchemaTransportHint(perplexityConfig); const grokConfig = resolveGrokConfig(search); const geminiConfig = resolveGeminiConfig(search); const kimiConfig = resolveKimiConfig(search); const braveConfig = resolveBraveConfig(search); const braveMode = resolveBraveMode(braveConfig); - const description = + const perplexityRuntime = + provider === "perplexity" ? resolvePerplexityTransport(perplexityConfig) : undefined; + const apiKey = provider === "perplexity" - ? perplexitySchemaTransportHint === "chat_completions" - ? "Search the web using Perplexity Sonar via Perplexity/OpenRouter chat completions. Returns AI-synthesized answers with citations from web-grounded search." - : "Search the web using Perplexity. Runtime routing decides between native Search API and Sonar chat-completions compatibility. Structured filters are available on the native Search API path." + ? perplexityRuntime?.apiKey : provider === "grok" - ? "Search the web using xAI Grok. Returns AI-synthesized answers with citations from real-time web search." + ? resolveGrokApiKey(grokConfig) : provider === "kimi" - ? "Search the web using Kimi by Moonshot. Returns AI-synthesized answers with citations from native $web_search." + ? resolveKimiApiKey(kimiConfig) : provider === "gemini" - ? "Search the web using Gemini with Google Search grounding. Returns AI-synthesized answers with citations from Google Search." - : braveMode === "llm-context" - ? "Search the web using Brave Search LLM Context API. Returns pre-extracted page content (text chunks, tables, code blocks) optimized for LLM grounding." - : "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research."; + ? resolveGeminiApiKey(geminiConfig) + : resolveSearchApiKey(search); + + if (!apiKey) { + return Promise.resolve(missingSearchKeyPayload(provider)); + } + + const supportsStructuredPerplexityFilters = + provider === "perplexity" && perplexityRuntime?.transport === "search_api"; + if ( + params.request.country && + provider !== "brave" && + !(provider === "perplexity" && supportsStructuredPerplexityFilters) + ) { + return Promise.resolve({ + error: "unsupported_country", + message: + provider === "perplexity" + ? "country filtering is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it." + : `country filtering is not supported by the ${provider} provider. Only Brave and Perplexity support country filtering.`, + docs: "https://docs.openclaw.ai/tools/web", + }); + } + if ( + params.request.language && + provider !== "brave" && + !(provider === "perplexity" && supportsStructuredPerplexityFilters) + ) { + return Promise.resolve({ + error: "unsupported_language", + message: + provider === "perplexity" + ? "language filtering is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it." + : `language filtering is not supported by the ${provider} provider. Only Brave and Perplexity support language filtering.`, + docs: "https://docs.openclaw.ai/tools/web", + }); + } + if ( + params.request.language && + provider === "perplexity" && + !/^[a-z]{2}$/i.test(params.request.language) + ) { + return Promise.resolve({ + error: "invalid_language", + message: "language must be a 2-letter ISO 639-1 code like 'en', 'de', or 'fr'.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + const normalizedBraveLanguageParams = + provider === "brave" + ? normalizeBraveLanguageParams({ + search_lang: params.request.search_lang || params.request.language, + ui_lang: params.request.ui_lang, + }) + : { search_lang: params.request.language, ui_lang: params.request.ui_lang }; + if (normalizedBraveLanguageParams.invalidField === "search_lang") { + return Promise.resolve({ + error: "invalid_search_lang", + message: + "search_lang must be a Brave-supported language code like 'en', 'en-gb', 'zh-hans', or 'zh-hant'.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + if (normalizedBraveLanguageParams.invalidField === "ui_lang") { + return Promise.resolve({ + error: "invalid_ui_lang", + message: "ui_lang must be a language-region locale like 'en-US'.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + if ( + normalizedBraveLanguageParams.ui_lang && + provider === "brave" && + braveMode === "llm-context" + ) { + return Promise.resolve({ + error: "unsupported_ui_lang", + message: + "ui_lang is not supported by Brave llm-context mode. Remove ui_lang or use Brave web mode for locale-based UI hints.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + if (params.request.freshness && provider !== "brave" && provider !== "perplexity") { + return Promise.resolve({ + error: "unsupported_freshness", + message: `freshness filtering is not supported by the ${provider} provider. Only Brave and Perplexity support freshness.`, + docs: "https://docs.openclaw.ai/tools/web", + }); + } + if (params.request.freshness && provider === "brave" && braveMode === "llm-context") { + return Promise.resolve({ + error: "unsupported_freshness", + message: + "freshness filtering is not supported by Brave llm-context mode. Remove freshness or use Brave web mode.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + const normalizedFreshness = params.request.freshness + ? normalizeFreshness(params.request.freshness, provider) + : undefined; + if (params.request.freshness && !normalizedFreshness) { + return Promise.resolve({ + error: "invalid_freshness", + message: "freshness must be day, week, month, or year.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + if ( + (params.request.dateAfter || params.request.dateBefore) && + provider !== "brave" && + !(provider === "perplexity" && supportsStructuredPerplexityFilters) + ) { + return Promise.resolve({ + error: "unsupported_date_filter", + message: + provider === "perplexity" + ? "date_after/date_before are only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable them." + : `date_after/date_before filtering is not supported by the ${provider} provider. Only Brave and Perplexity support date filtering.`, + docs: "https://docs.openclaw.ai/tools/web", + }); + } + if ( + (params.request.dateAfter || params.request.dateBefore) && + provider === "brave" && + braveMode === "llm-context" + ) { + return Promise.resolve({ + error: "unsupported_date_filter", + message: + "date_after/date_before filtering is not supported by Brave llm-context mode. Use Brave web mode for date filters.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + if (params.request.domainFilter && params.request.domainFilter.length > 0) { + const hasDenylist = params.request.domainFilter.some((domain) => domain.startsWith("-")); + const hasAllowlist = params.request.domainFilter.some((domain) => !domain.startsWith("-")); + if (hasDenylist && hasAllowlist) { + return Promise.resolve({ + error: "invalid_domain_filter", + message: + "domain_filter cannot mix allowlist and denylist entries. Use either all positive entries (allowlist) or all entries prefixed with '-' (denylist).", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + if (params.request.domainFilter.length > 20) { + return Promise.resolve({ + error: "invalid_domain_filter", + message: "domain_filter supports a maximum of 20 domains.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + } + if ( + params.request.domainFilter && + params.request.domainFilter.length > 0 && + !(provider === "perplexity" && supportsStructuredPerplexityFilters) + ) { + return Promise.resolve({ + error: "unsupported_domain_filter", + message: + provider === "perplexity" + ? "domain_filter is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it." + : `domain_filter is not supported by the ${provider} provider. Only Perplexity supports domain filtering.`, + docs: "https://docs.openclaw.ai/tools/web", + }); + } + if ( + provider === "perplexity" && + perplexityRuntime?.transport === "chat_completions" && + (params.request.maxTokens !== undefined || params.request.maxTokensPerPage !== undefined) + ) { + return Promise.resolve({ + error: "unsupported_content_budget", + message: + "max_tokens and max_tokens_per_page are only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable them.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + + return runWebSearch({ + query: params.request.query, + count: params.request.count, + apiKey, + timeoutSeconds: params.context.timeoutSeconds, + cacheTtlMs: params.context.cacheTtlMs, + provider, + country: params.request.country, + language: params.request.language, + search_lang: normalizedBraveLanguageParams.search_lang, + ui_lang: normalizedBraveLanguageParams.ui_lang, + freshness: normalizedFreshness, + dateAfter: params.request.dateAfter, + dateBefore: params.request.dateBefore, + searchDomainFilter: params.request.domainFilter, + maxTokens: params.request.maxTokens, + maxTokensPerPage: params.request.maxTokensPerPage, + perplexityBaseUrl: perplexityRuntime?.baseUrl, + perplexityModel: perplexityRuntime?.model, + perplexityTransport: perplexityRuntime?.transport, + grokModel: resolveGrokModel(grokConfig), + grokInlineCitations: resolveGrokInlineCitations(grokConfig), + geminiModel: resolveGeminiModel(geminiConfig), + kimiBaseUrl: resolveKimiBaseUrl(kimiConfig), + kimiModel: resolveKimiModel(kimiConfig), + braveMode, + }); +} + +function getBuiltinSearchProviders(search?: WebSearchConfig): SearchProviderPlugin[] { + const braveMode = resolveBraveMode(resolveBraveConfig(search)); + return [ + { + id: "brave", + name: braveMode === "llm-context" ? "Brave LLM Context" : "Brave Search", + description: + braveMode === "llm-context" + ? "Search the web using Brave Search LLM Context API. Returns pre-extracted page content (text chunks, tables, code blocks) optimized for LLM grounding." + : "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research.", + isAvailable: (config) => Boolean(resolveSearchApiKey(resolveSearchConfig(config))), + search: (request, context) => + executeBuiltinSearchProvider({ provider: "brave", request, context }), + }, + { + id: "gemini", + name: "Gemini Search", + description: + "Search the web using Gemini with Google Search grounding. Returns AI-synthesized answers with citations from Google Search.", + isAvailable: (config) => + Boolean(resolveGeminiApiKey(resolveGeminiConfig(resolveSearchConfig(config)))), + search: (request, context) => + executeBuiltinSearchProvider({ provider: "gemini", request, context }), + }, + { + id: "grok", + name: "xAI Grok", + description: + "Search the web using xAI Grok. Returns AI-synthesized answers with citations from real-time web search.", + isAvailable: (config) => + Boolean(resolveGrokApiKey(resolveGrokConfig(resolveSearchConfig(config)))), + search: (request, context) => + executeBuiltinSearchProvider({ provider: "grok", request, context }), + }, + { + id: "kimi", + name: "Kimi by Moonshot", + description: + "Search the web using Kimi by Moonshot. Returns AI-synthesized answers with citations from native $web_search.", + isAvailable: (config) => + Boolean(resolveKimiApiKey(resolveKimiConfig(resolveSearchConfig(config)))), + search: (request, context) => + executeBuiltinSearchProvider({ provider: "kimi", request, context }), + }, + { + id: "perplexity", + name: "Perplexity", + description: + resolvePerplexitySchemaTransportHint(resolvePerplexityConfig(search)) === "chat_completions" + ? "Search the web using Perplexity Sonar via Perplexity/OpenRouter chat completions. Returns AI-synthesized answers with citations from web-grounded search." + : "Search the web using Perplexity. Runtime routing decides between native Search API and Sonar chat-completions compatibility. Structured filters are available on the native Search API path.", + isAvailable: (config) => { + const searchConfig = resolveSearchConfig(config); + return Boolean(resolvePerplexityApiKey(resolvePerplexityConfig(searchConfig)).apiKey); + }, + search: (request, context) => + executeBuiltinSearchProvider({ provider: "perplexity", request, context }), + }, + ]; +} + +function getPluginSearchProviders(): SearchProviderPlugin[] { + return getActivePluginRegistry()?.searchProviders.map((entry) => entry.provider) ?? []; +} + +function resolvePreferredBuiltinSearchProvider(params: { + search?: WebSearchConfig; +}): BuiltinSearchProviderId { + const configuredProviderId = normalizeSearchProviderId( + typeof params.search?.provider === "string" ? params.search.provider : undefined, + ); + if (isBuiltinSearchProviderId(configuredProviderId)) { + return configuredProviderId; + } + + return resolveBuiltinSearchProvider(params.search); +} + +function resolveRegisteredSearchProvider(params: { + search?: WebSearchConfig; + config?: OpenClawConfig; +}): SearchProviderPlugin { + const configuredProviderId = normalizeSearchProviderId( + typeof params.search?.provider === "string" ? params.search.provider : undefined, + ); + const builtinProviders = new Map( + getBuiltinSearchProviders(params.search).map((provider) => [provider.id, provider]), + ); + const pluginProviders = new Map( + getPluginSearchProviders().map((provider) => [ + normalizeSearchProviderId(provider.id), + provider, + ]), + ); + + if (configuredProviderId) { + const pluginProvider = pluginProviders.get(configuredProviderId); + if (pluginProvider) { + return pluginProvider; + } + } else { + for (const provider of pluginProviders.values()) { + if (provider.isAvailable?.(params.config)) { + logVerbose( + `web_search: no provider configured, auto-detected plugin provider "${provider.id}"`, + ); + return provider; + } + } + } + + if (configuredProviderId && !isBuiltinSearchProviderId(configuredProviderId)) { + logVerbose( + `web_search: configured plugin provider "${configuredProviderId}" is not registered; failing closed`, + ); + return createMissingSearchProviderPlugin(configuredProviderId); + } + + return ( + builtinProviders.get( + resolvePreferredBuiltinSearchProvider({ + search: params.search, + }), + ) ?? builtinProviders.get(DEFAULT_PROVIDER)! + ); +} + +function createSearchProviderSchema(params: { + provider: SearchProviderPlugin; + search?: WebSearchConfig; +}) { + const providerId = normalizeSearchProviderId(params.provider.id); + if (!params.provider.pluginId && isBuiltinSearchProviderId(providerId)) { + const perplexityConfig = resolvePerplexityConfig(params.search); + const perplexityTransport = resolvePerplexitySchemaTransportHint(perplexityConfig); + return createWebSearchSchema({ + provider: providerId, + perplexityTransport: providerId === "perplexity" ? perplexityTransport : undefined, + }); + } + return createExtensibleWebSearchSchema(); +} + +function parseSearchProviderRequest( + args: Record, + search?: WebSearchConfig, +): SearchProviderRequest { + const rawFreshness = readStringParam(args, "freshness"); + const rawDateAfter = readStringParam(args, "date_after"); + const rawDateBefore = readStringParam(args, "date_before"); + + if (rawFreshness && (rawDateAfter || rawDateBefore)) { + return { + query: readStringParam(args, "query", { required: true }), + count: resolveSearchCount( + readNumberParam(args, "count", { integer: true }) ?? search?.maxResults ?? undefined, + DEFAULT_SEARCH_COUNT, + ), + freshness: "__invalid_conflicting_time_filters__", + }; + } + + return { + query: readStringParam(args, "query", { required: true }), + count: resolveSearchCount( + readNumberParam(args, "count", { integer: true }) ?? search?.maxResults ?? undefined, + DEFAULT_SEARCH_COUNT, + ), + country: readStringParam(args, "country"), + language: readStringParam(args, "language"), + search_lang: readStringParam(args, "search_lang"), + ui_lang: readStringParam(args, "ui_lang"), + freshness: rawFreshness, + dateAfter: rawDateAfter ? normalizeToIsoDate(rawDateAfter) : undefined, + dateBefore: rawDateBefore ? normalizeToIsoDate(rawDateBefore) : undefined, + domainFilter: readStringArrayParam(args, "domain_filter") ?? undefined, + maxTokens: readNumberParam(args, "max_tokens", { integer: true }) ?? undefined, + maxTokensPerPage: readNumberParam(args, "max_tokens_per_page", { integer: true }) ?? undefined, + providerConfig: search as Record | undefined, + }; +} + +function resolveSearchProviderPluginConfig( + config: OpenClawConfig | undefined, + provider: SearchProviderPlugin, +): Record | undefined { + if (!provider.pluginId) { + return undefined; + } + const pluginConfig = config?.plugins?.entries?.[provider.pluginId]?.config; + return pluginConfig && typeof pluginConfig === "object" ? pluginConfig : undefined; +} + +export function createWebSearchTool(options?: { + config?: OpenClawConfig; + sandboxed?: boolean; +}): AnyAgentTool | null { + const search = resolveSearchConfig(options?.config); + if (!resolveSearchEnabled({ search, sandboxed: options?.sandboxed })) { + return null; + } + + const provider = resolveRegisteredSearchProvider({ + search, + config: options?.config, + }); + const parameters = createSearchProviderSchema({ + provider, + search, + }); + const description = + provider.description ?? + `Search the web using ${provider.name}. Returns relevant results for research.`; return { label: "Web Search", name: "web_search", description, - parameters: createWebSearchSchema({ - provider, - perplexityTransport: provider === "perplexity" ? perplexitySchemaTransportHint : undefined, - }), + parameters, execute: async (_toolCallId, args) => { - // Resolve Perplexity auth/transport lazily at execution time so unrelated providers - // do not touch Perplexity-only credential surfaces during tool construction. - const perplexityRuntime = - provider === "perplexity" ? resolvePerplexityTransport(perplexityConfig) : undefined; - const apiKey = - provider === "perplexity" - ? perplexityRuntime?.apiKey - : provider === "grok" - ? resolveGrokApiKey(grokConfig) - : provider === "kimi" - ? resolveKimiApiKey(kimiConfig) - : provider === "gemini" - ? resolveGeminiApiKey(geminiConfig) - : resolveSearchApiKey(search); - - if (!apiKey) { - return jsonResult(missingSearchKeyPayload(provider)); - } - - const supportsStructuredPerplexityFilters = - provider === "perplexity" && perplexityRuntime?.transport === "search_api"; - const params = args as Record; - const query = readStringParam(params, "query", { required: true }); - const count = - readNumberParam(params, "count", { integer: true }) ?? search?.maxResults ?? undefined; - const country = readStringParam(params, "country"); - if ( - country && - provider !== "brave" && - !(provider === "perplexity" && supportsStructuredPerplexityFilters) - ) { - return jsonResult({ - error: "unsupported_country", - message: - provider === "perplexity" - ? "country filtering is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it." - : `country filtering is not supported by the ${provider} provider. Only Brave and Perplexity support country filtering.`, - docs: "https://docs.openclaw.ai/tools/web", - }); - } - const language = readStringParam(params, "language"); - if ( - language && - provider !== "brave" && - !(provider === "perplexity" && supportsStructuredPerplexityFilters) - ) { - return jsonResult({ - error: "unsupported_language", - message: - provider === "perplexity" - ? "language filtering is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it." - : `language filtering is not supported by the ${provider} provider. Only Brave and Perplexity support language filtering.`, - docs: "https://docs.openclaw.ai/tools/web", - }); - } - if (language && provider === "perplexity" && !/^[a-z]{2}$/i.test(language)) { - return jsonResult({ - error: "invalid_language", - message: "language must be a 2-letter ISO 639-1 code like 'en', 'de', or 'fr'.", - docs: "https://docs.openclaw.ai/tools/web", - }); - } - const search_lang = readStringParam(params, "search_lang"); - const ui_lang = readStringParam(params, "ui_lang"); - // For Brave, accept both `language` (unified) and `search_lang` - const normalizedBraveLanguageParams = - provider === "brave" - ? normalizeBraveLanguageParams({ search_lang: search_lang || language, ui_lang }) - : { search_lang: language, ui_lang }; - if (normalizedBraveLanguageParams.invalidField === "search_lang") { - return jsonResult({ - error: "invalid_search_lang", - message: - "search_lang must be a Brave-supported language code like 'en', 'en-gb', 'zh-hans', or 'zh-hant'.", - docs: "https://docs.openclaw.ai/tools/web", - }); - } - if (normalizedBraveLanguageParams.invalidField === "ui_lang") { - return jsonResult({ - error: "invalid_ui_lang", - message: "ui_lang must be a language-region locale like 'en-US'.", - docs: "https://docs.openclaw.ai/tools/web", - }); - } - const resolvedSearchLang = normalizedBraveLanguageParams.search_lang; - const resolvedUiLang = normalizedBraveLanguageParams.ui_lang; - if (resolvedUiLang && provider === "brave" && braveMode === "llm-context") { - return jsonResult({ - error: "unsupported_ui_lang", - message: - "ui_lang is not supported by Brave llm-context mode. Remove ui_lang or use Brave web mode for locale-based UI hints.", - docs: "https://docs.openclaw.ai/tools/web", - }); - } - const rawFreshness = readStringParam(params, "freshness"); - if (rawFreshness && provider !== "brave" && provider !== "perplexity") { - return jsonResult({ - error: "unsupported_freshness", - message: `freshness filtering is not supported by the ${provider} provider. Only Brave and Perplexity support freshness.`, - docs: "https://docs.openclaw.ai/tools/web", - }); - } - if (rawFreshness && provider === "brave" && braveMode === "llm-context") { - return jsonResult({ - error: "unsupported_freshness", - message: - "freshness filtering is not supported by Brave llm-context mode. Remove freshness or use Brave web mode.", - docs: "https://docs.openclaw.ai/tools/web", - }); - } - const freshness = rawFreshness ? normalizeFreshness(rawFreshness, provider) : undefined; - if (rawFreshness && !freshness) { - return jsonResult({ - error: "invalid_freshness", - message: "freshness must be day, week, month, or year.", - docs: "https://docs.openclaw.ai/tools/web", - }); - } - const rawDateAfter = readStringParam(params, "date_after"); - const rawDateBefore = readStringParam(params, "date_before"); + const rawArgs = args as Record; + const rawFreshness = readStringParam(rawArgs, "freshness"); + const rawDateAfter = readStringParam(rawArgs, "date_after"); + const rawDateBefore = readStringParam(rawArgs, "date_before"); if (rawFreshness && (rawDateAfter || rawDateBefore)) { return jsonResult({ error: "conflicting_time_filters", @@ -2064,136 +2641,67 @@ export function createWebSearchTool(options?: { docs: "https://docs.openclaw.ai/tools/web", }); } - if ( - (rawDateAfter || rawDateBefore) && - provider !== "brave" && - !(provider === "perplexity" && supportsStructuredPerplexityFilters) - ) { - return jsonResult({ - error: "unsupported_date_filter", - message: - provider === "perplexity" - ? "date_after/date_before are only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable them." - : `date_after/date_before filtering is not supported by the ${provider} provider. Only Brave and Perplexity support date filtering.`, - docs: "https://docs.openclaw.ai/tools/web", - }); - } - if ((rawDateAfter || rawDateBefore) && provider === "brave" && braveMode === "llm-context") { - return jsonResult({ - error: "unsupported_date_filter", - message: - "date_after/date_before filtering is not supported by Brave llm-context mode. Use Brave web mode for date filters.", - docs: "https://docs.openclaw.ai/tools/web", - }); - } - const dateAfter = rawDateAfter ? normalizeToIsoDate(rawDateAfter) : undefined; - if (rawDateAfter && !dateAfter) { + + const request = parseSearchProviderRequest(rawArgs, search); + if (rawDateAfter && !request.dateAfter) { return jsonResult({ error: "invalid_date", message: "date_after must be YYYY-MM-DD format.", docs: "https://docs.openclaw.ai/tools/web", }); } - const dateBefore = rawDateBefore ? normalizeToIsoDate(rawDateBefore) : undefined; - if (rawDateBefore && !dateBefore) { + if (rawDateBefore && !request.dateBefore) { return jsonResult({ error: "invalid_date", message: "date_before must be YYYY-MM-DD format.", docs: "https://docs.openclaw.ai/tools/web", }); } - if (dateAfter && dateBefore && dateAfter > dateBefore) { + if (request.dateAfter && request.dateBefore && request.dateAfter > request.dateBefore) { return jsonResult({ error: "invalid_date_range", message: "date_after must be before date_before.", docs: "https://docs.openclaw.ai/tools/web", }); } - const domainFilter = readStringArrayParam(params, "domain_filter"); - if ( - domainFilter && - domainFilter.length > 0 && - !(provider === "perplexity" && supportsStructuredPerplexityFilters) - ) { - return jsonResult({ - error: "unsupported_domain_filter", - message: - provider === "perplexity" - ? "domain_filter is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it." - : `domain_filter is not supported by the ${provider} provider. Only Perplexity supports domain filtering.`, - docs: "https://docs.openclaw.ai/tools/web", - }); - } - if (domainFilter && domainFilter.length > 0) { - const hasDenylist = domainFilter.some((d) => d.startsWith("-")); - const hasAllowlist = domainFilter.some((d) => !d.startsWith("-")); - if (hasDenylist && hasAllowlist) { - return jsonResult({ - error: "invalid_domain_filter", - message: - "domain_filter cannot mix allowlist and denylist entries. Use either all positive entries (allowlist) or all entries prefixed with '-' (denylist).", - docs: "https://docs.openclaw.ai/tools/web", - }); - } - if (domainFilter.length > 20) { - return jsonResult({ - error: "invalid_domain_filter", - message: "domain_filter supports a maximum of 20 domains.", - docs: "https://docs.openclaw.ai/tools/web", - }); - } - } - - const maxTokens = readNumberParam(params, "max_tokens", { integer: true }); - const maxTokensPerPage = readNumberParam(params, "max_tokens_per_page", { integer: true }); - if ( - provider === "perplexity" && - perplexityRuntime?.transport === "chat_completions" && - (maxTokens !== undefined || maxTokensPerPage !== undefined) - ) { - return jsonResult({ - error: "unsupported_content_budget", - message: - "max_tokens and max_tokens_per_page are only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable them.", - docs: "https://docs.openclaw.ai/tools/web", - }); - } - - const result = await runWebSearch({ - query, - count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT), - apiKey, - timeoutSeconds: resolveTimeoutSeconds(search?.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS), - cacheTtlMs: resolveCacheTtlMs(search?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES), - provider, - country, - language, - search_lang: resolvedSearchLang, - ui_lang: resolvedUiLang, - freshness, - dateAfter, - dateBefore, - searchDomainFilter: domainFilter, - maxTokens: maxTokens ?? undefined, - maxTokensPerPage: maxTokensPerPage ?? undefined, - perplexityBaseUrl: perplexityRuntime?.baseUrl, - perplexityModel: perplexityRuntime?.model, - perplexityTransport: perplexityRuntime?.transport, - grokModel: resolveGrokModel(grokConfig), - grokInlineCitations: resolveGrokInlineCitations(grokConfig), - geminiModel: resolveGeminiModel(geminiConfig), - kimiBaseUrl: resolveKimiBaseUrl(kimiConfig), - kimiModel: resolveKimiModel(kimiConfig), - braveMode, - }); + const providerId = normalizeSearchProviderId(provider.id); + const result = + !provider.pluginId && isBuiltinSearchProviderId(providerId) + ? await executeBuiltinSearchProvider({ + provider: providerId, + request, + context: { + config: options?.config ?? {}, + timeoutSeconds: resolveTimeoutSeconds( + search?.timeoutSeconds, + DEFAULT_TIMEOUT_SECONDS, + ), + cacheTtlMs: resolveCacheTtlMs(search?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES), + pluginConfig: resolveSearchProviderPluginConfig(options?.config, provider), + }, + }) + : await executePluginSearchProvider({ + provider, + request, + context: { + config: options?.config ?? {}, + timeoutSeconds: resolveTimeoutSeconds( + search?.timeoutSeconds, + DEFAULT_TIMEOUT_SECONDS, + ), + cacheTtlMs: resolveCacheTtlMs(search?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES), + pluginConfig: resolveSearchProviderPluginConfig(options?.config, provider), + }, + }); return jsonResult(result); }, }; } export const __testing = { - resolveSearchProvider, + resolveSearchProvider: resolveBuiltinSearchProvider, + resolveRegisteredSearchProvider, inferPerplexityBaseUrlFromApiKey, resolvePerplexityBaseUrl, resolvePerplexityModel, diff --git a/src/agents/tools/web-tools.enabled-defaults.test.ts b/src/agents/tools/web-tools.enabled-defaults.test.ts index c416804fa11..f0937434f46 100644 --- a/src/agents/tools/web-tools.enabled-defaults.test.ts +++ b/src/agents/tools/web-tools.enabled-defaults.test.ts @@ -1,9 +1,22 @@ import { EnvHttpProxyAgent } from "undici"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createEmptyPluginRegistry } from "../../plugins/registry.js"; +import { getActivePluginRegistry, setActivePluginRegistry } from "../../plugins/runtime.js"; import { withFetchPreconnect } from "../../test-utils/fetch-mock.js"; import { __testing as webSearchTesting } from "./web-search.js"; import { createWebFetchTool, createWebSearchTool } from "./web-tools.js"; +let previousPluginRegistry = getActivePluginRegistry(); + +beforeEach(() => { + previousPluginRegistry = getActivePluginRegistry(); + setActivePluginRegistry(createEmptyPluginRegistry()); +}); + +afterEach(() => { + setActivePluginRegistry(previousPluginRegistry ?? createEmptyPluginRegistry()); +}); + function installMockFetch(payload: unknown) { const mockFetch = vi.fn((_input?: unknown, _init?: unknown) => Promise.resolve({ @@ -169,8 +182,8 @@ describe("web tools defaults", () => { expect(tool?.name).toBe("web_search"); }); - it("prefers runtime-selected web_search provider over local provider config", async () => { - const mockFetch = installMockFetch(createProviderSuccessPayload("gemini")); + it("uses the configured built-in web_search provider from config", async () => { + const mockFetch = installMockFetch(createProviderSuccessPayload("brave")); const tool = createWebSearchTool({ config: { tools: { @@ -186,20 +199,314 @@ describe("web tools defaults", () => { }, }, sandboxed: true, - runtimeWebSearch: { - providerConfigured: "brave", - providerSource: "auto-detect", - selectedProvider: "gemini", - selectedProviderKeySource: "secretRef", - diagnostics: [], - }, }); - const result = await tool?.execute?.("call-runtime-provider", { query: "runtime override" }); + const result = await tool?.execute?.("call-config-provider", { query: "config provider" }); expect(mockFetch).toHaveBeenCalled(); - expect(String(mockFetch.mock.calls[0]?.[0])).toContain("generativelanguage.googleapis.com"); - expect((result?.details as { provider?: string } | undefined)?.provider).toBe("gemini"); + expect(String(mockFetch.mock.calls[0]?.[0])).toContain("api.search.brave.com"); + expect((result?.details as { provider?: string } | undefined)?.provider).toBe("brave"); + }); +}); + +describe("web_search plugin providers", () => { + it("prefers an explicitly configured plugin provider over a built-in provider with the same id", async () => { + const searchMock = vi.fn(async () => ({ + results: [ + { + title: "Plugin Result", + url: "https://example.com/plugin", + description: "Plugin description", + }, + ], + })); + const registry = createEmptyPluginRegistry(); + registry.searchProviders.push({ + pluginId: "plugin-search", + source: "test", + provider: { + id: "brave", + name: "Plugin Brave Override", + pluginId: "plugin-search", + search: searchMock, + }, + }); + setActivePluginRegistry(registry); + + const tool = createWebSearchTool({ + config: { + plugins: { + entries: { + "plugin-search": { + enabled: true, + config: { endpoint: "https://plugin.example" }, + }, + }, + }, + tools: { + web: { + search: { + provider: "brave", + apiKey: "brave-config-test", // pragma: allowlist secret + }, + }, + }, + }, + sandboxed: true, + }); + + const result = await tool?.execute?.("plugin-explicit", { query: "override" }); + const details = result?.details as + | { + provider?: string; + results?: Array<{ url: string }>; + } + | undefined; + + expect(searchMock).toHaveBeenCalledOnce(); + expect(details?.provider).toBe("brave"); + expect(details?.results?.[0]?.url).toBe("https://example.com/plugin"); + }); + + it("keeps an explicitly configured plugin provider even when built-in credentials are also present", async () => { + const searchMock = vi.fn(async () => ({ + content: "Plugin-configured answer", + citations: ["https://example.com/plugin-configured"], + })); + const registry = createEmptyPluginRegistry(); + registry.searchProviders.push({ + pluginId: "plugin-search", + source: "test", + provider: { + id: "searxng", + name: "SearXNG", + pluginId: "plugin-search", + search: searchMock, + }, + }); + setActivePluginRegistry(registry); + + const tool = createWebSearchTool({ + config: { + plugins: { + entries: { + "plugin-search": { + enabled: true, + config: { endpoint: "https://plugin.example" }, + }, + }, + }, + tools: { + web: { + search: { + provider: "searxng", + apiKey: "brave-config-test", // pragma: allowlist secret + gemini: { + apiKey: "gemini-config-test", // pragma: allowlist secret + }, + }, + }, + }, + }, + sandboxed: true, + }); + + const result = await tool?.execute?.("plugin-over-runtime", { query: "plugin configured" }); + const details = result?.details as { provider?: string; citations?: string[] } | undefined; + + expect(searchMock).toHaveBeenCalledOnce(); + expect(details?.provider).toBe("searxng"); + expect(details?.citations).toEqual(["https://example.com/plugin-configured"]); + }); + + it("auto-detects plugin providers before built-in API key detection", async () => { + vi.stubEnv("BRAVE_API_KEY", "test-brave-key"); // pragma: allowlist secret + const searchMock = vi.fn(async () => ({ + content: "Plugin answer", + citations: ["https://example.com/plugin-auto"], + })); + const registry = createEmptyPluginRegistry(); + registry.searchProviders.push({ + pluginId: "plugin-auto", + source: "test", + provider: { + id: "plugin-auto", + name: "Plugin Auto", + pluginId: "plugin-auto", + isAvailable: () => true, + search: searchMock, + }, + }); + setActivePluginRegistry(registry); + + const tool = createWebSearchTool({ config: {}, sandboxed: true }); + const result = await tool?.execute?.("plugin-auto", { query: "auto" }); + const details = result?.details as { provider?: string; citations?: string[] } | undefined; + + expect(searchMock).toHaveBeenCalledOnce(); + expect(details?.provider).toBe("plugin-auto"); + expect(details?.citations).toEqual(["https://example.com/plugin-auto"]); + }); + + it("fails closed when a configured custom provider is not registered", async () => { + const mockFetch = installMockFetch(createProviderSuccessPayload("brave")); + + const tool = createWebSearchTool({ + config: { + tools: { + web: { + search: { + provider: "searxng", + apiKey: "brave-config-test", // pragma: allowlist secret + }, + }, + }, + }, + sandboxed: true, + }); + + const result = await tool?.execute?.("plugin-missing", { query: "missing provider" }); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(result?.details).toMatchObject({ + error: "unknown_search_provider", + provider: "searxng", + }); + }); + + it("preserves plugin error payloads without caching them as success responses", async () => { + webSearchTesting.SEARCH_CACHE.clear(); + const searchMock = vi + .fn() + .mockResolvedValueOnce({ error: "rate_limited" }) + .mockResolvedValueOnce({ + results: [ + { + title: "Recovered", + url: "https://example.com/recovered", + }, + ], + }); + const registry = createEmptyPluginRegistry(); + registry.searchProviders.push({ + pluginId: "plugin-search", + source: "test", + provider: { + id: "searxng", + name: "SearXNG", + pluginId: "plugin-search", + search: searchMock, + }, + }); + setActivePluginRegistry(registry); + + const tool = createWebSearchTool({ + config: { + tools: { + web: { + search: { + provider: "searxng", + }, + }, + }, + }, + sandboxed: true, + }); + + const firstResult = await tool?.execute?.("plugin-error-1", { query: "same-query" }); + const secondResult = await tool?.execute?.("plugin-error-2", { query: "same-query" }); + + expect(searchMock).toHaveBeenCalledTimes(2); + expect(firstResult?.details).toMatchObject({ + error: "rate_limited", + provider: "searxng", + }); + expect((secondResult?.details as { cached?: boolean } | undefined)?.cached).not.toBe(true); + expect(secondResult?.details).toMatchObject({ + provider: "searxng", + results: [{ url: "https://example.com/recovered" }], + }); + }); + + it("does not reuse cached plugin results across different plugin configs", async () => { + webSearchTesting.SEARCH_CACHE.clear(); + const searchMock = vi + .fn() + .mockImplementation(async (_request, context: { pluginConfig?: { endpoint?: string } }) => ({ + results: [ + { + title: "Plugin Result", + url: `https://example.com/${context.pluginConfig?.endpoint || "missing"}`, + }, + ], + })); + const registry = createEmptyPluginRegistry(); + registry.searchProviders.push({ + pluginId: "plugin-search", + source: "test", + provider: { + id: "searxng", + name: "SearXNG", + pluginId: "plugin-search", + search: searchMock, + }, + }); + setActivePluginRegistry(registry); + + const firstTool = createWebSearchTool({ + config: { + plugins: { + entries: { + "plugin-search": { + enabled: true, + config: { endpoint: "tenant-a" }, + }, + }, + }, + tools: { + web: { + search: { + provider: "searxng", + }, + }, + }, + }, + sandboxed: true, + }); + const secondTool = createWebSearchTool({ + config: { + plugins: { + entries: { + "plugin-search": { + enabled: true, + config: { endpoint: "tenant-b" }, + }, + }, + }, + tools: { + web: { + search: { + provider: "searxng", + }, + }, + }, + }, + sandboxed: true, + }); + + const firstResult = await firstTool?.execute?.("plugin-cache-a", { query: "same-query" }); + const secondResult = await secondTool?.execute?.("plugin-cache-b", { query: "same-query" }); + const firstDetails = firstResult?.details as + | { results?: Array<{ url: string }>; cached?: boolean } + | undefined; + const secondDetails = secondResult?.details as + | { results?: Array<{ url: string }>; cached?: boolean } + | undefined; + + expect(searchMock).toHaveBeenCalledTimes(2); + expect(firstDetails?.results?.[0]?.url).toBe("https://example.com/tenant-a"); + expect(secondDetails?.results?.[0]?.url).toBe("https://example.com/tenant-b"); + expect(secondDetails?.cached).not.toBe(true); }); }); diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index 776a2374fbc..3b04c931a1e 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -82,6 +82,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => commands: [], channels, providers: [], + searchProviders: [], gatewayHandlers: {}, httpRoutes: [], cliRegistrars: [], diff --git a/src/commands/onboard-search.ts b/src/commands/onboard-search.ts index df2f4643b60..86a085f6d0c 100644 --- a/src/commands/onboard-search.ts +++ b/src/commands/onboard-search.ts @@ -214,7 +214,7 @@ export async function setupSearch( const defaultProvider: SearchProvider = (() => { if (existingProvider && SEARCH_PROVIDER_OPTIONS.some((e) => e.value === existingProvider)) { - return existingProvider; + return existingProvider as SearchProvider; } const detected = SEARCH_PROVIDER_OPTIONS.find( (e) => hasExistingKey(config, e.value) || hasKeyInEnv(e), diff --git a/src/config/config.web-search-provider.test.ts b/src/config/config.web-search-provider.test.ts index 7ddb4ca3ab4..d99398e2df2 100644 --- a/src/config/config.web-search-provider.test.ts +++ b/src/config/config.web-search-provider.test.ts @@ -1,15 +1,82 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { validateConfigObject } from "./config.js"; +import { validateConfigObject, validateConfigObjectWithPlugins } from "./config.js"; import { buildWebSearchProviderConfig } from "./test-helpers.js"; +const loadOpenClawPlugins = vi.hoisted(() => vi.fn(() => ({ searchProviders: [] }))); + vi.mock("../runtime.js", () => ({ defaultRuntime: { log: vi.fn(), error: vi.fn() }, })); +vi.mock("../plugins/loader.js", () => ({ + loadOpenClawPlugins, +})); + +vi.mock("@mariozechner/pi-ai/oauth", () => ({ + getOAuthApiKey: vi.fn(async () => null), + getOAuthProviders: () => [], +})); + const { __testing } = await import("../agents/tools/web-search.js"); const { resolveSearchProvider } = __testing; describe("web search provider config", () => { + beforeEach(() => { + loadOpenClawPlugins.mockReset(); + loadOpenClawPlugins.mockReturnValue({ searchProviders: [] }); + }); + + it("accepts custom plugin provider ids", () => { + const res = validateConfigObject( + buildWebSearchProviderConfig({ + provider: "searxng", + }), + ); + + expect(res.ok).toBe(true); + }); + + it("rejects unknown custom plugin provider ids during plugin-aware validation", () => { + const res = validateConfigObjectWithPlugins( + buildWebSearchProviderConfig({ + provider: "brvae", + }), + ); + + expect(res.ok).toBe(false); + expect(res.issues.some((issue) => issue.path === "tools.web.search.provider")).toBe(true); + }); + + it("accepts registered custom plugin provider ids during plugin-aware validation", () => { + loadOpenClawPlugins.mockReturnValue({ + searchProviders: [ + { + provider: { + id: "searxng", + }, + }, + ], + }); + + const res = validateConfigObjectWithPlugins( + buildWebSearchProviderConfig({ + provider: "searxng", + }), + ); + + expect(res.ok).toBe(true); + }); + + it("rejects invalid custom plugin provider ids", () => { + const res = validateConfigObject( + buildWebSearchProviderConfig({ + provider: "SearXNG!", + }), + ); + + expect(res.ok).toBe(false); + }); + it("accepts perplexity provider and config", () => { const res = validateConfigObject( buildWebSearchProviderConfig({ diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index 43d39285b57..75f8a9479a3 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -457,8 +457,8 @@ export type ToolsConfig = { search?: { /** Enable web search tool (default: true when API key is present). */ enabled?: boolean; - /** Search provider ("brave", "gemini", "grok", "kimi", or "perplexity"). */ - provider?: "brave" | "gemini" | "grok" | "kimi" | "perplexity"; + /** Search provider. Built-ins include "brave", "gemini", "grok", "kimi", and "perplexity"; plugins use their registered id. */ + provider?: "brave" | "gemini" | "grok" | "kimi" | "perplexity" | (string & {}); /** Brave Search API key (optional; defaults to BRAVE_API_KEY env var). */ apiKey?: SecretInput; /** Default search results count (1-10). */ diff --git a/src/config/validation.ts b/src/config/validation.ts index 686dbb0ed43..457476b27fd 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -6,6 +6,7 @@ import { resolveEffectiveEnableState, resolveMemorySlotDecision, } from "../plugins/config-state.js"; +import { loadOpenClawPlugins } from "../plugins/loader.js"; import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; import { validateJsonSchemaValue } from "../plugins/schema-validator.js"; import { @@ -388,8 +389,55 @@ function validateConfigObjectWithPluginsBase( return info.normalizedPlugins; }; + const validateWebSearchProvider = () => { + const provider = config.tools?.web?.search?.provider; + if ( + typeof provider !== "string" || + provider === "brave" || + provider === "perplexity" || + provider === "grok" || + provider === "gemini" || + provider === "kimi" + ) { + return; + } + + const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)); + try { + const pluginRegistry = loadOpenClawPlugins({ + config, + workspaceDir: workspaceDir ?? undefined, + logger: { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + }, + cache: false, + }); + const normalizedProvider = provider.trim().toLowerCase(); + const registered = pluginRegistry.searchProviders.some( + (entry) => entry.provider.id === normalizedProvider, + ); + if (registered) { + return; + } + } catch { + // Fall through and surface the unknown provider issue below. + } + + if (provider.trim()) { + issues.push({ + path: "tools.web.search.provider", + message: `unknown web search provider: ${provider}`, + }); + } + }; + const allowedChannels = new Set(["defaults", "modelByChannel", ...CHANNEL_IDS]); + validateWebSearchProvider(); + if (config.channels && isRecord(config.channels)) { for (const key of Object.keys(config.channels)) { const trimmed = key.trim(); diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 7a87440a768..32b9dbc7555 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -269,6 +269,7 @@ export const ToolsWebSearchSchema = z z.literal("grok"), z.literal("gemini"), z.literal("kimi"), + z.string().regex(/^[a-z][a-z0-9_-]*$/, "custom provider id"), ]) .optional(), apiKey: SecretInputSchema.optional().register(sensitive), diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index 38f13cf6ac3..c99b5016326 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -28,6 +28,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({ channels: [], commands: [], providers: [], + searchProviders: [], gatewayHandlers: {}, httpRoutes: [], cliRegistrars: [], diff --git a/src/gateway/server.agent.gateway-server-agent.mocks.ts b/src/gateway/server.agent.gateway-server-agent.mocks.ts index c3a33eca9ad..c80712c5bb3 100644 --- a/src/gateway/server.agent.gateway-server-agent.mocks.ts +++ b/src/gateway/server.agent.gateway-server-agent.mocks.ts @@ -10,6 +10,7 @@ export const registryState: { registry: PluginRegistry } = { typedHooks: [], channels: [], providers: [], + searchProviders: [], gatewayHandlers: {}, httpHandlers: [], httpRoutes: [], diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index c8032527294..8a42d915599 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -145,6 +145,7 @@ const createStubPluginRegistry = (): PluginRegistry => ({ }, ], providers: [], + searchProviders: [], gatewayHandlers: {}, httpRoutes: [], cliRegistrars: [], diff --git a/src/plugins/hooks.test-helpers.ts b/src/plugins/hooks.test-helpers.ts index 8b7076239c2..db3a67e0ae2 100644 --- a/src/plugins/hooks.test-helpers.ts +++ b/src/plugins/hooks.test-helpers.ts @@ -20,6 +20,7 @@ export function createMockPluginRegistry( cliRegistrars: [], services: [], providers: [], + searchProviders: [], commands: [], } as unknown as PluginRegistry; } diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 18c0b4bfee2..a0e065db1d4 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -305,6 +305,7 @@ function createPluginRecord(params: { hookNames: [], channelIds: [], providerIds: [], + searchProviderIds: [], gatewayMethods: [], cliCommands: [], services: [], diff --git a/src/plugins/registry.search-provider.test.ts b/src/plugins/registry.search-provider.test.ts new file mode 100644 index 00000000000..4eb9ccde914 --- /dev/null +++ b/src/plugins/registry.search-provider.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from "vitest"; +import { createPluginRegistry, type PluginRecord } from "./registry.js"; + +function createRecord(id: string): PluginRecord { + return { + id, + name: id, + source: `/tmp/${id}.ts`, + origin: "workspace", + enabled: true, + status: "loaded", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: [], + searchProviderIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 0, + configSchema: false, + }; +} + +describe("search provider registration", () => { + it("rejects duplicate provider ids case-insensitively and tracks plugin ids", () => { + const { registry, createApi } = createPluginRegistry({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + runtime: {} as never, + }); + + const firstApi = createApi(createRecord("first-plugin"), { config: {} }); + const secondApi = createApi(createRecord("second-plugin"), { config: {} }); + + firstApi.registerSearchProvider({ + id: "Tavily", + name: "Tavily", + search: async () => ({ content: "ok" }), + }); + secondApi.registerSearchProvider({ + id: "tavily", + name: "Duplicate Tavily", + search: async () => ({ content: "duplicate" }), + }); + + expect(registry.searchProviders).toHaveLength(1); + expect(registry.searchProviders[0]?.provider.id).toBe("tavily"); + expect(registry.searchProviders[0]?.provider.pluginId).toBe("first-plugin"); + expect(registry.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + level: "error", + pluginId: "second-plugin", + message: "search provider already registered: tavily (first-plugin)", + }), + ]), + ); + }); +}); diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index ca987dc8e79..21cca57076b 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -42,6 +42,7 @@ import type { PluginHookName, PluginHookHandlerMap, PluginHookRegistration as TypedPluginHookRegistration, + SearchProviderPlugin, } from "./types.js"; export type PluginToolRegistration = { @@ -81,6 +82,12 @@ export type PluginProviderRegistration = { source: string; }; +export type PluginSearchProviderRegistration = { + pluginId: string; + provider: SearchProviderPlugin; + source: string; +}; + export type PluginHookRegistration = { pluginId: string; entry: HookEntry; @@ -116,6 +123,7 @@ export type PluginRecord = { hookNames: string[]; channelIds: string[]; providerIds: string[]; + searchProviderIds: string[]; gatewayMethods: string[]; cliCommands: string[]; services: string[]; @@ -134,6 +142,7 @@ export type PluginRegistry = { typedHooks: TypedPluginHookRegistration[]; channels: PluginChannelRegistration[]; providers: PluginProviderRegistration[]; + searchProviders: PluginSearchProviderRegistration[]; gatewayHandlers: GatewayRequestHandlers; httpRoutes: PluginHttpRouteRegistration[]; cliRegistrars: PluginCliRegistration[]; @@ -174,6 +183,7 @@ export function createEmptyPluginRegistry(): PluginRegistry { typedHooks: [], channels: [], providers: [], + searchProviders: [], gatewayHandlers: {}, httpRoutes: [], cliRegistrars: [], @@ -467,6 +477,41 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }); }; + const registerSearchProvider = (record: PluginRecord, provider: SearchProviderPlugin) => { + const id = typeof provider?.id === "string" ? provider.id.trim().toLowerCase() : ""; + if (!id) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: "search provider registration missing id", + }); + return; + } + const existing = registry.searchProviders.find((entry) => entry.provider.id === id); + if (existing) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `search provider already registered: ${id} (${existing.pluginId})`, + }); + return; + } + + const normalizedProvider = { + ...provider, + id, + pluginId: record.id, + }; + record.searchProviderIds.push(id); + registry.searchProviders.push({ + pluginId: record.id, + provider: normalizedProvider, + source: record.source, + }); + }; + const registerCli = ( record: PluginRecord, registrar: OpenClawPluginCliRegistrar, @@ -607,6 +652,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registerHttpRoute: (params) => registerHttpRoute(record, params), registerChannel: (registration) => registerChannel(record, registration), registerProvider: (provider) => registerProvider(record, provider), + registerSearchProvider: (provider) => registerSearchProvider(record, provider), registerGatewayMethod: (method, handler) => registerGatewayMethod(record, method, handler), registerCli: (registrar, opts) => registerCli(record, registrar, opts), registerService: (service) => registerService(record, service), @@ -625,6 +671,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registerTool, registerChannel, registerProvider, + registerSearchProvider, registerGatewayMethod, registerCli, registerService, diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 40e3de13529..179e1e240e3 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -239,6 +239,73 @@ export type OpenClawPluginGatewayMethod = { handler: GatewayRequestHandler; }; +export type SearchProviderRequest = { + query: string; + count: number; + country?: string; + language?: string; + search_lang?: string; + ui_lang?: string; + freshness?: string; + dateAfter?: string; + dateBefore?: string; + domainFilter?: string[]; + maxTokens?: number; + maxTokensPerPage?: number; + providerConfig?: Record; +}; + +export type SearchProviderResultItem = { + url: string; + title?: string; + description?: string; + published?: string; +}; + +export type SearchProviderCitation = + | string + | { + url: string; + title?: string; + }; + +export type SearchProviderSuccessResult = { + error?: undefined; + message?: undefined; + results?: SearchProviderResultItem[]; + citations?: SearchProviderCitation[]; + content?: string; + tookMs?: number; +}; + +export type SearchProviderErrorResult = { + error: string; + message?: string; + docs?: string; + tookMs?: number; +}; + +export type SearchProviderExecutionResult = SearchProviderSuccessResult | SearchProviderErrorResult; + +export type SearchProviderContext = { + config: OpenClawConfig; + timeoutSeconds: number; + cacheTtlMs: number; + pluginConfig?: Record; +}; + +export type SearchProviderPlugin = { + id: string; + name: string; + description?: string; + pluginId?: string; + isAvailable?: (config?: OpenClawConfig) => boolean; + search: ( + params: SearchProviderRequest, + ctx: SearchProviderContext, + ) => Promise; +}; + // ============================================================================= // Plugin Commands // ============================================================================= @@ -388,6 +455,7 @@ export type OpenClawPluginApi = { registerCli: (registrar: OpenClawPluginCliRegistrar, opts?: { commands?: string[] }) => void; registerService: (service: OpenClawPluginService) => void; registerProvider: (provider: ProviderPlugin) => void; + registerSearchProvider: (provider: SearchProviderPlugin) => void; /** * Register a custom command that bypasses the LLM agent. * Plugin commands are processed before built-in commands and before agent invocation. diff --git a/src/test-utils/channel-plugins.ts b/src/test-utils/channel-plugins.ts index 38f850ab2a5..66835ed3848 100644 --- a/src/test-utils/channel-plugins.ts +++ b/src/test-utils/channel-plugins.ts @@ -19,6 +19,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl typedHooks: [], channels: channels as unknown as PluginRegistry["channels"], providers: [], + searchProviders: [], gatewayHandlers: {}, httpRoutes: [], cliRegistrars: [], diff --git a/src/wizard/onboarding.finalize.test.ts b/src/wizard/onboarding.finalize.test.ts index 0fa67d16a8f..1a0506633ca 100644 --- a/src/wizard/onboarding.finalize.test.ts +++ b/src/wizard/onboarding.finalize.test.ts @@ -307,4 +307,50 @@ describe("finalizeOnboardingWizard", () => { expect(progressUpdate).toHaveBeenCalledWith("Restarting Gateway service…"); expect(progressStop).toHaveBeenCalledWith("Gateway service restart scheduled."); }); + + it("does not report plugin-provided web search providers as missing API keys", async () => { + const prompter = buildWizardPrompter({ + select: vi.fn(async () => "later") as never, + confirm: vi.fn(async () => false), + }); + const runtime = createRuntime(); + + await finalizeOnboardingWizard({ + flow: "advanced", + opts: { + acceptRisk: true, + authChoice: "skip", + installDaemon: false, + skipHealth: true, + skipUi: true, + }, + baseConfig: {}, + nextConfig: { + tools: { + web: { + search: { + enabled: true, + provider: "searxng", + }, + }, + }, + }, + workspaceDir: "/tmp", + settings: { + port: 18789, + bind: "loopback", + authMode: "token", + gatewayToken: undefined, + tailscaleMode: "off", + tailscaleResetOnExit: false, + }, + prompter, + runtime, + }); + + const noteCalls = (prompter.note as ReturnType).mock.calls; + const webSearchNote = noteCalls.find((call) => call?.[1] === "Web search"); + expect(webSearchNote?.[0]).toContain("plugin-provided provider"); + expect(webSearchNote?.[0]).not.toContain("no API key was found"); + }); }); diff --git a/src/wizard/onboarding.finalize.ts b/src/wizard/onboarding.finalize.ts index b218e160ed5..b27a5a1054b 100644 --- a/src/wizard/onboarding.finalize.ts +++ b/src/wizard/onboarding.finalize.ts @@ -488,8 +488,8 @@ export async function finalizeOnboardingWizard( await import("../commands/onboard-search.js"); const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === webSearchProvider); const label = entry?.label ?? webSearchProvider; - const storedKey = resolveExistingKey(nextConfig, webSearchProvider); - const keyConfigured = hasExistingKey(nextConfig, webSearchProvider); + const storedKey = entry ? resolveExistingKey(nextConfig, entry.value) : undefined; + const keyConfigured = entry ? hasExistingKey(nextConfig, entry.value) : false; const envAvailable = entry ? hasKeyInEnv(entry) : false; const hasKey = keyConfigured || envAvailable; const keySource = storedKey @@ -499,7 +499,20 @@ export async function finalizeOnboardingWizard( : envAvailable ? `API key: provided via ${entry?.envKeys.join(" / ")} env var.` : undefined; - if (webSearchEnabled !== false && hasKey) { + if (!entry) { + await prompter.note( + [ + webSearchEnabled !== false + ? "Web search is enabled through a plugin-provided provider." + : "Web search is configured through a plugin-provided provider but currently disabled.", + "", + `Provider: ${label}`, + "Plugin-managed providers may use plugin config or plugin-specific credentials instead of the built-in API key fields.", + "Docs: https://docs.openclaw.ai/tools/web", + ].join("\n"), + "Web search", + ); + } else if (webSearchEnabled !== false && hasKey) { await prompter.note( [ "Web search is enabled, so your agent can look things up online when needed.",