diff --git a/docs/refactor/plugin-sdk.md b/docs/refactor/plugin-sdk.md index 4722644083b..0a10b56f754 100644 --- a/docs/refactor/plugin-sdk.md +++ b/docs/refactor/plugin-sdk.md @@ -201,7 +201,7 @@ Notes: - Where to host SDK types: separate package or core export? - Runtime type distribution: in SDK (types only) or in core? -- How to expose docs links for bundled vs external plugins? +- How to expose docs links from one plugin-owned metadata path regardless of provenance? - Do we allow limited direct core imports for in-repo plugins during transition? ## Success criteria diff --git a/extensions/search-brave/openclaw.plugin.json b/extensions/search-brave/openclaw.plugin.json index 457dcab3cfa..9c5503fa021 100644 --- a/extensions/search-brave/openclaw.plugin.json +++ b/extensions/search-brave/openclaw.plugin.json @@ -1,5 +1,6 @@ { "id": "search-brave", + "defaultEnabledWhenBundled": true, "configSchema": { "type": "object", "properties": {} diff --git a/extensions/search-brave/package.json b/extensions/search-brave/package.json index c3017ebe3a9..b645659be38 100644 --- a/extensions/search-brave/package.json +++ b/extensions/search-brave/package.json @@ -2,7 +2,7 @@ "name": "@openclaw/search-brave", "version": "2026.3.12", "private": true, - "description": "OpenClaw bundled Brave search provider plugin", + "description": "OpenClaw Brave search plugin", "type": "module", "openclaw": { "extensions": [ diff --git a/extensions/search-brave/src/provider.ts b/extensions/search-brave/src/provider.ts index 50bcc17c709..d480a351803 100644 --- a/extensions/search-brave/src/provider.ts +++ b/extensions/search-brave/src/provider.ts @@ -1,3 +1,4 @@ +import { Type } from "@sinclair/typebox"; import { CacheEntry, createSearchProviderSetupMetadata, @@ -8,14 +9,13 @@ import { normalizeSecretInput, readCache, readResponseText, - readSearchProviderApiKeyValue, resolveSearchConfig, resolveSiteName, type OpenClawConfig, type SearchProviderContext, type SearchProviderErrorResult, type SearchProviderExecutionResult, - type SearchProviderSetupUiMetadata, + type SearchProviderSetupMetadata, type SearchProviderPlugin, type SearchProviderRequest, withTrustedWebToolsEndpoint, @@ -91,6 +91,7 @@ const BRAVE_SEARCH_LANG_ALIASES: Record = { "zh-tw": "zh-hant", }; const BRAVE_UI_LANG_LOCALE = /^([a-z]{2})-([a-z]{2})$/i; +const MAX_SEARCH_COUNT = 10; type BraveSearchResult = { title?: string; @@ -136,7 +137,7 @@ function resolveBraveMode(brave: BraveConfig): "web" | "llm-context" { function resolveBraveApiKey(search?: WebSearchConfig): string | undefined { const fromConfigRaw = search ? normalizeResolvedSecretInputString({ - value: readSearchProviderApiKeyValue(search as Record, "brave"), + value: search.apiKey, path: "tools.web.search.apiKey", }) : undefined; @@ -395,7 +396,7 @@ async function runBraveWebSearch(params: { ); } -export const BRAVE_SEARCH_PROVIDER_METADATA: SearchProviderSetupUiMetadata = +export const BRAVE_SEARCH_PROVIDER_METADATA: SearchProviderSetupMetadata = createSearchProviderSetupMetadata({ provider: "brave", label: "Brave Search", @@ -404,20 +405,34 @@ export const BRAVE_SEARCH_PROVIDER_METADATA: SearchProviderSetupUiMetadata = placeholder: "BSA...", signupUrl: "https://brave.com/search/api/", apiKeyConfigPath: "tools.web.search.apiKey", + autodetectPriority: 10, + requestSchema: Type.Object({ + query: Type.String({ description: "Search query string." }), + count: Type.Optional(Type.Number({ minimum: 1, maximum: MAX_SEARCH_COUNT })), + country: Type.Optional(Type.String()), + language: Type.Optional(Type.String()), + freshness: Type.Optional(Type.String()), + date_after: Type.Optional(Type.String()), + date_before: Type.Optional(Type.String()), + search_lang: Type.Optional(Type.String()), + ui_lang: Type.Optional(Type.String()), + }), + readApiKeyValue: (search) => + search && typeof search === "object" && !Array.isArray(search) ? search.apiKey : undefined, + writeApiKeyValue: (search, value) => { + search.apiKey = value; + }, }); export function createBundledBraveSearchProvider(): SearchProviderPlugin { return { id: "brave", - name: BRAVE_SEARCH_PROVIDER_METADATA.label, + name: "Brave Search", description: "Search the web using Brave Search. Supports web and llm-context modes, region-specific search, and localized search parameters.", pluginOwnedExecution: true, - docsUrl: BRAVE_SEARCH_PROVIDER_METADATA.signupUrl, - setup: { - hint: BRAVE_SEARCH_PROVIDER_METADATA.hint, - credentials: BRAVE_SEARCH_PROVIDER_METADATA, - }, + docsUrl: "https://brave.com/search/api/", + setup: BRAVE_SEARCH_PROVIDER_METADATA, isAvailable: (config) => { const search = config?.tools?.web?.search; return Boolean( diff --git a/extensions/search-gemini/openclaw.plugin.json b/extensions/search-gemini/openclaw.plugin.json index 2f08b7a333c..5563081dd59 100644 --- a/extensions/search-gemini/openclaw.plugin.json +++ b/extensions/search-gemini/openclaw.plugin.json @@ -1,5 +1,6 @@ { "id": "search-gemini", + "defaultEnabledWhenBundled": true, "configSchema": { "type": "object", "properties": {} diff --git a/extensions/search-gemini/package.json b/extensions/search-gemini/package.json index 0749797bd00..e3bccbb6070 100644 --- a/extensions/search-gemini/package.json +++ b/extensions/search-gemini/package.json @@ -2,7 +2,7 @@ "name": "@openclaw/search-gemini", "version": "2026.3.12", "private": true, - "description": "OpenClaw bundled Gemini search provider plugin", + "description": "OpenClaw Gemini search plugin", "type": "module", "openclaw": { "extensions": [ diff --git a/extensions/search-gemini/src/provider.ts b/extensions/search-gemini/src/provider.ts index 536e75ace03..976ec18517a 100644 --- a/extensions/search-gemini/src/provider.ts +++ b/extensions/search-gemini/src/provider.ts @@ -1,3 +1,4 @@ +import { Type } from "@sinclair/typebox"; import { buildSearchRequestCacheIdentity, createSearchProviderSetupMetadata, @@ -12,7 +13,7 @@ import { resolveSearchProviderSectionConfig, type OpenClawConfig, type SearchProviderExecutionResult, - type SearchProviderSetupUiMetadata, + type SearchProviderSetupMetadata, type SearchProviderPlugin, withTrustedWebToolsEndpoint, wrapWebContent, @@ -26,6 +27,7 @@ const GEMINI_SEARCH_CACHE = new Map< string, { value: Record; expiresAt: number } >(); +const MAX_SEARCH_COUNT = 10; type WebSearchConfig = NonNullable["web"] extends infer Web ? Web extends { search?: infer Search } @@ -156,7 +158,7 @@ async function runGeminiSearch(params: { ); } -export const GEMINI_SEARCH_PROVIDER_METADATA: SearchProviderSetupUiMetadata = +export const GEMINI_SEARCH_PROVIDER_METADATA: SearchProviderSetupMetadata = createSearchProviderSetupMetadata({ provider: "gemini", label: "Gemini (Google Search)", @@ -165,6 +167,11 @@ export const GEMINI_SEARCH_PROVIDER_METADATA: SearchProviderSetupUiMetadata = placeholder: "AIza...", signupUrl: "https://aistudio.google.com/apikey", apiKeyConfigPath: "tools.web.search.gemini.apiKey", + autodetectPriority: 20, + requestSchema: Type.Object({ + query: Type.String({ description: "Search query string." }), + count: Type.Optional(Type.Number({ minimum: 1, maximum: MAX_SEARCH_COUNT })), + }), }); export function createBundledGeminiSearchProvider(): SearchProviderPlugin { @@ -174,10 +181,7 @@ export function createBundledGeminiSearchProvider(): SearchProviderPlugin { description: "Search the web using Gemini with Google Search grounding. Returns AI-synthesized answers with citations from Google Search.", pluginOwnedExecution: true, - setup: { - hint: GEMINI_SEARCH_PROVIDER_METADATA.hint, - credentials: GEMINI_SEARCH_PROVIDER_METADATA, - }, + setup: GEMINI_SEARCH_PROVIDER_METADATA, isAvailable: (config) => Boolean( resolveGeminiApiKey( diff --git a/extensions/search-grok/openclaw.plugin.json b/extensions/search-grok/openclaw.plugin.json index e6b9706cd7d..8d1664b7c04 100644 --- a/extensions/search-grok/openclaw.plugin.json +++ b/extensions/search-grok/openclaw.plugin.json @@ -1,5 +1,6 @@ { "id": "search-grok", + "defaultEnabledWhenBundled": true, "configSchema": { "type": "object", "properties": {} diff --git a/extensions/search-grok/package.json b/extensions/search-grok/package.json index 7e8a28af810..63dd2a73952 100644 --- a/extensions/search-grok/package.json +++ b/extensions/search-grok/package.json @@ -2,7 +2,7 @@ "name": "@openclaw/search-grok", "version": "2026.3.12", "private": true, - "description": "OpenClaw bundled Grok search provider plugin", + "description": "OpenClaw Grok search plugin", "type": "module", "openclaw": { "extensions": [ diff --git a/extensions/search-grok/src/provider.ts b/extensions/search-grok/src/provider.ts index 14fffa8b863..5e696b03ba7 100644 --- a/extensions/search-grok/src/provider.ts +++ b/extensions/search-grok/src/provider.ts @@ -1,3 +1,4 @@ +import { Type } from "@sinclair/typebox"; import { buildSearchRequestCacheIdentity, createSearchProviderSetupMetadata, @@ -11,7 +12,7 @@ import { throwWebSearchApiError, type OpenClawConfig, type SearchProviderExecutionResult, - type SearchProviderSetupUiMetadata, + type SearchProviderSetupMetadata, type SearchProviderPlugin, withTrustedWebToolsEndpoint, wrapWebContent, @@ -22,6 +23,7 @@ const XAI_API_ENDPOINT = "https://api.x.ai/v1/responses"; const DEFAULT_GROK_MODEL = "grok-4-1-fast"; const GROK_SEARCH_CACHE = new Map; expiresAt: number }>(); +const MAX_SEARCH_COUNT = 10; type WebSearchConfig = NonNullable["web"] extends infer Web ? Web extends { search?: infer Search } @@ -164,7 +166,7 @@ async function runGrokSearch(params: { ); } -export const GROK_SEARCH_PROVIDER_METADATA: SearchProviderSetupUiMetadata = +export const GROK_SEARCH_PROVIDER_METADATA: SearchProviderSetupMetadata = createSearchProviderSetupMetadata({ provider: "grok", label: "Grok (xAI)", @@ -173,6 +175,11 @@ export const GROK_SEARCH_PROVIDER_METADATA: SearchProviderSetupUiMetadata = placeholder: "xai-...", signupUrl: "https://console.x.ai/", apiKeyConfigPath: "tools.web.search.grok.apiKey", + autodetectPriority: 30, + requestSchema: Type.Object({ + query: Type.String({ description: "Search query string." }), + count: Type.Optional(Type.Number({ minimum: 1, maximum: MAX_SEARCH_COUNT })), + }), }); export function createBundledGrokSearchProvider(): SearchProviderPlugin { @@ -182,10 +189,7 @@ export function createBundledGrokSearchProvider(): SearchProviderPlugin { description: "Search the web using xAI Grok. Returns AI-synthesized answers with citations from real-time web search.", pluginOwnedExecution: true, - setup: { - hint: GROK_SEARCH_PROVIDER_METADATA.hint, - credentials: GROK_SEARCH_PROVIDER_METADATA, - }, + setup: GROK_SEARCH_PROVIDER_METADATA, isAvailable: (config) => Boolean( resolveGrokApiKey( diff --git a/extensions/search-kimi/openclaw.plugin.json b/extensions/search-kimi/openclaw.plugin.json index 86f27a5c4b1..39463486d88 100644 --- a/extensions/search-kimi/openclaw.plugin.json +++ b/extensions/search-kimi/openclaw.plugin.json @@ -1,5 +1,6 @@ { "id": "search-kimi", + "defaultEnabledWhenBundled": true, "configSchema": { "type": "object", "properties": {} diff --git a/extensions/search-kimi/package.json b/extensions/search-kimi/package.json index 19e51871443..6c48ded1d07 100644 --- a/extensions/search-kimi/package.json +++ b/extensions/search-kimi/package.json @@ -2,7 +2,7 @@ "name": "@openclaw/search-kimi", "version": "2026.3.12", "private": true, - "description": "OpenClaw bundled Kimi search provider plugin", + "description": "OpenClaw Kimi search plugin", "type": "module", "openclaw": { "extensions": [ diff --git a/extensions/search-kimi/src/provider.ts b/extensions/search-kimi/src/provider.ts index 6512980b582..e7262ef4561 100644 --- a/extensions/search-kimi/src/provider.ts +++ b/extensions/search-kimi/src/provider.ts @@ -1,3 +1,4 @@ +import { Type } from "@sinclair/typebox"; import { buildSearchRequestCacheIdentity, createSearchProviderSetupMetadata, @@ -10,7 +11,7 @@ import { resolveSearchProviderSectionConfig, type OpenClawConfig, type SearchProviderExecutionResult, - type SearchProviderSetupUiMetadata, + type SearchProviderSetupMetadata, type SearchProviderPlugin, withTrustedWebToolsEndpoint, wrapWebContent, @@ -25,6 +26,7 @@ const KIMI_WEB_SEARCH_TOOL = { } as const; const KIMI_SEARCH_CACHE = new Map; expiresAt: number }>(); +const MAX_SEARCH_COUNT = 10; type WebSearchConfig = NonNullable["web"] extends infer Web ? Web extends { search?: infer Search } @@ -216,7 +218,7 @@ async function runKimiSearch(params: { }; } -export const KIMI_SEARCH_PROVIDER_METADATA: SearchProviderSetupUiMetadata = +export const KIMI_SEARCH_PROVIDER_METADATA: SearchProviderSetupMetadata = createSearchProviderSetupMetadata({ provider: "kimi", label: "Kimi (Moonshot)", @@ -225,6 +227,11 @@ export const KIMI_SEARCH_PROVIDER_METADATA: SearchProviderSetupUiMetadata = placeholder: "sk-...", signupUrl: "https://platform.moonshot.cn/", apiKeyConfigPath: "tools.web.search.kimi.apiKey", + autodetectPriority: 40, + requestSchema: Type.Object({ + query: Type.String({ description: "Search query string." }), + count: Type.Optional(Type.Number({ minimum: 1, maximum: MAX_SEARCH_COUNT })), + }), }); export function createBundledKimiSearchProvider(): SearchProviderPlugin { @@ -234,10 +241,7 @@ export function createBundledKimiSearchProvider(): SearchProviderPlugin { description: "Search the web using Kimi by Moonshot. Returns AI-synthesized answers with citations from native $web_search.", pluginOwnedExecution: true, - setup: { - hint: KIMI_SEARCH_PROVIDER_METADATA.hint, - credentials: KIMI_SEARCH_PROVIDER_METADATA, - }, + setup: KIMI_SEARCH_PROVIDER_METADATA, isAvailable: (config) => Boolean( resolveKimiApiKey( diff --git a/extensions/search-perplexity/openclaw.plugin.json b/extensions/search-perplexity/openclaw.plugin.json index b412ca28256..0c0bcdf34fa 100644 --- a/extensions/search-perplexity/openclaw.plugin.json +++ b/extensions/search-perplexity/openclaw.plugin.json @@ -1,5 +1,6 @@ { "id": "search-perplexity", + "defaultEnabledWhenBundled": true, "configSchema": { "type": "object", "properties": {} diff --git a/extensions/search-perplexity/package.json b/extensions/search-perplexity/package.json index 0f9abb18293..3b10dcd4e8b 100644 --- a/extensions/search-perplexity/package.json +++ b/extensions/search-perplexity/package.json @@ -2,7 +2,7 @@ "name": "@openclaw/search-perplexity", "version": "2026.3.12", "private": true, - "description": "OpenClaw bundled Perplexity search provider plugin", + "description": "OpenClaw Perplexity search plugin", "type": "module", "openclaw": { "extensions": [ diff --git a/extensions/search-perplexity/src/provider.ts b/extensions/search-perplexity/src/provider.ts index 9df537e2503..64ff6ff8621 100644 --- a/extensions/search-perplexity/src/provider.ts +++ b/extensions/search-perplexity/src/provider.ts @@ -1,3 +1,4 @@ +import { Type } from "@sinclair/typebox"; import { buildSearchRequestCacheIdentity, createSearchProviderSetupMetadata, @@ -14,7 +15,7 @@ import { throwWebSearchApiError, type OpenClawConfig, type SearchProviderExecutionResult, - type SearchProviderSetupUiMetadata, + type SearchProviderSetupMetadata, type SearchProviderPlugin, withTrustedWebToolsEndpoint, wrapWebContent, @@ -29,6 +30,7 @@ const PERPLEXITY_KEY_PREFIXES = ["pplx-"]; const OPENROUTER_KEY_PREFIXES = ["sk-or-"]; const PERPLEXITY_RECENCY_VALUES = new Set(["day", "week", "month", "year"]); const ISO_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/; +const MAX_SEARCH_COUNT = 10; const PERPLEXITY_SEARCH_CACHE = new Map< string, @@ -376,7 +378,7 @@ function createPerplexityPayload(params: { return payload; } -export const PERPLEXITY_SEARCH_PROVIDER_METADATA: SearchProviderSetupUiMetadata = +export const PERPLEXITY_SEARCH_PROVIDER_METADATA: SearchProviderSetupMetadata = createSearchProviderSetupMetadata({ provider: "perplexity", label: "Perplexity Search", @@ -390,8 +392,99 @@ export const PERPLEXITY_SEARCH_PROVIDER_METADATA: SearchProviderSetupUiMetadata resolvePerplexityConfig(resolveSearchConfig(params.search)), ).transport, }), + autodetectPriority: 50, + resolveRequestSchema: (params) => { + const runtimeTransport = + params.runtimeMetadata && typeof params.runtimeMetadata.perplexityTransport === "string" + ? params.runtimeMetadata.perplexityTransport + : undefined; + return runtimeTransport === "chat_completions" + ? createPerplexityChatSchema() + : createPerplexitySearchApiSchema(); + }, }); +function createPerplexitySearchApiSchema() { + return Type.Object({ + 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, + }), + ), + freshness: Type.Optional( + Type.String({ + description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'.", + }), + ), + country: Type.Optional( + Type.String({ + description: + "Native Perplexity Search API only. 2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.", + }), + ), + language: Type.Optional( + Type.String({ + description: + "Native Perplexity Search API only. ISO 639-1 language code for results (e.g., 'en', 'de', 'fr').", + }), + ), + date_after: Type.Optional( + Type.String({ + description: + "Native Perplexity Search API only. Only results published after this date (YYYY-MM-DD).", + }), + ), + date_before: Type.Optional( + Type.String({ + description: + "Native Perplexity Search API only. Only results published before this date (YYYY-MM-DD).", + }), + ), + domain_filter: Type.Optional( + Type.Array(Type.String(), { + description: + "Native Perplexity Search API only. Domain filter (max 20). Allowlist: ['nature.com'] or denylist: ['-reddit.com']. Cannot mix.", + }), + ), + max_tokens: Type.Optional( + Type.Number({ + description: + "Native Perplexity Search API only. Total content budget across all results (default: 25000, max: 1000000).", + minimum: 1, + maximum: 1000000, + }), + ), + max_tokens_per_page: Type.Optional( + Type.Number({ + description: + "Native Perplexity Search API only. Max tokens extracted per page (default: 2048).", + minimum: 1, + }), + ), + }); +} + +function createPerplexityChatSchema() { + return Type.Object({ + 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, + }), + ), + freshness: Type.Optional( + Type.String({ + description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'.", + }), + ), + }); +} + export function createBundledPerplexitySearchProvider(): SearchProviderPlugin { return { id: "perplexity", @@ -399,11 +492,8 @@ export function createBundledPerplexitySearchProvider(): SearchProviderPlugin { description: "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.", pluginOwnedExecution: true, - setup: { - hint: PERPLEXITY_SEARCH_PROVIDER_METADATA.hint, - credentials: PERPLEXITY_SEARCH_PROVIDER_METADATA, - }, - resolveRuntimeMetadata: PERPLEXITY_SEARCH_PROVIDER_METADATA.resolveRuntimeMetadata, + setup: PERPLEXITY_SEARCH_PROVIDER_METADATA, + resolveRuntimeMetadata: PERPLEXITY_SEARCH_PROVIDER_METADATA.credentials?.resolveRuntimeMetadata, isAvailable: (config) => Boolean( resolvePerplexityApiKey( diff --git a/extensions/tavily-search/index.ts b/extensions/tavily-search/index.ts index 3ab42b091ea..d5a802f2ebc 100644 --- a/extensions/tavily-search/index.ts +++ b/extensions/tavily-search/index.ts @@ -1,6 +1,9 @@ +import { Type } from "@sinclair/typebox"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { createSearchProviderSetupMetadata } from "openclaw/plugin-sdk/web-search"; const TAVILY_SEARCH_ENDPOINT = "https://api.tavily.com/search"; +const MAX_SEARCH_COUNT = 10; type TavilyPluginConfig = { apiKey?: string; @@ -69,15 +72,52 @@ function resolveFreshnessDays(freshness?: string): number | undefined { const plugin = { id: "tavily-search", name: "Tavily Search", - description: "External Tavily web_search provider plugin", + description: "Tavily web_search plugin", register(api: OpenClawPluginApi) { api.registerSearchProvider({ id: "tavily", name: "Tavily Search", description: - "Search the web using Tavily via an external plugin provider. Returns structured results and an AI-synthesized answer when available.", + "Search the web using Tavily. Returns structured results and an AI-synthesized answer when available.", docsUrl: "https://docs.tavily.com/", configFieldOrder: ["apiKey", "searchDepth"], + setup: createSearchProviderSetupMetadata({ + provider: "tavily", + label: "Tavily Search", + hint: "Plugin search with structured results and optional AI answer synthesis.", + envKeys: ["TAVILY_API_KEY"], + placeholder: "tvly-...", + signupUrl: "https://app.tavily.com/home", + apiKeyConfigPath: "plugins.entries.tavily-search.config.apiKey", + install: { + npmSpec: "@openclaw/tavily-search", + localPath: "extensions/tavily-search", + defaultChoice: "local", + }, + requestSchema: Type.Object( + { + 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, + }), + ), + country: Type.Optional( + Type.String({ + description: "Optional 2-letter country code for region-specific results.", + }), + ), + freshness: Type.Optional( + Type.String({ + description: "Filter by time: 'day', 'week', 'month', or 'year'.", + }), + ), + }, + { additionalProperties: true }, + ), + }), isAvailable: (config) => Boolean(resolveApiKey(resolveRootPluginConfig(config ?? {}, api.id))), search: async (params, ctx) => { diff --git a/extensions/tavily-search/install-catalog.ts b/extensions/tavily-search/install-catalog.ts deleted file mode 100644 index 3fccae12bd1..00000000000 --- a/extensions/tavily-search/install-catalog.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { InstallableSearchProviderPluginCatalogEntry } from "../../src/commands/search-provider-plugin-catalog.js"; -import pluginManifest from "./openclaw.plugin.json"; -import packageJson from "./package.json"; - -export const tavilySearchInstallCatalogEntry = { - id: pluginManifest.id, - providerId: "tavily", - meta: { - label: "Tavily Search", - }, - description: "Install Tavily as a plugin search provider.", - install: { - npmSpec: packageJson.name, - localPath: "extensions/tavily-search", - defaultChoice: "local", - }, -} satisfies InstallableSearchProviderPluginCatalogEntry; diff --git a/extensions/tavily-search/openclaw.plugin.json b/extensions/tavily-search/openclaw.plugin.json index a400ff5eec6..b37e29877cf 100644 --- a/extensions/tavily-search/openclaw.plugin.json +++ b/extensions/tavily-search/openclaw.plugin.json @@ -1,5 +1,7 @@ { "id": "tavily-search", + "name": "Tavily Search", + "description": "Search the web using Tavily.", "provides": ["providers.search.tavily"], "uiHints": { "apiKey": { diff --git a/extensions/tavily-search/package.json b/extensions/tavily-search/package.json index c54793ce678..fb2459e09f4 100644 --- a/extensions/tavily-search/package.json +++ b/extensions/tavily-search/package.json @@ -1,11 +1,16 @@ { "name": "@openclaw/tavily-search", "version": "2026.3.9", - "description": "OpenClaw Tavily external search provider plugin", + "description": "OpenClaw Tavily search plugin", "type": "module", "openclaw": { "extensions": [ "./index.ts" - ] + ], + "install": { + "npmSpec": "@openclaw/tavily-search", + "localPath": "extensions/tavily-search", + "defaultChoice": "local" + } } } diff --git a/src/agents/skills/plugin-skills.ts b/src/agents/skills/plugin-skills.ts index 5a02737e5cd..de31116e9b3 100644 --- a/src/agents/skills/plugin-skills.ts +++ b/src/agents/skills/plugin-skills.ts @@ -43,6 +43,7 @@ export function resolvePluginSkillDirs(params: { origin: record.origin, config: normalizedPlugins, rootConfig: params.config, + defaultEnabledWhenBundled: record.defaultEnabledWhenBundled, }); if (!enableState.enabled) { continue; diff --git a/src/agents/tools/web-search-provider-catalog.ts b/src/agents/tools/web-search-provider-catalog.ts deleted file mode 100644 index 075fa39d3dd..00000000000 --- a/src/agents/tools/web-search-provider-catalog.ts +++ /dev/null @@ -1,143 +0,0 @@ -export const BUILTIN_WEB_SEARCH_PROVIDER_IDS = [ - "brave", - "gemini", - "grok", - "kimi", - "perplexity", -] as const; - -export type BuiltinWebSearchProviderId = (typeof BUILTIN_WEB_SEARCH_PROVIDER_IDS)[number]; - -export const MIGRATED_BUNDLED_WEB_SEARCH_PROVIDER_IDS = BUILTIN_WEB_SEARCH_PROVIDER_IDS; - -export type MigratedBundledWebSearchProviderId = - (typeof MIGRATED_BUNDLED_WEB_SEARCH_PROVIDER_IDS)[number]; - -export const bundledCoreWebSearchPluginId = (providerId: BuiltinWebSearchProviderId): string => - `search-${providerId}`; - -export const MIGRATED_BUNDLED_WEB_SEARCH_PLUGIN_IDS = MIGRATED_BUNDLED_WEB_SEARCH_PROVIDER_IDS.map( - bundledCoreWebSearchPluginId, -); - -export type BuiltinWebSearchProviderEntry = { - value: BuiltinWebSearchProviderId; - label: string; - hint: string; - envKeys: readonly string[]; - placeholder: string; - signupUrl: string; - apiKeyConfigPath: string; -}; - -const BUILTIN_WEB_SEARCH_PROVIDER_CATALOG: Record< - BuiltinWebSearchProviderId, - Omit -> = { - brave: { - label: "Brave Search", - hint: "Structured results · country/language/time filters", - envKeys: ["BRAVE_API_KEY"], - placeholder: "BSA...", - signupUrl: "https://brave.com/search/api/", - apiKeyConfigPath: "tools.web.search.apiKey", - }, - gemini: { - label: "Gemini (Google Search)", - hint: "Google Search grounding · AI-synthesized", - envKeys: ["GEMINI_API_KEY"], - placeholder: "AIza...", - signupUrl: "https://aistudio.google.com/apikey", - apiKeyConfigPath: "tools.web.search.gemini.apiKey", - }, - grok: { - label: "Grok (xAI)", - hint: "xAI web-grounded responses", - envKeys: ["XAI_API_KEY"], - placeholder: "xai-...", - signupUrl: "https://console.x.ai/", - apiKeyConfigPath: "tools.web.search.grok.apiKey", - }, - kimi: { - label: "Kimi (Moonshot)", - hint: "Moonshot web search", - envKeys: ["KIMI_API_KEY", "MOONSHOT_API_KEY"], - placeholder: "sk-...", - signupUrl: "https://platform.moonshot.cn/", - apiKeyConfigPath: "tools.web.search.kimi.apiKey", - }, - perplexity: { - label: "Perplexity Search", - hint: "Structured results · domain/country/language/time filters", - envKeys: ["PERPLEXITY_API_KEY"], - placeholder: "pplx-...", - signupUrl: "https://www.perplexity.ai/settings/api", - apiKeyConfigPath: "tools.web.search.perplexity.apiKey", - }, -}; - -export const BUILTIN_WEB_SEARCH_PROVIDER_OPTIONS: readonly BuiltinWebSearchProviderEntry[] = - BUILTIN_WEB_SEARCH_PROVIDER_IDS.map((value) => ({ - value, - ...BUILTIN_WEB_SEARCH_PROVIDER_CATALOG[value], - })); - -export function isBuiltinWebSearchProviderId(value: string): value is BuiltinWebSearchProviderId { - return BUILTIN_WEB_SEARCH_PROVIDER_IDS.includes(value as BuiltinWebSearchProviderId); -} - -export function normalizeBuiltinWebSearchProvider( - value: unknown, -): BuiltinWebSearchProviderId | undefined { - if (typeof value !== "string") { - return undefined; - } - const normalized = value.trim().toLowerCase(); - return isBuiltinWebSearchProviderId(normalized) ? normalized : undefined; -} - -export function getBuiltinWebSearchProviderEntry( - provider: BuiltinWebSearchProviderId, -): BuiltinWebSearchProviderEntry { - return BUILTIN_WEB_SEARCH_PROVIDER_OPTIONS.find((entry) => entry.value === provider)!; -} - -function getScopedSearchConfig( - search: Record, - provider: BuiltinWebSearchProviderId, -): Record | undefined { - if (provider === "brave") { - return search; - } - const scoped = search[provider]; - return typeof scoped === "object" && scoped !== null && !Array.isArray(scoped) - ? (scoped as Record) - : undefined; -} - -export function readBuiltinWebSearchApiKeyValue( - search: Record | undefined, - provider: BuiltinWebSearchProviderId, -): unknown { - if (!search) { - return undefined; - } - return getScopedSearchConfig(search, provider)?.apiKey; -} - -export function writeBuiltinWebSearchApiKeyValue(params: { - search: Record; - provider: BuiltinWebSearchProviderId; - value: unknown; -}): void { - if (params.provider === "brave") { - params.search.apiKey = params.value; - return; - } - const current = getScopedSearchConfig(params.search, params.provider); - if (current) { - current.apiKey = params.value; - return; - } - params.search[params.provider] = { apiKey: params.value }; -} diff --git a/src/agents/tools/web-search.test.ts b/src/agents/tools/web-search.test.ts index b8bccd7dfd3..327738883be 100644 --- a/src/agents/tools/web-search.test.ts +++ b/src/agents/tools/web-search.test.ts @@ -1,174 +1,7 @@ import { describe, expect, it } from "vitest"; -import { withEnv } from "../../test-utils/env.js"; import { __testing } from "./web-search.js"; -const { - inferPerplexityBaseUrlFromApiKey, - resolvePerplexityBaseUrl, - resolvePerplexityModel, - resolvePerplexityTransport, - isDirectPerplexityBaseUrl, - resolvePerplexityRequestModel, - resolvePerplexityApiKey, - normalizeBraveLanguageParams, - normalizeFreshness, - normalizeToIsoDate, - isoToPerplexityDate, - resolveGrokApiKey, - resolveGrokModel, - resolveGrokInlineCitations, - extractGrokContent, - resolveKimiApiKey, - resolveKimiModel, - resolveKimiBaseUrl, - extractKimiCitations, - resolveBraveMode, - mapBraveLlmContextResults, -} = __testing; - -const kimiApiKeyEnv = ["KIMI_API", "KEY"].join("_"); -const moonshotApiKeyEnv = ["MOONSHOT_API", "KEY"].join("_"); -const openRouterApiKeyEnv = ["OPENROUTER_API", "KEY"].join("_"); -const perplexityApiKeyEnv = ["PERPLEXITY_API", "KEY"].join("_"); -const openRouterPerplexityApiKey = ["sk", "or", "v1", "test"].join("-"); -const directPerplexityApiKey = ["pplx", "test"].join("-"); -const enterprisePerplexityApiKey = ["enterprise", "perplexity", "test"].join("-"); - -describe("web_search perplexity compatibility routing", () => { - it("detects API key prefixes", () => { - expect(inferPerplexityBaseUrlFromApiKey("pplx-123")).toBe("direct"); - expect(inferPerplexityBaseUrlFromApiKey("sk-or-v1-123")).toBe("openrouter"); - expect(inferPerplexityBaseUrlFromApiKey("unknown-key")).toBeUndefined(); - }); - - it("prefers explicit baseUrl over key-based defaults", () => { - expect(resolvePerplexityBaseUrl({ baseUrl: "https://example.com" }, "config", "pplx-123")).toBe( - "https://example.com", - ); - }); - - it("resolves OpenRouter env auth and transport", () => { - withEnv( - { [perplexityApiKeyEnv]: undefined, [openRouterApiKeyEnv]: openRouterPerplexityApiKey }, - () => { - expect(resolvePerplexityApiKey(undefined)).toEqual({ - apiKey: openRouterPerplexityApiKey, - source: "openrouter_env", - }); - expect(resolvePerplexityTransport(undefined)).toMatchObject({ - baseUrl: "https://openrouter.ai/api/v1", - model: "perplexity/sonar-pro", - transport: "chat_completions", - }); - }, - ); - }); - - it("uses native Search API for direct Perplexity when no legacy overrides exist", () => { - withEnv( - { [perplexityApiKeyEnv]: directPerplexityApiKey, [openRouterApiKeyEnv]: undefined }, - () => { - expect(resolvePerplexityTransport(undefined)).toMatchObject({ - baseUrl: "https://api.perplexity.ai", - model: "perplexity/sonar-pro", - transport: "search_api", - }); - }, - ); - }); - - it("switches direct Perplexity to chat completions when model override is configured", () => { - expect(resolvePerplexityModel({ model: "perplexity/sonar-reasoning-pro" })).toBe( - "perplexity/sonar-reasoning-pro", - ); - expect( - resolvePerplexityTransport({ - apiKey: directPerplexityApiKey, - model: "perplexity/sonar-reasoning-pro", - }), - ).toMatchObject({ - baseUrl: "https://api.perplexity.ai", - model: "perplexity/sonar-reasoning-pro", - transport: "chat_completions", - }); - }); - - it("treats unrecognized configured keys as direct Perplexity by default", () => { - expect( - resolvePerplexityTransport({ - apiKey: enterprisePerplexityApiKey, - }), - ).toMatchObject({ - baseUrl: "https://api.perplexity.ai", - transport: "search_api", - }); - }); - - it("normalizes direct Perplexity models for chat completions", () => { - expect(isDirectPerplexityBaseUrl("https://api.perplexity.ai")).toBe(true); - expect(isDirectPerplexityBaseUrl("https://openrouter.ai/api/v1")).toBe(false); - expect(resolvePerplexityRequestModel("https://api.perplexity.ai", "perplexity/sonar-pro")).toBe( - "sonar-pro", - ); - expect( - resolvePerplexityRequestModel("https://openrouter.ai/api/v1", "perplexity/sonar-pro"), - ).toBe("perplexity/sonar-pro"); - }); -}); - -describe("web_search brave language param normalization", () => { - it("normalizes and auto-corrects swapped Brave language params", () => { - expect(normalizeBraveLanguageParams({ search_lang: "tr-TR", ui_lang: "tr" })).toEqual({ - search_lang: "tr", - ui_lang: "tr-TR", - }); - expect(normalizeBraveLanguageParams({ search_lang: "EN", ui_lang: "en-us" })).toEqual({ - search_lang: "en", - ui_lang: "en-US", - }); - }); - - it("flags invalid Brave language formats", () => { - expect(normalizeBraveLanguageParams({ search_lang: "en-US" })).toEqual({ - invalidField: "search_lang", - }); - expect(normalizeBraveLanguageParams({ ui_lang: "en" })).toEqual({ - invalidField: "ui_lang", - }); - }); -}); - -describe("web_search freshness normalization", () => { - it("accepts Brave shortcut values and maps for Perplexity", () => { - expect(normalizeFreshness("pd", "brave")).toBe("pd"); - expect(normalizeFreshness("PW", "brave")).toBe("pw"); - expect(normalizeFreshness("pd", "perplexity")).toBe("day"); - expect(normalizeFreshness("pw", "perplexity")).toBe("week"); - }); - - it("accepts Perplexity values and maps for Brave", () => { - expect(normalizeFreshness("day", "perplexity")).toBe("day"); - expect(normalizeFreshness("week", "perplexity")).toBe("week"); - expect(normalizeFreshness("day", "brave")).toBe("pd"); - expect(normalizeFreshness("week", "brave")).toBe("pw"); - }); - - it("accepts valid date ranges for Brave", () => { - expect(normalizeFreshness("2024-01-01to2024-01-31", "brave")).toBe("2024-01-01to2024-01-31"); - }); - - it("rejects invalid values", () => { - expect(normalizeFreshness("yesterday", "brave")).toBeUndefined(); - expect(normalizeFreshness("yesterday", "perplexity")).toBeUndefined(); - expect(normalizeFreshness("2024-01-01to2024-01-31", "perplexity")).toBeUndefined(); - }); - - it("rejects invalid date ranges for Brave", () => { - expect(normalizeFreshness("2024-13-01to2024-01-31", "brave")).toBeUndefined(); - expect(normalizeFreshness("2024-02-30to2024-03-01", "brave")).toBeUndefined(); - expect(normalizeFreshness("2024-03-10to2024-03-01", "brave")).toBeUndefined(); - }); -}); +const { normalizeToIsoDate } = __testing; describe("web_search date normalization", () => { it("accepts ISO format", () => { @@ -186,285 +19,4 @@ describe("web_search date normalization", () => { expect(normalizeToIsoDate("2024/01/15")).toBeUndefined(); expect(normalizeToIsoDate("invalid")).toBeUndefined(); }); - - it("converts ISO to Perplexity format", () => { - expect(isoToPerplexityDate("2024-01-15")).toBe("1/15/2024"); - expect(isoToPerplexityDate("2025-12-31")).toBe("12/31/2025"); - expect(isoToPerplexityDate("2024-03-05")).toBe("3/5/2024"); - }); - - it("rejects invalid ISO dates", () => { - expect(isoToPerplexityDate("1/15/2024")).toBeUndefined(); - expect(isoToPerplexityDate("invalid")).toBeUndefined(); - }); -}); - -describe("web_search grok config resolution", () => { - it("uses config apiKey when provided", () => { - expect(resolveGrokApiKey({ apiKey: "xai-test-key" })).toBe("xai-test-key"); // pragma: allowlist secret - }); - - it("returns undefined when no apiKey is available", () => { - withEnv({ XAI_API_KEY: undefined }, () => { - expect(resolveGrokApiKey({})).toBeUndefined(); - expect(resolveGrokApiKey(undefined)).toBeUndefined(); - }); - }); - - it("uses default model when not specified", () => { - expect(resolveGrokModel({})).toBe("grok-4-1-fast"); - expect(resolveGrokModel(undefined)).toBe("grok-4-1-fast"); - }); - - it("uses config model when provided", () => { - expect(resolveGrokModel({ model: "grok-3" })).toBe("grok-3"); - }); - - it("defaults inlineCitations to false", () => { - expect(resolveGrokInlineCitations({})).toBe(false); - expect(resolveGrokInlineCitations(undefined)).toBe(false); - }); - - it("respects inlineCitations config", () => { - expect(resolveGrokInlineCitations({ inlineCitations: true })).toBe(true); - expect(resolveGrokInlineCitations({ inlineCitations: false })).toBe(false); - }); -}); - -describe("web_search grok response parsing", () => { - it("extracts content from Responses API message blocks", () => { - const result = extractGrokContent({ - output: [ - { - type: "message", - content: [{ type: "output_text", text: "hello from output" }], - }, - ], - }); - expect(result.text).toBe("hello from output"); - expect(result.annotationCitations).toEqual([]); - }); - - it("extracts url_citation annotations from content blocks", () => { - const result = extractGrokContent({ - output: [ - { - type: "message", - content: [ - { - type: "output_text", - text: "hello with citations", - annotations: [ - { - type: "url_citation", - url: "https://example.com/a", - start_index: 0, - end_index: 5, - }, - { - type: "url_citation", - url: "https://example.com/b", - start_index: 6, - end_index: 10, - }, - { - type: "url_citation", - url: "https://example.com/a", - start_index: 11, - end_index: 15, - }, // duplicate - ], - }, - ], - }, - ], - }); - expect(result.text).toBe("hello with citations"); - expect(result.annotationCitations).toEqual(["https://example.com/a", "https://example.com/b"]); - }); - - it("falls back to deprecated output_text", () => { - const result = extractGrokContent({ output_text: "hello from output_text" }); - expect(result.text).toBe("hello from output_text"); - expect(result.annotationCitations).toEqual([]); - }); - - it("returns undefined text when no content found", () => { - const result = extractGrokContent({}); - expect(result.text).toBeUndefined(); - expect(result.annotationCitations).toEqual([]); - }); - - it("extracts output_text blocks directly in output array (no message wrapper)", () => { - const result = extractGrokContent({ - output: [ - { type: "web_search_call" }, - { - type: "output_text", - text: "direct output text", - annotations: [ - { - type: "url_citation", - url: "https://example.com/direct", - start_index: 0, - end_index: 5, - }, - ], - }, - ], - } as Parameters[0]); - expect(result.text).toBe("direct output text"); - expect(result.annotationCitations).toEqual(["https://example.com/direct"]); - }); -}); - -describe("web_search kimi config resolution", () => { - it("uses config apiKey when provided", () => { - expect(resolveKimiApiKey({ apiKey: "kimi-test-key" })).toBe("kimi-test-key"); // pragma: allowlist secret - }); - - it("falls back to KIMI_API_KEY, then MOONSHOT_API_KEY", () => { - const kimiEnvValue = "kimi-env"; // pragma: allowlist secret - const moonshotEnvValue = "moonshot-env"; // pragma: allowlist secret - withEnv({ [kimiApiKeyEnv]: kimiEnvValue, [moonshotApiKeyEnv]: moonshotEnvValue }, () => { - expect(resolveKimiApiKey({})).toBe(kimiEnvValue); - }); - withEnv({ [kimiApiKeyEnv]: undefined, [moonshotApiKeyEnv]: moonshotEnvValue }, () => { - expect(resolveKimiApiKey({})).toBe(moonshotEnvValue); - }); - }); - - it("returns undefined when no Kimi key is configured", () => { - withEnv({ KIMI_API_KEY: undefined, MOONSHOT_API_KEY: undefined }, () => { - expect(resolveKimiApiKey({})).toBeUndefined(); - expect(resolveKimiApiKey(undefined)).toBeUndefined(); - }); - }); - - it("resolves default model and baseUrl", () => { - expect(resolveKimiModel({})).toBe("moonshot-v1-128k"); - expect(resolveKimiBaseUrl({})).toBe("https://api.moonshot.ai/v1"); - }); -}); - -describe("extractKimiCitations", () => { - it("collects unique URLs from search_results and tool arguments", () => { - expect( - extractKimiCitations({ - search_results: [{ url: "https://example.com/a" }, { url: "https://example.com/a" }], - choices: [ - { - message: { - tool_calls: [ - { - function: { - arguments: JSON.stringify({ - search_results: [{ url: "https://example.com/b" }], - url: "https://example.com/c", - }), - }, - }, - ], - }, - }, - ], - }).toSorted(), - ).toEqual(["https://example.com/a", "https://example.com/b", "https://example.com/c"]); - }); -}); - -describe("resolveBraveMode", () => { - it("defaults to 'web' when no config is provided", () => { - expect(resolveBraveMode({})).toBe("web"); - }); - - it("defaults to 'web' when mode is undefined", () => { - expect(resolveBraveMode({ mode: undefined })).toBe("web"); - }); - - it("returns 'llm-context' when configured", () => { - expect(resolveBraveMode({ mode: "llm-context" })).toBe("llm-context"); - }); - - it("returns 'web' when mode is explicitly 'web'", () => { - expect(resolveBraveMode({ mode: "web" })).toBe("web"); - }); - - it("falls back to 'web' for unrecognized mode values", () => { - expect(resolveBraveMode({ mode: "invalid" })).toBe("web"); - }); -}); - -describe("mapBraveLlmContextResults", () => { - it("maps plain string snippets correctly", () => { - const results = mapBraveLlmContextResults({ - grounding: { - generic: [ - { - url: "https://example.com/page", - title: "Example Page", - snippets: ["first snippet", "second snippet"], - }, - ], - }, - }); - expect(results).toEqual([ - { - url: "https://example.com/page", - title: "Example Page", - snippets: ["first snippet", "second snippet"], - siteName: "example.com", - }, - ]); - }); - - it("filters out non-string and empty snippets", () => { - const results = mapBraveLlmContextResults({ - grounding: { - generic: [ - { - url: "https://example.com", - title: "Test", - snippets: ["valid", "", null, undefined, 42, { text: "object" }] as string[], - }, - ], - }, - }); - expect(results[0].snippets).toEqual(["valid"]); - }); - - it("handles missing snippets array", () => { - const results = mapBraveLlmContextResults({ - grounding: { - generic: [{ url: "https://example.com", title: "No Snippets" } as never], - }, - }); - expect(results[0].snippets).toEqual([]); - }); - - it("handles empty grounding.generic", () => { - expect(mapBraveLlmContextResults({ grounding: { generic: [] } })).toEqual([]); - }); - - it("handles missing grounding.generic", () => { - expect(mapBraveLlmContextResults({ grounding: {} } as never)).toEqual([]); - }); - - it("resolves siteName from URL hostname", () => { - const results = mapBraveLlmContextResults({ - grounding: { - generic: [{ url: "https://docs.example.org/path", title: "Docs", snippets: ["text"] }], - }, - }); - expect(results[0].siteName).toBe("docs.example.org"); - }); - - it("sets siteName to undefined for invalid URLs", () => { - const results = mapBraveLlmContextResults({ - grounding: { - generic: [{ url: "not-a-url", title: "Bad URL", snippets: ["text"] }], - }, - }); - expect(results[0].siteName).toBeUndefined(); - }); }); diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 64972b890d2..4b45b74cbbf 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -1,7 +1,6 @@ import { Type } from "@sinclair/typebox"; import { formatCliCommand as _formatCliCommand } from "../../cli/command-format.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { normalizeResolvedSecretInputString } from "../../config/types.secrets.js"; import { logVerbose } from "../../globals.js"; import { resolveCapabilitySlotSelection } from "../../plugins/capability-slots.js"; import { loadOpenClawPlugins } from "../../plugins/loader.js"; @@ -15,15 +14,10 @@ import type { } 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"; import { jsonResult, readNumberParam, readStringArrayParam, readStringParam } from "./common.js"; import { withTrustedWebToolsEndpoint } from "./web-guarded-fetch.js"; import { resolveCitationRedirectUrl } from "./web-search-citation-redirect.js"; -import { - type BuiltinWebSearchProviderId, - isBuiltinWebSearchProviderId as isBuiltinWebSearchProviderIdFromCatalog, -} from "./web-search-provider-catalog.js"; import { CacheEntry, DEFAULT_CACHE_TTL_MINUTES, @@ -38,105 +32,18 @@ import { 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"; -const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1"; -const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai"; const PERPLEXITY_SEARCH_ENDPOINT = "https://api.perplexity.ai/search"; -const DEFAULT_PERPLEXITY_MODEL = "perplexity/sonar-pro"; -const PERPLEXITY_KEY_PREFIXES = ["pplx-"]; -const OPENROUTER_KEY_PREFIXES = ["sk-or-"]; const XAI_API_ENDPOINT = "https://api.x.ai/v1/responses"; -const DEFAULT_GROK_MODEL = "grok-4-1-fast"; -const DEFAULT_KIMI_BASE_URL = "https://api.moonshot.ai/v1"; -const DEFAULT_KIMI_MODEL = "moonshot-v1-128k"; const KIMI_WEB_SEARCH_TOOL = { type: "builtin_function", function: { name: "$web_search" }, } as const; const SEARCH_CACHE = new Map>>(); -const BRAVE_FRESHNESS_SHORTCUTS = new Set(["pd", "pw", "pm", "py"]); -const BRAVE_FRESHNESS_RANGE = /^(\d{4}-\d{2}-\d{2})to(\d{4}-\d{2}-\d{2})$/; -const BRAVE_SEARCH_LANG_CODES = new Set([ - "ar", - "eu", - "bn", - "bg", - "ca", - "zh-hans", - "zh-hant", - "hr", - "cs", - "da", - "nl", - "en", - "en-gb", - "et", - "fi", - "fr", - "gl", - "de", - "el", - "gu", - "he", - "hi", - "hu", - "is", - "it", - "jp", - "kn", - "ko", - "lv", - "lt", - "ms", - "ml", - "mr", - "nb", - "pl", - "pt-br", - "pt-pt", - "pa", - "ro", - "ru", - "sr", - "sk", - "sl", - "es", - "sv", - "ta", - "te", - "th", - "tr", - "uk", - "vi", -]); -const BRAVE_SEARCH_LANG_ALIASES: Record = { - ja: "jp", - zh: "zh-hans", - "zh-cn": "zh-hans", - "zh-hk": "zh-hant", - "zh-sg": "zh-hans", - "zh-tw": "zh-hant", -}; -const BRAVE_UI_LANG_LOCALE = /^([a-z]{2})-([a-z]{2})$/i; -const PERPLEXITY_RECENCY_VALUES = new Set(["day", "week", "month", "year"]); - -const FRESHNESS_TO_RECENCY: Record = { - pd: "day", - pw: "week", - pm: "month", - py: "year", -}; -const RECENCY_TO_FRESHNESS: Record = { - day: "pd", - week: "pw", - month: "pm", - year: "py", -}; const ISO_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/; const PERPLEXITY_DATE_PATTERN = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/; @@ -211,15 +118,6 @@ const SEARCH_PLUGIN_EXTENSION_FIELDS = { ), } as const; -function isoToPerplexityDate(iso: string): string | undefined { - const match = iso.match(ISO_DATE_PATTERN); - if (!match) { - return undefined; - } - const [, year, month, day] = match; - return `${parseInt(month, 10)}/${parseInt(day, 10)}/${year}`; -} - function normalizeToIsoDate(value: string): string | undefined { const trimmed = value.trim(); if (ISO_DATE_PATTERN.test(trimmed)) { @@ -234,98 +132,6 @@ function normalizeToIsoDate(value: string): string | undefined { return undefined; } -function createWebSearchSchema(params: { - provider: BuiltinWebSearchProviderId; - perplexityTransport?: PerplexityTransport; -}) { - const perplexityStructuredFilterSchema = { - country: Type.Optional( - Type.String({ - description: - "Native Perplexity Search API only. 2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.", - }), - ), - language: Type.Optional( - Type.String({ - description: - "Native Perplexity Search API only. ISO 639-1 language code for results (e.g., 'en', 'de', 'fr').", - }), - ), - date_after: Type.Optional( - Type.String({ - description: - "Native Perplexity Search API only. Only results published after this date (YYYY-MM-DD).", - }), - ), - date_before: Type.Optional( - Type.String({ - description: - "Native Perplexity Search API only. Only results published before this date (YYYY-MM-DD).", - }), - ), - } as const; - - if (params.provider === "brave") { - return Type.Object({ - ...SEARCH_QUERY_SCHEMA_FIELDS, - ...SEARCH_FILTER_SCHEMA_FIELDS, - search_lang: Type.Optional( - Type.String({ - description: - "Brave language code for search results (e.g., 'en', 'de', 'en-gb', 'zh-hans', 'zh-hant', 'pt-br').", - }), - ), - ui_lang: Type.Optional( - Type.String({ - description: - "Locale code for UI elements in language-region format (e.g., 'en-US', 'de-DE', 'fr-FR', 'tr-TR'). Must include region subtag.", - }), - ), - }); - } - - if (params.provider === "perplexity") { - if (params.perplexityTransport === "chat_completions") { - return Type.Object({ - ...SEARCH_QUERY_SCHEMA_FIELDS, - freshness: SEARCH_FILTER_SCHEMA_FIELDS.freshness, - }); - } - return Type.Object({ - ...SEARCH_QUERY_SCHEMA_FIELDS, - freshness: SEARCH_FILTER_SCHEMA_FIELDS.freshness, - ...perplexityStructuredFilterSchema, - domain_filter: Type.Optional( - Type.Array(Type.String(), { - description: - "Native Perplexity Search API only. Domain filter (max 20). Allowlist: ['nature.com'] or denylist: ['-reddit.com']. Cannot mix.", - }), - ), - max_tokens: Type.Optional( - Type.Number({ - description: - "Native Perplexity Search API only. Total content budget across all results (default: 25000, max: 1000000).", - minimum: 1, - maximum: 1000000, - }), - ), - max_tokens_per_page: Type.Optional( - Type.Number({ - description: - "Native Perplexity Search API only. Max tokens extracted per page (default: 2048).", - minimum: 1, - }), - ), - }); - } - - // grok, gemini, kimi, etc. - return Type.Object({ - ...SEARCH_QUERY_SCHEMA_FIELDS, - ...SEARCH_FILTER_SCHEMA_FIELDS, - }); -} - type WebSearchConfig = NonNullable["web"] extends infer Web ? Web extends { search?: infer Search } ? Search @@ -351,32 +157,6 @@ type BraveLlmContextResponse = { sources?: { url?: string; hostname?: string; date?: string }[]; }; -type BraveConfig = { - mode?: string; -}; - -type PerplexityConfig = { - apiKey?: string; - baseUrl?: string; - model?: string; -}; - -type PerplexityApiKeySource = "config" | "perplexity_env" | "openrouter_env" | "none"; -type PerplexityTransport = "search_api" | "chat_completions"; -type PerplexityBaseUrlHint = "direct" | "openrouter"; - -type GrokConfig = { - apiKey?: string; - model?: string; - inlineCitations?: boolean; -}; - -type KimiConfig = { - apiKey?: string; - baseUrl?: string; - model?: string; -}; - type GrokSearchResponse = { output?: Array<{ type?: string; @@ -539,11 +319,6 @@ function extractGrokContent(data: GrokSearchResponse): { return { text, annotationCitations: [] }; } -type GeminiConfig = { - apiKey?: string; - model?: string; -}; - type GeminiGroundingResponse = { candidates?: Array<{ content?: { @@ -592,166 +367,6 @@ function resolveSearchEnabled(params: { search?: WebSearchConfig; sandboxed?: bo return true; } -function resolveSearchApiKey(search?: WebSearchConfig): string | undefined { - const fromConfigRaw = - search && "apiKey" in search - ? normalizeResolvedSecretInputString({ - value: search.apiKey, - path: "tools.web.search.apiKey", - }) - : undefined; - const fromConfig = normalizeSecretInput(fromConfigRaw); - const fromEnv = normalizeSecretInput(process.env.BRAVE_API_KEY); - return fromConfig || fromEnv || undefined; -} - -function resolveBuiltinSearchProvider(search?: WebSearchConfig): BuiltinWebSearchProviderId { - const raw = - search && "provider" in search && typeof search.provider === "string" - ? search.provider.trim().toLowerCase() - : ""; - if (isBuiltinSearchProviderId(raw)) { - return raw; - } - - // Auto-detect provider from available API keys (alphabetical order) - if (raw === "") { - // Brave - if (resolveSearchApiKey(search)) { - logVerbose( - 'web_search: no provider configured, auto-detected "brave" from available API keys', - ); - return "brave"; - } - // Gemini - const geminiConfig = resolveGeminiConfig(search); - if (resolveGeminiApiKey(geminiConfig)) { - logVerbose( - 'web_search: no provider configured, auto-detected "gemini" from available API keys', - ); - return "gemini"; - } - // Grok - const grokConfig = resolveGrokConfig(search); - if (resolveGrokApiKey(grokConfig)) { - logVerbose( - 'web_search: no provider configured, auto-detected "grok" from available API keys', - ); - return "grok"; - } - // Kimi - const kimiConfig = resolveKimiConfig(search); - if (resolveKimiApiKey(kimiConfig)) { - logVerbose( - 'web_search: no provider configured, auto-detected "kimi" from available API keys', - ); - return "kimi"; - } - // Perplexity - const perplexityConfig = resolvePerplexityConfig(search); - const { apiKey: perplexityKey } = resolvePerplexityApiKey(perplexityConfig); - if (perplexityKey) { - logVerbose( - 'web_search: no provider configured, auto-detected "perplexity" from available API keys', - ); - return "perplexity"; - } - } - - return "brave"; -} - -function resolveBraveMode(brave: BraveConfig): "web" | "llm-context" { - return brave.mode === "llm-context" ? "llm-context" : "web"; -} - -function resolvePerplexityConfig(search?: WebSearchConfig): PerplexityConfig { - if (!search || typeof search !== "object") { - return {}; - } - const perplexity = "perplexity" in search ? search.perplexity : undefined; - if (!perplexity || typeof perplexity !== "object") { - return {}; - } - return perplexity as PerplexityConfig; -} - -function resolvePerplexityApiKey(perplexity?: PerplexityConfig): { - apiKey?: string; - source: PerplexityApiKeySource; -} { - const fromConfig = normalizeApiKey(perplexity?.apiKey); - if (fromConfig) { - return { apiKey: fromConfig, source: "config" }; - } - - const fromEnvPerplexity = normalizeApiKey(process.env.PERPLEXITY_API_KEY); - if (fromEnvPerplexity) { - return { apiKey: fromEnvPerplexity, source: "perplexity_env" }; - } - - const fromEnvOpenRouter = normalizeApiKey(process.env.OPENROUTER_API_KEY); - if (fromEnvOpenRouter) { - return { apiKey: fromEnvOpenRouter, source: "openrouter_env" }; - } - - return { apiKey: undefined, source: "none" }; -} - -function normalizeApiKey(key: unknown): string { - return normalizeSecretInput(key); -} - -function inferPerplexityBaseUrlFromApiKey(apiKey?: string): PerplexityBaseUrlHint | undefined { - if (!apiKey) { - return undefined; - } - const normalized = apiKey.toLowerCase(); - if (PERPLEXITY_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { - return "direct"; - } - if (OPENROUTER_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { - return "openrouter"; - } - return undefined; -} - -function resolvePerplexityBaseUrl( - perplexity?: PerplexityConfig, - authSource: PerplexityApiKeySource = "none", // pragma: allowlist secret - configuredKey?: string, -): string { - const fromConfig = - perplexity && "baseUrl" in perplexity && typeof perplexity.baseUrl === "string" - ? perplexity.baseUrl.trim() - : ""; - if (fromConfig) { - return fromConfig; - } - if (authSource === "perplexity_env") { - return PERPLEXITY_DIRECT_BASE_URL; - } - if (authSource === "openrouter_env") { - return DEFAULT_PERPLEXITY_BASE_URL; - } - if (authSource === "config") { - const inferred = inferPerplexityBaseUrlFromApiKey(configuredKey); - if (inferred === "openrouter") { - return DEFAULT_PERPLEXITY_BASE_URL; - } - return PERPLEXITY_DIRECT_BASE_URL; - } - return DEFAULT_PERPLEXITY_BASE_URL; -} - -function resolvePerplexityModel(perplexity?: PerplexityConfig): string { - const fromConfig = - perplexity && "model" in perplexity && typeof perplexity.model === "string" - ? perplexity.model.trim() - : ""; - return fromConfig || DEFAULT_PERPLEXITY_MODEL; -} - function isDirectPerplexityBaseUrl(baseUrl: string): boolean { const trimmed = baseUrl.trim(); if (!trimmed) { @@ -771,125 +386,6 @@ function resolvePerplexityRequestModel(baseUrl: string, model: string): string { return model.startsWith("perplexity/") ? model.slice("perplexity/".length) : model; } -function resolvePerplexityTransport(perplexity?: PerplexityConfig): { - apiKey?: string; - source: PerplexityApiKeySource; - baseUrl: string; - model: string; - transport: PerplexityTransport; -} { - const auth = resolvePerplexityApiKey(perplexity); - const baseUrl = resolvePerplexityBaseUrl(perplexity, auth.source, auth.apiKey); - const model = resolvePerplexityModel(perplexity); - const hasLegacyOverride = Boolean( - (perplexity?.baseUrl && perplexity.baseUrl.trim()) || - (perplexity?.model && perplexity.model.trim()), - ); - return { - ...auth, - baseUrl, - model, - transport: - hasLegacyOverride || !isDirectPerplexityBaseUrl(baseUrl) ? "chat_completions" : "search_api", - }; -} - -function resolvePerplexitySchemaTransportHint( - perplexity?: PerplexityConfig, -): PerplexityTransport | undefined { - const hasLegacyOverride = Boolean( - (perplexity?.baseUrl && perplexity.baseUrl.trim()) || - (perplexity?.model && perplexity.model.trim()), - ); - return hasLegacyOverride ? "chat_completions" : undefined; -} - -function resolveGrokConfig(search?: WebSearchConfig): GrokConfig { - if (!search || typeof search !== "object") { - return {}; - } - const grok = "grok" in search ? search.grok : undefined; - if (!grok || typeof grok !== "object") { - return {}; - } - return grok as GrokConfig; -} - -function resolveGrokApiKey(grok?: GrokConfig): string | undefined { - const fromConfig = normalizeApiKey(grok?.apiKey); - if (fromConfig) { - return fromConfig; - } - const fromEnv = normalizeApiKey(process.env.XAI_API_KEY); - return fromEnv || undefined; -} - -function resolveGrokModel(grok?: GrokConfig): string { - const fromConfig = - grok && "model" in grok && typeof grok.model === "string" ? grok.model.trim() : ""; - return fromConfig || DEFAULT_GROK_MODEL; -} - -function resolveGrokInlineCitations(grok?: GrokConfig): boolean { - return grok?.inlineCitations === true; -} - -function resolveKimiConfig(search?: WebSearchConfig): KimiConfig { - if (!search || typeof search !== "object") { - return {}; - } - const kimi = "kimi" in search ? search.kimi : undefined; - if (!kimi || typeof kimi !== "object") { - return {}; - } - return kimi as KimiConfig; -} - -function resolveKimiApiKey(kimi?: KimiConfig): string | undefined { - const fromConfig = normalizeApiKey(kimi?.apiKey); - if (fromConfig) { - return fromConfig; - } - const fromEnvKimi = normalizeApiKey(process.env.KIMI_API_KEY); - if (fromEnvKimi) { - return fromEnvKimi; - } - const fromEnvMoonshot = normalizeApiKey(process.env.MOONSHOT_API_KEY); - return fromEnvMoonshot || undefined; -} - -function resolveKimiModel(kimi?: KimiConfig): string { - const fromConfig = - kimi && "model" in kimi && typeof kimi.model === "string" ? kimi.model.trim() : ""; - return fromConfig || DEFAULT_KIMI_MODEL; -} - -function resolveKimiBaseUrl(kimi?: KimiConfig): string { - const fromConfig = - kimi && "baseUrl" in kimi && typeof kimi.baseUrl === "string" ? kimi.baseUrl.trim() : ""; - return fromConfig || DEFAULT_KIMI_BASE_URL; -} - -function resolveGeminiConfig(search?: WebSearchConfig): GeminiConfig { - if (!search || typeof search !== "object") { - return {}; - } - const gemini = "gemini" in search ? search.gemini : undefined; - if (!gemini || typeof gemini !== "object") { - return {}; - } - return gemini as GeminiConfig; -} - -function resolveGeminiApiKey(gemini?: GeminiConfig): string | undefined { - const fromConfig = normalizeApiKey(gemini?.apiKey); - if (fromConfig) { - return fromConfig; - } - const fromEnv = normalizeApiKey(process.env.GEMINI_API_KEY); - return fromEnv || undefined; -} - async function withTrustedWebSearchEndpoint( params: { url: string; @@ -1002,107 +498,6 @@ function resolveSearchCount(value: unknown, fallback: number): number { return clamped; } -function normalizeBraveSearchLang(value: string | undefined): string | undefined { - if (!value) { - return undefined; - } - const trimmed = value.trim(); - if (!trimmed) { - return undefined; - } - const canonical = BRAVE_SEARCH_LANG_ALIASES[trimmed.toLowerCase()] ?? trimmed.toLowerCase(); - if (!BRAVE_SEARCH_LANG_CODES.has(canonical)) { - return undefined; - } - return canonical; -} - -function normalizeBraveUiLang(value: string | undefined): string | undefined { - if (!value) { - return undefined; - } - const trimmed = value.trim(); - if (!trimmed) { - return undefined; - } - const match = trimmed.match(BRAVE_UI_LANG_LOCALE); - if (!match) { - return undefined; - } - const [, language, region] = match; - return `${language.toLowerCase()}-${region.toUpperCase()}`; -} - -function normalizeBraveLanguageParams(params: { search_lang?: string; ui_lang?: string }): { - search_lang?: string; - ui_lang?: string; - invalidField?: "search_lang" | "ui_lang"; -} { - const rawSearchLang = params.search_lang?.trim() || undefined; - const rawUiLang = params.ui_lang?.trim() || undefined; - let searchLangCandidate = rawSearchLang; - let uiLangCandidate = rawUiLang; - - // Recover common LLM mix-up: locale in search_lang + short code in ui_lang. - if (normalizeBraveUiLang(rawSearchLang) && normalizeBraveSearchLang(rawUiLang)) { - searchLangCandidate = rawUiLang; - uiLangCandidate = rawSearchLang; - } - - const search_lang = normalizeBraveSearchLang(searchLangCandidate); - if (searchLangCandidate && !search_lang) { - return { invalidField: "search_lang" }; - } - - const ui_lang = normalizeBraveUiLang(uiLangCandidate); - if (uiLangCandidate && !ui_lang) { - return { invalidField: "ui_lang" }; - } - - return { search_lang, ui_lang }; -} - -/** - * Normalizes freshness shortcut to the provider's expected format. - * Accepts both Brave format (pd/pw/pm/py) and Perplexity format (day/week/month/year). - * For Brave, also accepts date ranges (YYYY-MM-DDtoYYYY-MM-DD). - */ -function normalizeFreshness( - value: string | undefined, - provider: BuiltinWebSearchProviderId, -): string | undefined { - if (!value) { - return undefined; - } - const trimmed = value.trim(); - if (!trimmed) { - return undefined; - } - - const lower = trimmed.toLowerCase(); - - if (BRAVE_FRESHNESS_SHORTCUTS.has(lower)) { - return provider === "brave" ? lower : FRESHNESS_TO_RECENCY[lower]; - } - - if (PERPLEXITY_RECENCY_VALUES.has(lower)) { - return provider === "perplexity" ? lower : RECENCY_TO_FRESHNESS[lower]; - } - - // Brave date range support - if (provider === "brave") { - const match = trimmed.match(BRAVE_FRESHNESS_RANGE); - if (match) { - const [, start, end] = match; - if (isValidIsoDate(start) && isValidIsoDate(end) && start <= end) { - return `${start}to${end}`; - } - } - } - - return undefined; -} - function isValidIsoDate(value: string): boolean { if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { return false; @@ -1556,10 +951,6 @@ function normalizeSearchProviderId(value: string | undefined): string { return value?.trim().toLowerCase() ?? ""; } -function isBuiltinSearchProviderId(value: string): value is BuiltinWebSearchProviderId { - return isBuiltinWebSearchProviderIdFromCatalog(value); -} - function stableSerializeForCache(value: unknown): string { if (value === null || value === undefined) { return String(value); @@ -1677,7 +1068,7 @@ function executePluginSearchProvider(params: { ? stableSerializeForCache(params.context.pluginConfig) : "no-plugin-config"; const cacheKey = normalizeCacheKey( - `${params.provider.id}:${params.provider.pluginId || "builtin"}:${pluginConfigKey}:${buildSearchRequestCacheIdentity( + `${params.provider.id}:${params.provider.pluginId || "unregistered"}:${pluginConfigKey}:${buildSearchRequestCacheIdentity( { query: params.request.query, count: params.request.count, @@ -1800,13 +1191,6 @@ function getRegisteredSearchProviders(config?: OpenClawConfig): SearchProviderPl return registry.searchProviders.map((entry) => entry.provider); } -function resolveBuiltinSchemaProviderId( - provider: SearchProviderPlugin, -): BuiltinWebSearchProviderId | undefined { - const candidate = normalizeSearchProviderId(provider.id); - return isBuiltinSearchProviderId(candidate) ? candidate : undefined; -} - function resolveConfiguredSearchProviderId(params: { config?: OpenClawConfig; search?: WebSearchConfig; @@ -1823,36 +1207,15 @@ function resolveConfiguredSearchProviderId(params: { ); } -function resolvePreferredBuiltinSearchProvider(params: { - search?: WebSearchConfig; - runtimeWebSearch?: RuntimeWebSearchMetadata; - config?: OpenClawConfig; -}): BuiltinWebSearchProviderId { - const configuredProviderId = normalizeSearchProviderId( - resolveConfiguredSearchProviderId({ - config: params.config, - search: params.search, - }) ?? undefined, - ); - if (isBuiltinSearchProviderId(configuredProviderId)) { - 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 sortRegisteredSearchProviders(providers: SearchProviderPlugin[]): SearchProviderPlugin[] { + return [...providers].toSorted((left, right) => { + const leftPriority = left.setup?.autodetectPriority ?? Number.MAX_SAFE_INTEGER; + const rightPriority = right.setup?.autodetectPriority ?? Number.MAX_SAFE_INTEGER; + if (leftPriority !== rightPriority) { + return leftPriority - rightPriority; + } + return left.id.localeCompare(right.id); + }); } function resolveRegisteredSearchProvider(params: { @@ -1867,7 +1230,7 @@ function resolveRegisteredSearchProvider(params: { }) ?? undefined, ); const registeredProviders = new Map( - getRegisteredSearchProviders(params.config).map((provider) => [ + sortRegisteredSearchProviders(getRegisteredSearchProviders(params.config)).map((provider) => [ normalizeSearchProviderId(provider.id), provider, ]), @@ -1901,16 +1264,11 @@ function resolveRegisteredSearchProvider(params: { } } } - const preferredBuiltinProvider = resolvePreferredBuiltinSearchProvider({ - config: params.config, - search: params.search, - runtimeWebSearch: params.runtimeWebSearch, - }); - return ( - registeredProviders.get(preferredBuiltinProvider) ?? - registeredProviders.get(DEFAULT_PROVIDER) ?? - createMissingSearchProviderPlugin(preferredBuiltinProvider) - ); + const firstRegisteredProvider = registeredProviders.values().next().value; + if (firstRegisteredProvider) { + return firstRegisteredProvider; + } + return createMissingSearchProviderPlugin(configuredProviderId || "web-search"); } function createSearchProviderSchema(params: { @@ -1918,17 +1276,17 @@ function createSearchProviderSchema(params: { search?: WebSearchConfig; runtimeWebSearch?: RuntimeWebSearchMetadata; }) { - const providerId = resolveBuiltinSchemaProviderId(params.provider); - if (providerId) { - const perplexityTransport = - params.runtimeWebSearch?.selectedProvider === "perplexity" - ? params.runtimeWebSearch.perplexityTransport - : resolvePerplexitySchemaTransportHint(resolvePerplexityConfig(params.search)); - return createWebSearchSchema({ - provider: providerId, - perplexityTransport: providerId === "perplexity" ? perplexityTransport : undefined, + if (params.provider.setup?.resolveRequestSchema) { + return params.provider.setup.resolveRequestSchema({ + config: params.search + ? ({ tools: { web: { search: params.search } } } as OpenClawConfig) + : undefined, + runtimeMetadata: params.runtimeWebSearch as Record | undefined, }); } + if (params.provider.setup?.requestSchema) { + return params.provider.setup.requestSchema; + } return createExtensibleWebSearchSchema(); } @@ -1986,7 +1344,7 @@ function formatWebSearchExecutionLog(provider: SearchProviderPlugin): string { if (provider.pluginId) { return `web_search: executing plugin provider "${provider.id}" from "${provider.pluginId}"`; } - return `web_search: executing built-in provider "${provider.id}"`; + return `web_search: executing registered provider "${provider.id}"`; } export function createWebSearchTool(options?: { @@ -2073,31 +1431,9 @@ export function createWebSearchTool(options?: { } export const __testing = { - resolveSearchProvider: resolveBuiltinSearchProvider, + resolveSearchProvider: resolveRegisteredSearchProvider, resolveRegisteredSearchProvider, - inferPerplexityBaseUrlFromApiKey, - resolvePerplexityBaseUrl, - resolvePerplexityModel, - resolvePerplexityTransport, - isDirectPerplexityBaseUrl, - resolvePerplexityRequestModel, - resolvePerplexityApiKey, - normalizeBraveLanguageParams, - normalizeFreshness, normalizeToIsoDate, - isoToPerplexityDate, SEARCH_CACHE, - FRESHNESS_TO_RECENCY, - RECENCY_TO_FRESHNESS, - resolveGrokApiKey, - resolveGrokModel, - resolveGrokInlineCitations, - extractGrokContent, - resolveKimiApiKey, - resolveKimiModel, - resolveKimiBaseUrl, - extractKimiCitations, resolveRedirectUrl: resolveCitationRedirectUrl, - resolveBraveMode, - mapBraveLlmContextResults, } as const; diff --git a/src/agents/tools/web-tools.enabled-defaults.test.ts b/src/agents/tools/web-tools.enabled-defaults.test.ts index 91ab14c196a..62035ab1ad9 100644 --- a/src/agents/tools/web-tools.enabled-defaults.test.ts +++ b/src/agents/tools/web-tools.enabled-defaults.test.ts @@ -234,7 +234,7 @@ describe("web tools defaults", () => { expect(tool?.name).toBe("web_search"); }); - it("uses the configured built-in web_search provider from config", async () => { + it("uses the configured web_search provider from config", async () => { const mockFetch = installMockFetch(createProviderSuccessPayload("brave")); const tool = createWebSearchTool({ config: { @@ -263,7 +263,7 @@ describe("web tools defaults", () => { describe("web_search plugin providers", () => { it.each(["brave", "perplexity", "grok", "gemini", "kimi"] as const)( - "resolves configured built-in provider %s through bundled plugin registrations when available", + "resolves configured provider %s through plugin registrations when available", async (providerId) => { const registry = createEmptyPluginRegistry(); const bundledProvider = BUNDLED_PROVIDER_CREATORS[providerId](); @@ -328,7 +328,7 @@ describe("web_search plugin providers", () => { }, ); - it("prefers an explicitly configured plugin provider over a built-in provider with the same id", async () => { + it("prefers an explicitly configured plugin provider over another registered provider with the same id", async () => { const searchMock = vi.fn(async () => ({ results: [ { @@ -386,7 +386,7 @@ describe("web_search plugin providers", () => { 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 () => { + it("keeps an explicitly configured plugin provider even when other provider credentials are also present", async () => { const searchMock = vi.fn(async () => ({ content: "Plugin-configured answer", citations: ["https://example.com/plugin-configured"], @@ -437,7 +437,7 @@ describe("web_search plugin providers", () => { expect(details?.citations).toEqual(["https://example.com/plugin-configured"]); }); - it("auto-detects plugin providers before built-in API key detection", async () => { + it("auto-detects registered providers before falling back to later detection candidates", async () => { vi.stubEnv("BRAVE_API_KEY", "test-brave-key"); // pragma: allowlist secret const searchMock = vi.fn(async () => ({ content: "Plugin answer", diff --git a/src/commands/configure.wizard.test.ts b/src/commands/configure.wizard.test.ts index 572c7c85fa9..819894f7f42 100644 --- a/src/commands/configure.wizard.test.ts +++ b/src/commands/configure.wizard.test.ts @@ -454,7 +454,7 @@ describe("runConfigureWizard", () => { ); }); - it("configures a bundled plugin search provider from configure without the external install step", async () => { + it("configures a manifest-discovered search provider from configure without a separate install step", async () => { loadOpenClawPlugins.mockImplementation(({ config }: { config: OpenClawConfig }) => { const enabled = config.plugins?.entries?.["tavily-search"]?.enabled === true; return enabled @@ -509,6 +509,11 @@ describe("runConfigureWizard", () => { provides: ["providers.search.tavily"], origin: "bundled", source: "/tmp/bundled/tavily-search", + packageInstall: { + npmSpec: "@openclaw/tavily-search", + localPath: "extensions/tavily-search", + defaultChoice: "local", + }, configSchema: { type: "object", required: ["apiKey"], diff --git a/src/commands/onboard-search.test.ts b/src/commands/onboard-search.test.ts index 1a34674c367..ea37b9b6bcc 100644 --- a/src/commands/onboard-search.test.ts +++ b/src/commands/onboard-search.test.ts @@ -186,7 +186,7 @@ describe("setupSearch", () => { ); }); - it("shows bundled plugin providers directly in the picker while keeping the external install path available", async () => { + it("shows manifest-discovered providers directly in the picker while keeping install available", async () => { loadOpenClawPlugins.mockReturnValue({ searchProviders: [], plugins: [], @@ -201,6 +201,11 @@ describe("setupSearch", () => { provides: ["providers.search.tavily"], origin: "bundled", source: "/tmp/bundled/tavily-search", + packageInstall: { + npmSpec: "@openclaw/tavily-search", + localPath: "extensions/tavily-search", + defaultChoice: "local", + }, configSchema: { type: "object", required: ["apiKey"], @@ -255,7 +260,7 @@ describe("setupSearch", () => { ["kimi", "Kimi (Moonshot)"], ["perplexity", "Perplexity Search"], ] as const)( - "does not duplicate built-in provider %s when a bundled search plugin registers the same provider id", + "does not duplicate registered provider %s when a search plugin registers the same provider id", async (providerId, providerLabel) => { loadOpenClawPlugins.mockReturnValue({ searchProviders: [ @@ -534,13 +539,13 @@ describe("setupSearch", () => { expect.objectContaining({ options: expect.arrayContaining([ expect.objectContaining({ - value: "brave", - label: "Brave Search [Active]", + value: "__keep_current__", + label: "Keep current provider (brave)", }), expect.objectContaining({ value: "__install_plugin__", label: "Install provider plugin", - hint: "Add a web search plugin", + hint: "Install a web search plugin from npm or a local path", }), ]), }), @@ -675,7 +680,7 @@ describe("setupSearch", () => { ); }); - it("shows a provider setup note from before_search_provider_configure hooks", async () => { + it("shows a provider setup note from before_provider_configure hooks", async () => { loadOpenClawPlugins.mockReturnValue({ searchProviders: [ { @@ -719,13 +724,6 @@ describe("setupSearch", () => { source: "/tmp/tavily-search", handler: () => ({ note: "Generic provider guidance." }), }, - { - pluginId: "tavily-search", - hookName: "before_search_provider_configure", - priority: 0, - source: "/tmp/tavily-search", - handler: () => ({ note: "Read the provider docs before entering your key." }), - }, ], }); @@ -746,8 +744,7 @@ describe("setupSearch", () => { ); }); - it("fires after_search_provider_activate only when the active provider changes", async () => { - const afterActivate = vi.fn(); + it("fires after_provider_activate only when the active provider changes", async () => { const afterProviderActivate = vi.fn(); loadOpenClawPlugins.mockReturnValue({ searchProviders: [ @@ -781,13 +778,6 @@ describe("setupSearch", () => { source: "/tmp/tavily-search", handler: afterProviderActivate, }, - { - pluginId: "tavily-search", - hookName: "after_search_provider_activate", - priority: 0, - source: "/tmp/tavily-search", - handler: afterActivate, - }, ], }); @@ -833,85 +823,6 @@ describe("setupSearch", () => { workspaceDir: undefined, }), ); - expect(afterActivate).not.toHaveBeenCalled(); - }); - - it("fires legacy after_search_provider_activate hooks when no generic provider hook is registered", async () => { - const afterActivate = vi.fn(); - loadOpenClawPlugins.mockReturnValue({ - searchProviders: [ - { - pluginId: "tavily-search", - provider: { - id: "tavily", - name: "Tavily Search", - description: "Plugin search", - isAvailable: () => true, - search: async () => ({ content: "ok" }), - }, - }, - ], - plugins: [ - { - id: "tavily-search", - name: "Tavily Search", - description: "External Tavily plugin", - origin: "workspace", - source: "/tmp/tavily-search", - configJsonSchema: undefined, - configUiHints: undefined, - }, - ], - typedHooks: [ - { - pluginId: "tavily-search", - hookName: "after_search_provider_activate", - priority: 0, - source: "/tmp/tavily-search", - handler: afterActivate, - }, - ], - }); - - const cfg: OpenClawConfig = { - tools: { - web: { - search: { - provider: "brave", - enabled: true, - apiKey: "BSA-test-key", - }, - }, - }, - plugins: { - entries: { - "tavily-search": { - enabled: true, - config: { - apiKey: "tvly-existing-key", - }, - }, - }, - }, - }; - - const { prompter } = createPrompter({ - selectValue: "tavily", - }); - - const result = await setupSearch(cfg, runtime, prompter); - - expect(result.tools?.web?.search?.provider).toBe("tavily"); - expect(afterActivate).toHaveBeenCalledWith( - expect.objectContaining({ - providerId: "tavily", - previousProviderId: "brave", - intent: "switch-active", - }), - expect.objectContaining({ - workspaceDir: undefined, - }), - ); }); it("re-prompts invalid plugin config values before saving", async () => { @@ -1146,7 +1057,7 @@ describe("setupSearch", () => { }); }); - it("installs an external search plugin and continues provider setup for the discovered provider", async () => { + it("installs a search plugin and continues provider setup for the discovered provider", async () => { loadOpenClawPlugins.mockImplementation(({ config }: { config: OpenClawConfig }) => { const enabled = config.plugins?.entries?.["external-search"]?.enabled === true; return enabled diff --git a/src/commands/onboard-search.ts b/src/commands/onboard-search.ts index 7746bc63a3c..2284d6b3d09 100644 --- a/src/commands/onboard-search.ts +++ b/src/commands/onboard-search.ts @@ -18,7 +18,6 @@ import { validateJsonSchemaValue } from "../plugins/schema-validator.js"; import type { PluginConfigUiHint, SearchProviderCredentialMetadata, - SearchProviderLegacyConfigMetadata, SearchProviderSetupMetadata, } from "../plugins/types.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -28,15 +27,12 @@ import { ensureGenericOnboardingPluginInstalled, reloadOnboardingPluginRegistry, } from "./onboarding/plugin-install.js"; +import type { InstallablePluginCatalogEntry } from "./onboarding/plugin-install.js"; import { buildProviderSelectionOptions, promptProviderManagementIntent, type ProviderManagementIntent, } from "./provider-management.js"; -import { - SEARCH_PROVIDER_PLUGIN_INSTALL_CATALOG, - type InstallableSearchProviderPluginCatalogEntry, -} from "./search-provider-plugin-catalog.js"; export type SearchProvider = string; @@ -66,6 +62,11 @@ export type SearchProviderPickerEntry = PluginSearchProviderEntry; type SearchProviderPickerChoice = string; type SearchProviderFlowIntent = ProviderManagementIntent; +type InstallableSearchProviderPluginCatalogEntry = InstallablePluginCatalogEntry & { + providerId: string; + description: string; +}; + type PluginPromptableField = | { key: string; @@ -120,18 +121,8 @@ function humanizeConfigKey(value: string): string { function resolveProviderSetupMetadata( setup?: SearchProviderSetupMetadata, - legacyConfig?: SearchProviderLegacyConfigMetadata, ): SearchProviderSetupMetadata | undefined { - if (setup) { - return setup; - } - if (!legacyConfig) { - return undefined; - } - return { - hint: legacyConfig.hint, - credentials: legacyConfig, - }; + return setup; } function resolveProviderCredentialMetadata( @@ -140,18 +131,52 @@ function resolveProviderCredentialMetadata( return setup?.credentials; } -export function resolveInstallableSearchProviderPlugins( - providerEntries: SearchProviderPickerEntry[], -): InstallableSearchProviderPluginCatalogEntry[] { +function normalizeInstallMetadata(install?: { + npmSpec?: string; + localPath?: string; + defaultChoice?: "npm" | "local"; +}): InstallablePluginCatalogEntry["install"] | undefined { + if (!install?.npmSpec) { + return undefined; + } + return { + npmSpec: install.npmSpec, + ...(install.localPath ? { localPath: install.localPath } : {}), + ...(install.defaultChoice ? { defaultChoice: install.defaultChoice } : {}), + }; +} + +export function resolveInstallableSearchProviderPlugins(params: { + config: OpenClawConfig; + providerEntries: SearchProviderPickerEntry[]; + workspaceDir?: string; +}): InstallableSearchProviderPluginCatalogEntry[] { const loadedPluginProviderIds = new Set( - providerEntries.filter((entry) => entry.kind === "plugin").map((entry) => entry.value), + params.providerEntries.filter((entry) => entry.kind === "plugin").map((entry) => entry.value), ); - return SEARCH_PROVIDER_PLUGIN_INSTALL_CATALOG.filter( - (entry) => !loadedPluginProviderIds.has(entry.providerId), - ).map((entry) => ({ - ...entry, - description: entry.description, - })); + const registry = loadPluginManifestRegistry({ + config: params.config, + workspaceDir: params.workspaceDir, + cache: false, + }); + return registry.plugins + .map((plugin) => { + const providerId = searchProviderIdFromProvides(plugin.provides); + const install = normalizeInstallMetadata(plugin.packageInstall); + if (!providerId || !install?.npmSpec || loadedPluginProviderIds.has(providerId)) { + return undefined; + } + return { + id: plugin.id, + providerId, + meta: { + label: plugin.name || providerId, + }, + description: plugin.description || "Install a web search provider plugin.", + install, + } satisfies InstallableSearchProviderPluginCatalogEntry; + }) + .filter((entry): entry is InstallableSearchProviderPluginCatalogEntry => Boolean(entry)); } function normalizePluginConfigObject(value: unknown): Record { @@ -545,10 +570,7 @@ export async function resolveSearchProviderPickerEntries( configured = false; } - const setup = resolveProviderSetupMetadata( - registration.provider.setup, - registration.provider.legacyConfig, - ); + const setup = resolveProviderSetupMetadata(registration.provider.setup); const baseHint = setup?.hint?.trim() || registration.provider.description?.trim() || @@ -581,19 +603,14 @@ export async function resolveSearchProviderPickerEntries( } try { - loadPluginManifestRegistry({ + const registry = loadPluginManifestRegistry({ config, workspaceDir, cache: false, }); const loadedPluginProviderIds = new Set(pluginEntries.map((entry) => entry.value)); - const bundledManifestEntries = SEARCH_PROVIDER_PLUGIN_INSTALL_CATALOG.map((installEntry) => - buildPluginSearchProviderEntryFromManifest({ - config, - installEntry, - workspaceDir, - }), - ) + const manifestEntries = registry.plugins + .map((plugin) => buildPluginSearchProviderEntryFromManifestRecord(plugin)) .filter( (entry): entry is PluginSearchProviderEntry => Boolean(entry) && !loadedPluginProviderIds.has(entry.value), @@ -606,7 +623,7 @@ export async function resolveSearchProviderPickerEntries( configured: validation.ok, }; }); - pluginEntries = [...pluginEntries, ...bundledManifestEntries].toSorted((left, right) => + pluginEntries = [...pluginEntries, ...manifestEntries].toSorted((left, right) => left.label.localeCompare(right.label), ); } catch { @@ -638,6 +655,11 @@ function buildPluginSearchProviderEntryFromManifestRecord(pluginRecord: { configSchema?: Record; configUiHints?: Record; provides: string[]; + packageInstall?: { + npmSpec?: string; + localPath?: string; + defaultChoice?: "npm" | "local"; + }; }): PluginSearchProviderEntry | undefined { const providerId = searchProviderIdFromProvides(pluginRecord.provides); if (!providerId) { @@ -656,27 +678,13 @@ function buildPluginSearchProviderEntryFromManifestRecord(pluginRecord: { configFieldOrder: undefined, configJsonSchema: pluginRecord.configSchema, configUiHints: pluginRecord.configUiHints, - setup: undefined, + setup: (() => { + const install = normalizeInstallMetadata(pluginRecord.packageInstall); + return install ? { install } : undefined; + })(), }; } -function buildPluginSearchProviderEntryFromManifest(params: { - config: OpenClawConfig; - installEntry: InstallableSearchProviderPluginCatalogEntry; - workspaceDir?: string; -}): PluginSearchProviderEntry | undefined { - const registry = loadPluginManifestRegistry({ - config: params.config, - workspaceDir: params.workspaceDir, - cache: false, - }); - const pluginRecord = registry.plugins.find((plugin) => plugin.id === params.installEntry.id); - if (!pluginRecord) { - return undefined; - } - return buildPluginSearchProviderEntryFromManifestRecord(pluginRecord); -} - async function installSearchProviderPlugin(params: { config: OpenClawConfig; runtime: RuntimeEnv; @@ -838,6 +846,7 @@ type SearchProviderPickerModelParams = { providerEntries: SearchProviderPickerEntry[]; includeSkipOption: boolean; skipHint?: string; + workspaceDir?: string; }; type SearchProviderPickerModel = { @@ -869,7 +878,7 @@ function formatPickerEntryHint(params: { export function buildSearchProviderPickerModel( params: SearchProviderPickerModelParams, ): SearchProviderPickerModel { - const { config, providerEntries, includeSkipOption, skipHint } = params; + const { config, providerEntries, includeSkipOption, skipHint, workspaceDir } = params; const existingProvider = resolveCapabilitySlotSelection(config, "providers.search"); const existingPluginProvider = typeof existingProvider === "string" && existingProvider.trim() ? existingProvider : undefined; @@ -908,7 +917,11 @@ export function buildSearchProviderPickerModel( sortedEntries[0]?.value ?? SEARCH_PROVIDER_SKIP_SENTINEL; - const installableEntries = resolveInstallableSearchProviderPlugins(providerEntries); + const installableEntries = resolveInstallableSearchProviderPlugins({ + config, + providerEntries, + workspaceDir, + }); const options: Array<{ value: SearchProviderPickerChoice; label: string; hint?: string }> = [ ...(unloadedExistingPluginProvider ? [ @@ -1228,6 +1241,7 @@ export async function promptSearchProviderFlow(params: { providerEntries, includeSkipOption: params.includeSkipOption, skipHint: params.skipHint, + workspaceDir: params.opts?.workspaceDir, }); const action = await promptProviderManagementIntent({ prompter: params.prompter, diff --git a/src/commands/search-provider-plugin-catalog.ts b/src/commands/search-provider-plugin-catalog.ts deleted file mode 100644 index c505435b89f..00000000000 --- a/src/commands/search-provider-plugin-catalog.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { tavilySearchInstallCatalogEntry } from "../../extensions/tavily-search/install-catalog.js"; -import type { InstallablePluginCatalogEntry } from "./onboarding/plugin-install.js"; - -export type InstallableSearchProviderPluginCatalogEntry = InstallablePluginCatalogEntry & { - providerId: string; - description: string; -}; - -export const SEARCH_PROVIDER_PLUGIN_INSTALL_CATALOG: readonly InstallableSearchProviderPluginCatalogEntry[] = - [tavilySearchInstallCatalogEntry]; diff --git a/src/config/config.web-search-provider.test.ts b/src/config/config.web-search-provider.test.ts index ac69df775c8..f4f72b0dd34 100644 --- a/src/config/config.web-search-provider.test.ts +++ b/src/config/config.web-search-provider.test.ts @@ -1,4 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createBundledBraveSearchProvider } from "../../extensions/search-brave/src/provider.js"; +import { createBundledGeminiSearchProvider } from "../../extensions/search-gemini/src/provider.js"; +import { createBundledGrokSearchProvider } from "../../extensions/search-grok/src/provider.js"; +import { createBundledKimiSearchProvider } from "../../extensions/search-kimi/src/provider.js"; +import { createBundledPerplexitySearchProvider } from "../../extensions/search-perplexity/src/provider.js"; import { validateConfigObject, validateConfigObjectWithPlugins } from "./config.js"; import { buildWebSearchProviderConfig } from "./test-helpers.js"; @@ -20,6 +25,26 @@ vi.mock("@mariozechner/pi-ai/oauth", () => ({ const { __testing } = await import("../agents/tools/web-search.js"); const { resolveSearchProvider } = __testing; +const bundledSearchProviders = [ + { pluginId: "search-brave", provider: createBundledBraveSearchProvider() }, + { pluginId: "search-gemini", provider: createBundledGeminiSearchProvider() }, + { pluginId: "search-grok", provider: createBundledGrokSearchProvider() }, + { pluginId: "search-kimi", provider: createBundledKimiSearchProvider() }, + { pluginId: "search-perplexity", provider: createBundledPerplexitySearchProvider() }, +]; + +function resolveSearchProviderId(search: Record) { + return resolveSearchProvider({ + config: { + tools: { + web: { + search, + }, + }, + }, + }).id; +} + describe("web search provider config", () => { beforeEach(() => { loadOpenClawPlugins.mockReset(); @@ -175,6 +200,8 @@ describe("web search provider auto-detection", () => { const savedEnv = { ...process.env }; beforeEach(() => { + loadOpenClawPlugins.mockReset(); + loadOpenClawPlugins.mockReturnValue({ searchProviders: bundledSearchProviders }); delete process.env.BRAVE_API_KEY; delete process.env.GEMINI_API_KEY; delete process.env.KIMI_API_KEY; @@ -192,47 +219,47 @@ describe("web search provider auto-detection", () => { }); it("falls back to brave when no keys available", () => { - expect(resolveSearchProvider({})).toBe("brave"); + expect(resolveSearchProviderId({})).toBe("brave"); }); it("auto-detects brave when only BRAVE_API_KEY is set", () => { process.env.BRAVE_API_KEY = "test-brave-key"; // pragma: allowlist secret - expect(resolveSearchProvider({})).toBe("brave"); + expect(resolveSearchProviderId({})).toBe("brave"); }); it("auto-detects gemini when only GEMINI_API_KEY is set", () => { process.env.GEMINI_API_KEY = "test-gemini-key"; // pragma: allowlist secret - expect(resolveSearchProvider({})).toBe("gemini"); + expect(resolveSearchProviderId({})).toBe("gemini"); }); it("auto-detects kimi when only KIMI_API_KEY is set", () => { process.env.KIMI_API_KEY = "test-kimi-key"; // pragma: allowlist secret - expect(resolveSearchProvider({})).toBe("kimi"); + expect(resolveSearchProviderId({})).toBe("kimi"); }); it("auto-detects perplexity when only PERPLEXITY_API_KEY is set", () => { process.env.PERPLEXITY_API_KEY = "test-perplexity-key"; // pragma: allowlist secret - expect(resolveSearchProvider({})).toBe("perplexity"); + expect(resolveSearchProviderId({})).toBe("perplexity"); }); it("auto-detects perplexity when only OPENROUTER_API_KEY is set", () => { process.env.OPENROUTER_API_KEY = "sk-or-v1-test"; // pragma: allowlist secret - expect(resolveSearchProvider({})).toBe("perplexity"); + expect(resolveSearchProviderId({})).toBe("perplexity"); }); it("auto-detects grok when only XAI_API_KEY is set", () => { process.env.XAI_API_KEY = "test-xai-key"; // pragma: allowlist secret - expect(resolveSearchProvider({})).toBe("grok"); + expect(resolveSearchProviderId({})).toBe("grok"); }); it("auto-detects kimi when only KIMI_API_KEY is set", () => { process.env.KIMI_API_KEY = "test-kimi-key"; // pragma: allowlist secret - expect(resolveSearchProvider({})).toBe("kimi"); + expect(resolveSearchProviderId({})).toBe("kimi"); }); it("auto-detects kimi when only MOONSHOT_API_KEY is set", () => { process.env.MOONSHOT_API_KEY = "test-moonshot-key"; // pragma: allowlist secret - expect(resolveSearchProvider({})).toBe("kimi"); + expect(resolveSearchProviderId({})).toBe("kimi"); }); it("follows alphabetical order — brave wins when multiple keys available", () => { @@ -240,29 +267,25 @@ describe("web search provider auto-detection", () => { process.env.GEMINI_API_KEY = "test-gemini-key"; // pragma: allowlist secret process.env.PERPLEXITY_API_KEY = "test-perplexity-key"; // pragma: allowlist secret process.env.XAI_API_KEY = "test-xai-key"; // pragma: allowlist secret - expect(resolveSearchProvider({})).toBe("brave"); + expect(resolveSearchProviderId({})).toBe("brave"); }); it("gemini wins over grok, kimi, and perplexity when brave unavailable", () => { process.env.GEMINI_API_KEY = "test-gemini-key"; // pragma: allowlist secret process.env.PERPLEXITY_API_KEY = "test-perplexity-key"; // pragma: allowlist secret process.env.XAI_API_KEY = "test-xai-key"; // pragma: allowlist secret - expect(resolveSearchProvider({})).toBe("gemini"); + expect(resolveSearchProviderId({})).toBe("gemini"); }); it("grok wins over kimi and perplexity when brave and gemini unavailable", () => { process.env.XAI_API_KEY = "test-xai-key"; // pragma: allowlist secret process.env.KIMI_API_KEY = "test-kimi-key"; // pragma: allowlist secret process.env.PERPLEXITY_API_KEY = "test-perplexity-key"; // pragma: allowlist secret - expect(resolveSearchProvider({})).toBe("grok"); + expect(resolveSearchProviderId({})).toBe("grok"); }); it("explicit provider always wins regardless of keys", () => { process.env.BRAVE_API_KEY = "test-brave-key"; // pragma: allowlist secret - expect( - resolveSearchProvider({ provider: "gemini" } as unknown as Parameters< - typeof resolveSearchProvider - >[0]), - ).toBe("gemini"); + expect(resolveSearchProviderId({ provider: "gemini" })).toBe("gemini"); }); }); diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index 71df649817d..c5f8e630922 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -1,4 +1,3 @@ -import type { BuiltinWebSearchProviderId } from "../agents/tools/web-search-provider-catalog.js"; import type { ChatType } from "../channels/chat-type.js"; import type { SafeBinProfileFixture } from "../infra/exec-safe-bin-policy.js"; import type { AgentElevatedAllowFromConfig, SessionSendPolicyAction } from "./types.base.js"; @@ -458,8 +457,8 @@ export type ToolsConfig = { search?: { /** Enable web search tool (default: true when API key is present). */ enabled?: boolean; - /** Search provider. Built-ins include "brave", "gemini", "grok", "kimi", and "perplexity"; plugins use their registered id. */ - provider?: BuiltinWebSearchProviderId | (string & {}); + /** Search provider id registered through the plugin system. */ + provider?: 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 b034869966e..7b9774dd1ac 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -645,6 +645,7 @@ function validateConfigObjectWithPluginsBase( origin: record.origin, config: normalizedPlugins, rootConfig: config, + defaultEnabledWhenBundled: record.defaultEnabledWhenBundled, }); let enabled = enableState.enabled; let reason = enableState.reason; diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 06a538b0d47..1ed89f0d540 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -1,6 +1,5 @@ import { z } from "zod"; import { getBlockedNetworkModeReason } from "../agents/sandbox/network-mode.js"; -import { BUILTIN_WEB_SEARCH_PROVIDER_IDS } from "../agents/tools/web-search-provider-catalog.js"; import { parseDurationMs } from "../cli/parse-duration.js"; import { AgentModelSchema } from "./zod-schema.agent-model.js"; import { @@ -264,10 +263,8 @@ export const ToolsWebSearchSchema = z .object({ enabled: z.boolean().optional(), provider: z - .union([ - z.enum(BUILTIN_WEB_SEARCH_PROVIDER_IDS), - z.string().regex(/^[a-z][a-z0-9_-]*$/, "custom provider id"), - ]) + .string() + .regex(/^[a-z][a-z0-9_-]*$/, "provider id") .optional(), apiKey: SecretInputSchema.optional().register(sensitive), maxResults: z.number().int().positive().optional(), diff --git a/src/plugin-sdk/web-search.ts b/src/plugin-sdk/web-search.ts index a55dd592181..ca8f7c4fd7c 100644 --- a/src/plugin-sdk/web-search.ts +++ b/src/plugin-sdk/web-search.ts @@ -10,6 +10,7 @@ export type { SearchProviderRequest, SearchProviderPlugin, SearchProviderRuntimeMetadataResolver, + SearchProviderSetupMetadata, SearchProviderSuccessResult, } from "../plugins/types.js"; export { @@ -21,24 +22,23 @@ export { export { withTrustedWebToolsEndpoint } from "../agents/tools/web-guarded-fetch.js"; export { resolveCitationRedirectUrl } from "../agents/tools/web-search-citation-redirect.js"; -export type SearchProviderLegacyUiMetadata = { - label: string; - hint: string; - envKeys: readonly string[]; - placeholder: string; - signupUrl: string; - apiKeyConfigPath: string; - readApiKeyValue?: (search: Record | undefined) => unknown; - writeApiKeyValue?: (search: Record, value: unknown) => void; -}; - -export type SearchProviderSetupUiMetadata = { +export type SearchProviderSetupParams = { label: string; hint: string; envKeys: readonly string[]; placeholder: string; signupUrl: string; apiKeyConfigPath: string; + install?: SearchProviderSetupMetadata["install"]; + autodetectPriority?: SearchProviderSetupMetadata["autodetectPriority"]; + requestSchema?: SearchProviderSetupMetadata["requestSchema"]; + resolveRequestSchema?: SearchProviderSetupMetadata["resolveRequestSchema"]; + resolveRuntimeMetadata?: (params: { + search: Record | undefined; + keyValue?: string; + keySource: "config" | "secretRef" | "env" | "missing"; + fallbackEnvVar?: string; + }) => Record; readApiKeyValue?: (search: Record | undefined) => unknown; writeApiKeyValue?: (search: Record, value: unknown) => void; }; @@ -51,15 +51,6 @@ export type SearchProviderFilterSupport = { domainFilter?: boolean; }; -export type SearchProviderLegacyUiMetadataParams = Omit< - SearchProviderLegacyUiMetadata, - "readApiKeyValue" | "writeApiKeyValue" -> & { - provider: string; -}; - -export type SearchProviderSetupUiMetadataParams = SearchProviderLegacyUiMetadataParams; - const WEB_SEARCH_DOCS_URL = "https://docs.openclaw.ai/tools/web"; export function resolveSearchConfig(search?: Record): T { @@ -80,27 +71,34 @@ export function resolveSearchProviderSectionConfig( return scoped as T; } -export function createLegacySearchProviderMetadata( - params: SearchProviderLegacyUiMetadataParams, -): SearchProviderLegacyUiMetadata { - return { - label: params.label, - hint: params.hint, - envKeys: params.envKeys, - placeholder: params.placeholder, - signupUrl: params.signupUrl, - apiKeyConfigPath: params.apiKeyConfigPath, - resolveRuntimeMetadata: params.resolveRuntimeMetadata, - readApiKeyValue: (search) => readSearchProviderApiKeyValue(search, params.provider), - writeApiKeyValue: (search, value) => - writeSearchProviderApiKeyValue({ search, provider: params.provider, value }), - }; -} - export function createSearchProviderSetupMetadata( - params: SearchProviderSetupUiMetadataParams, -): SearchProviderSetupUiMetadata { - return createLegacySearchProviderMetadata(params); + params: SearchProviderSetupParams & { provider: string }, +): SearchProviderSetupMetadata { + return { + hint: params.hint, + credentials: { + label: params.label, + hint: params.hint, + envKeys: params.envKeys, + placeholder: params.placeholder, + signupUrl: params.signupUrl, + apiKeyConfigPath: params.apiKeyConfigPath, + resolveRuntimeMetadata: params.resolveRuntimeMetadata, + readApiKeyValue: + params.readApiKeyValue ?? + ((search) => readSearchProviderApiKeyValue(search, params.provider)), + writeApiKeyValue: + params.writeApiKeyValue ?? + ((search, value) => + writeSearchProviderApiKeyValue({ search, provider: params.provider, value })), + }, + ...(params.install ? { install: params.install } : {}), + ...(params.autodetectPriority !== undefined + ? { autodetectPriority: params.autodetectPriority } + : {}), + ...(params.requestSchema ? { requestSchema: params.requestSchema } : {}), + ...(params.resolveRequestSchema ? { resolveRequestSchema: params.resolveRequestSchema } : {}), + }; } export function createSearchProviderErrorResult( @@ -130,31 +128,31 @@ export function rejectUnsupportedSearchFilters(params: { if (params.request.country && params.support.country !== true) { return createSearchProviderErrorResult( "unsupported_country", - `country filtering is not supported by the ${provider} provider. Only Brave and Perplexity support country filtering.`, + `country filtering is not supported by the ${provider} provider.`, ); } if (params.request.language && params.support.language !== true) { return createSearchProviderErrorResult( "unsupported_language", - `language filtering is not supported by the ${provider} provider. Only Brave and Perplexity support language filtering.`, + `language filtering is not supported by the ${provider} provider.`, ); } if (params.request.freshness && params.support.freshness !== true) { return createSearchProviderErrorResult( "unsupported_freshness", - `freshness filtering is not supported by the ${provider} provider. Only Brave and Perplexity support freshness.`, + `freshness filtering is not supported by the ${provider} provider.`, ); } if ((params.request.dateAfter || params.request.dateBefore) && params.support.date !== true) { return createSearchProviderErrorResult( "unsupported_date_filter", - `date_after/date_before filtering is not supported by the ${provider} provider. Only Brave and Perplexity support date filtering.`, + `date_after/date_before filtering is not supported by the ${provider} provider.`, ); } if (params.request.domainFilter?.length && params.support.domainFilter !== true) { return createSearchProviderErrorResult( "unsupported_domain_filter", - `domain_filter is not supported by the ${provider} provider. Only Perplexity supports domain filtering.`, + `domain_filter is not supported by the ${provider} provider.`, ); } return undefined; @@ -238,9 +236,6 @@ function getScopedSearchConfig( search: Record, provider: string, ): Record | undefined { - if (provider === "brave") { - return search; - } const scoped = search[provider]; return typeof scoped === "object" && scoped !== null && !Array.isArray(scoped) ? (scoped as Record) @@ -262,10 +257,6 @@ export function writeSearchProviderApiKeyValue(params: { provider: string; value: unknown; }): void { - if (params.provider === "brave") { - params.search.apiKey = params.value; - return; - } const current = getScopedSearchConfig(params.search, params.provider); if (current) { current.apiKey = params.value; diff --git a/src/plugins/config-state.test.ts b/src/plugins/config-state.test.ts index 67cd4bf12d8..cecb82d94ef 100644 --- a/src/plugins/config-state.test.ts +++ b/src/plugins/config-state.test.ts @@ -115,7 +115,7 @@ describe("resolveEffectiveEnableState", () => { expect(state).toEqual({ enabled: false, reason: "disabled in config" }); }); - it("enables bundled search provider plugins by default", () => { + it("enables plugins marked defaultEnabledWhenBundled by default", () => { const normalized = normalizePluginsConfig({ enabled: true, }); @@ -124,11 +124,12 @@ describe("resolveEffectiveEnableState", () => { origin: "bundled", config: normalized, rootConfig: {}, + defaultEnabledWhenBundled: true, }); expect(state).toEqual({ enabled: true }); }); - it("enables other migrated bundled search provider plugins by default", () => { + it("applies defaultEnabledWhenBundled consistently across plugin ids", () => { const normalized = normalizePluginsConfig({ enabled: true, }); @@ -137,6 +138,7 @@ describe("resolveEffectiveEnableState", () => { origin: "bundled", config: normalized, rootConfig: {}, + defaultEnabledWhenBundled: true, }); expect(state).toEqual({ enabled: true }); }); diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index f39ef0581cd..98d98e5e6c9 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -1,4 +1,3 @@ -import { MIGRATED_BUNDLED_WEB_SEARCH_PLUGIN_IDS } from "../agents/tools/web-search-provider-catalog.js"; import { normalizeChatChannelId } from "../channels/registry.js"; import type { OpenClawConfig } from "../config/config.js"; import type { PluginRecord } from "./registry.js"; @@ -29,7 +28,6 @@ export const BUNDLED_ENABLED_BY_DEFAULT = new Set([ "ollama", "phone-control", "sglang", - ...MIGRATED_BUNDLED_WEB_SEARCH_PLUGIN_IDS, "talk-voice", "vllm", ]); @@ -195,6 +193,7 @@ export function resolveEnableState( id: string, origin: PluginRecord["origin"], config: NormalizedPluginsConfig, + defaultEnabledWhenBundled = false, ): { enabled: boolean; reason?: string } { if (!config.enabled) { return { enabled: false, reason: "plugins disabled" }; @@ -219,7 +218,7 @@ export function resolveEnableState( if (entry?.enabled === true) { return { enabled: true }; } - if (origin === "bundled" && BUNDLED_ENABLED_BY_DEFAULT.has(id)) { + if (origin === "bundled" && (BUNDLED_ENABLED_BY_DEFAULT.has(id) || defaultEnabledWhenBundled)) { return { enabled: true }; } if (origin === "bundled") { @@ -252,8 +251,14 @@ export function resolveEffectiveEnableState(params: { origin: PluginRecord["origin"]; config: NormalizedPluginsConfig; rootConfig?: OpenClawConfig; + defaultEnabledWhenBundled?: boolean; }): { enabled: boolean; reason?: string } { - const base = resolveEnableState(params.id, params.origin, params.config); + const base = resolveEnableState( + params.id, + params.origin, + params.config, + params.defaultEnabledWhenBundled, + ); if ( !base.enabled && base.reason === "bundled (disabled by default)" && diff --git a/src/plugins/hooks.ts b/src/plugins/hooks.ts index 37dd41c555e..be74686a1fe 100644 --- a/src/plugins/hooks.ts +++ b/src/plugins/hooks.ts @@ -8,8 +8,6 @@ import { concatOptionalTextSegments } from "../shared/text/join-segments.js"; import type { PluginRegistry } from "./registry.js"; import type { - PluginHookAfterSearchProviderActivateEvent, - PluginHookAfterSearchProviderConfigureEvent, PluginHookAfterProviderActivateEvent, PluginHookAfterProviderConfigureEvent, PluginHookAfterCompactionEvent, @@ -18,8 +16,6 @@ import type { PluginHookAgentEndEvent, PluginHookBeforeAgentStartEvent, PluginHookBeforeAgentStartResult, - PluginHookBeforeSearchProviderConfigureEvent, - PluginHookBeforeSearchProviderConfigureResult, PluginHookBeforeProviderConfigureEvent, PluginHookBeforeProviderConfigureResult, PluginHookBeforeModelResolveEvent, @@ -66,8 +62,6 @@ export type { PluginHookAgentContext, PluginHookBeforeAgentStartEvent, PluginHookBeforeAgentStartResult, - PluginHookBeforeSearchProviderConfigureEvent, - PluginHookBeforeSearchProviderConfigureResult, PluginHookBeforeProviderConfigureEvent, PluginHookBeforeProviderConfigureResult, PluginHookBeforeModelResolveEvent, @@ -108,8 +102,6 @@ export type { PluginHookGatewayContext, PluginHookGatewayStartEvent, PluginHookGatewayStopEvent, - PluginHookAfterSearchProviderConfigureEvent, - PluginHookAfterSearchProviderActivateEvent, PluginHookAfterProviderConfigureEvent, PluginHookAfterProviderActivateEvent, }; @@ -138,87 +130,6 @@ function getHooksForName( .toSorted((a, b) => (b.priority ?? 0) - (a.priority ?? 0)); } -function getHooksForNameWithoutPluginIds(params: { - registry: PluginRegistry; - hookName: K; - excludedPluginIds: ReadonlySet; -}): PluginHookRegistration[] { - return getHooksForName(params.registry, params.hookName).filter( - (hook) => !params.excludedPluginIds.has(hook.pluginId), - ); -} - -type SearchBeforeProviderAliasDescriptor = { - genericHookName: "before_provider_configure"; - legacyHookName: "before_search_provider_configure"; - toLegacyEvent: ( - event: PluginHookBeforeProviderConfigureEvent, - ) => PluginHookBeforeSearchProviderConfigureEvent; -}; - -type SearchAfterProviderConfigureAliasDescriptor = { - genericHookName: "after_provider_configure"; - legacyHookName: "after_search_provider_configure"; - toLegacyEvent: ( - event: PluginHookAfterProviderConfigureEvent, - ) => PluginHookAfterSearchProviderConfigureEvent; -}; - -type SearchAfterProviderActivateAliasDescriptor = { - genericHookName: "after_provider_activate"; - legacyHookName: "after_search_provider_activate"; - toLegacyEvent: ( - event: PluginHookAfterProviderActivateEvent, - ) => PluginHookAfterSearchProviderActivateEvent; -}; - -const SEARCH_PROVIDER_ALIAS_HOOKS = { - beforeConfigure: { - genericHookName: "before_provider_configure", - legacyHookName: "before_search_provider_configure", - toLegacyEvent: ( - event: PluginHookBeforeProviderConfigureEvent, - ): PluginHookBeforeSearchProviderConfigureEvent => ({ - providerId: event.providerId, - providerLabel: event.providerLabel, - providerSource: event.providerSource, - pluginId: event.pluginId, - intent: event.intent, - activeProviderId: event.activeProviderId, - configured: event.configured, - }), - } satisfies SearchBeforeProviderAliasDescriptor, - afterConfigure: { - genericHookName: "after_provider_configure", - legacyHookName: "after_search_provider_configure", - toLegacyEvent: ( - event: PluginHookAfterProviderConfigureEvent, - ): PluginHookAfterSearchProviderConfigureEvent => ({ - providerId: event.providerId, - providerLabel: event.providerLabel, - providerSource: event.providerSource, - pluginId: event.pluginId, - intent: event.intent, - activeProviderId: event.activeProviderId, - configured: event.configured, - }), - } satisfies SearchAfterProviderConfigureAliasDescriptor, - afterActivate: { - genericHookName: "after_provider_activate", - legacyHookName: "after_search_provider_activate", - toLegacyEvent: ( - event: PluginHookAfterProviderActivateEvent, - ): PluginHookAfterSearchProviderActivateEvent => ({ - providerId: event.providerId, - providerLabel: event.providerLabel, - providerSource: event.providerSource, - pluginId: event.pluginId, - previousProviderId: event.previousProviderId, - intent: event.intent, - }), - } satisfies SearchAfterProviderActivateAliasDescriptor, -} as const; - /** * Create a hook runner for a specific registry. */ @@ -280,16 +191,6 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp return next; }; - const mergeBeforeSearchProviderConfigure = ( - acc: PluginHookBeforeSearchProviderConfigureResult | undefined, - next: PluginHookBeforeSearchProviderConfigureResult, - ): PluginHookBeforeSearchProviderConfigureResult => ({ - note: concatOptionalTextSegments({ - left: acc?.note, - right: next.note, - }), - }); - const mergeBeforeProviderConfigure = ( acc: PluginHookBeforeProviderConfigureResult | undefined, next: PluginHookBeforeProviderConfigureResult, @@ -401,21 +302,6 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp return result; } - function getSearchProviderLegacyHooks( - descriptor: - | SearchBeforeProviderAliasDescriptor - | SearchAfterProviderConfigureAliasDescriptor - | SearchAfterProviderActivateAliasDescriptor, - ) { - const genericHooks = getHooksForName(registry, descriptor.genericHookName); - const genericPluginIds = new Set(genericHooks.map((hook) => hook.pluginId)); - return getHooksForNameWithoutPluginIds({ - registry, - hookName: descriptor.legacyHookName, - excludedPluginIds: genericPluginIds, - }); - } - // ========================================================================= // Agent Hooks // ========================================================================= @@ -471,98 +357,30 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp ); } - async function runBeforeSearchProviderConfigure( - event: PluginHookBeforeSearchProviderConfigureEvent, - ctx: PluginHookSearchProviderContext, - ): Promise { - return runModifyingHook< - "before_search_provider_configure", - PluginHookBeforeSearchProviderConfigureResult - >("before_search_provider_configure", event, ctx, mergeBeforeSearchProviderConfigure); - } - async function runBeforeProviderConfigure( event: PluginHookBeforeProviderConfigureEvent, ctx: PluginHookSearchProviderContext, ): Promise { - const aliasDescriptor = SEARCH_PROVIDER_ALIAS_HOOKS.beforeConfigure; - const genericResult = await runModifyingHook< + return runModifyingHook<"before_provider_configure", PluginHookBeforeProviderConfigureResult>( "before_provider_configure", - PluginHookBeforeProviderConfigureResult - >("before_provider_configure", event, ctx, mergeBeforeProviderConfigure); - if (event.providerKind !== "search") { - return genericResult; - } - - const legacyHooks = getSearchProviderLegacyHooks(aliasDescriptor); - const searchResult = await runModifyingHookRegistrations< - "before_search_provider_configure", - PluginHookBeforeSearchProviderConfigureResult - >( - aliasDescriptor.legacyHookName, - legacyHooks as PluginHookRegistration<"before_search_provider_configure">[], - aliasDescriptor.toLegacyEvent(event), + event, ctx, - mergeBeforeSearchProviderConfigure, + mergeBeforeProviderConfigure, ); - if (!searchResult) { - return genericResult; - } - return mergeBeforeProviderConfigure(genericResult, { - note: searchResult.note, - }); } async function runAfterProviderConfigure( event: PluginHookAfterProviderConfigureEvent, ctx: PluginHookSearchProviderContext, ): Promise { - const aliasDescriptor = SEARCH_PROVIDER_ALIAS_HOOKS.afterConfigure; await runVoidHook("after_provider_configure", event, ctx); - if (event.providerKind !== "search") { - return; - } - - const legacyHooks = getSearchProviderLegacyHooks(aliasDescriptor); - await runVoidHookRegistrations( - aliasDescriptor.legacyHookName, - legacyHooks as PluginHookRegistration<"after_search_provider_configure">[], - aliasDescriptor.toLegacyEvent(event), - ctx, - ); } async function runAfterProviderActivate( event: PluginHookAfterProviderActivateEvent, ctx: PluginHookSearchProviderContext, ): Promise { - const aliasDescriptor = SEARCH_PROVIDER_ALIAS_HOOKS.afterActivate; await runVoidHook("after_provider_activate", event, ctx); - if (event.providerKind !== "search") { - return; - } - - const legacyHooks = getSearchProviderLegacyHooks(aliasDescriptor); - await runVoidHookRegistrations( - aliasDescriptor.legacyHookName, - legacyHooks as PluginHookRegistration<"after_search_provider_activate">[], - aliasDescriptor.toLegacyEvent(event), - ctx, - ); - } - - async function runAfterSearchProviderConfigure( - event: PluginHookAfterSearchProviderConfigureEvent, - ctx: PluginHookSearchProviderContext, - ): Promise { - return runVoidHook("after_search_provider_configure", event, ctx); - } - - async function runAfterSearchProviderActivate( - event: PluginHookAfterSearchProviderActivateEvent, - ctx: PluginHookSearchProviderContext, - ): Promise { - return runVoidHook("after_search_provider_activate", event, ctx); } /** @@ -970,26 +788,13 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp } function hasProviderConfigureHooks(providerKind?: string): boolean { - if (hasHooks("before_provider_configure") || hasHooks("after_provider_configure")) { - return true; - } - if (providerKind !== "search") { - return false; - } - return ( - hasHooks(SEARCH_PROVIDER_ALIAS_HOOKS.beforeConfigure.legacyHookName) || - hasHooks(SEARCH_PROVIDER_ALIAS_HOOKS.afterConfigure.legacyHookName) - ); + void providerKind; + return hasHooks("before_provider_configure") || hasHooks("after_provider_configure"); } function hasProviderActivationHooks(providerKind?: string): boolean { - if (hasHooks("after_provider_activate")) { - return true; - } - if (providerKind !== "search") { - return false; - } - return hasHooks(SEARCH_PROVIDER_ALIAS_HOOKS.afterActivate.legacyHookName); + void providerKind; + return hasHooks("after_provider_activate"); } return { @@ -998,11 +803,8 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp runBeforePromptBuild, runBeforeAgentStart, runBeforeProviderConfigure, - runBeforeSearchProviderConfigure, runAfterProviderConfigure, - runAfterSearchProviderConfigure, runAfterProviderActivate, - runAfterSearchProviderActivate, runLlmInput, runLlmOutput, runAgentEnd, diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index dacf8b1be71..a95d1b3f9b0 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -827,6 +827,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi origin: candidate.origin, config: normalized, rootConfig: cfg, + defaultEnabledWhenBundled: manifestRecord.defaultEnabledWhenBundled, }); const entry = normalized.entries[pluginId]; const record = createPluginRecord({ @@ -843,6 +844,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi record.kind = manifestRecord.kind; record.configUiHints = manifestRecord.configUiHints; record.configJsonSchema = manifestRecord.configSchema; + record.defaultEnabledWhenBundled = manifestRecord.defaultEnabledWhenBundled; record.declaredCapabilities = [...manifestRecord.provides]; record.requiredCapabilities = [...manifestRecord.requires]; record.conflictingCapabilities = [...manifestRecord.conflicts]; diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index a948344cba8..cb1e0fc8e23 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -6,6 +6,7 @@ import { clearPluginManifestRegistryCache, loadPluginManifestRegistry, } from "./manifest-registry.js"; +import type { OpenClawPackageManifest } from "./manifest.js"; import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js"; const tempDirs: string[] = []; @@ -36,12 +37,14 @@ function createPluginCandidate(params: { rootDir: string; sourceName?: string; origin: "bundled" | "global" | "workspace" | "config"; + packageManifest?: OpenClawPackageManifest; }): PluginCandidate { return { idHint: params.idHint, source: path.join(params.rootDir, params.sourceName ?? "index.ts"), rootDir: params.rootDir, origin: params.origin, + packageManifest: params.packageManifest, }; } @@ -215,6 +218,37 @@ describe("loadPluginManifestRegistry", () => { expect(countDuplicateWarnings(loadRegistry(candidates))).toBe(0); }); + it("surfaces package install metadata on manifest records", () => { + const dir = makeTempDir(); + writeManifest(dir, { + id: "tavily-search", + name: "Tavily Search", + provides: ["providers.search.tavily"], + configSchema: { type: "object" }, + }); + + const registry = loadRegistry([ + createPluginCandidate({ + idHint: "tavily-search", + rootDir: dir, + origin: "bundled", + packageManifest: { + install: { + npmSpec: "@openclaw/tavily-search", + localPath: "extensions/tavily-search", + defaultChoice: "local", + }, + }, + }), + ]); + + expect(registry.plugins[0]?.packageInstall).toEqual({ + npmSpec: "@openclaw/tavily-search", + localPath: "extensions/tavily-search", + defaultChoice: "local", + }); + }); + it("prefers higher-precedence origins for the same physical directory (config > workspace > global > bundled)", () => { const dir = makeTempDir(); mkdirSafe(path.join(dir, "sub")); diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index b522e5691d4..edcab414f5a 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import type { OpenClawConfig } from "../config/config.js"; import { normalizePluginsConfig, type NormalizedPluginsConfig } from "./config-state.js"; import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js"; -import { loadPluginManifest, type PluginManifest } from "./manifest.js"; +import { loadPluginManifest, type PluginManifest, type PluginPackageInstall } from "./manifest.js"; import { safeRealpathSync } from "./path-safety.js"; import { resolvePluginCacheInputs } from "./roots.js"; import type { PluginConfigUiHint, PluginDiagnostic, PluginKind, PluginOrigin } from "./types.js"; @@ -40,6 +40,8 @@ export type PluginManifestRecord = { schemaCacheKey?: string; configSchema?: Record; configUiHints?: Record; + defaultEnabledWhenBundled?: boolean; + packageInstall?: PluginPackageInstall; }; export type PluginManifestRegistry = { @@ -138,6 +140,8 @@ function buildRecord(params: { schemaCacheKey: params.schemaCacheKey, configSchema: params.configSchema, configUiHints: params.manifest.uiHints, + defaultEnabledWhenBundled: params.manifest.defaultEnabledWhenBundled, + packageInstall: params.candidate.packageManifest?.install, }; } diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index d935450dfcf..8ba4710ab0e 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -21,6 +21,7 @@ export type PluginManifest = { name?: string; description?: string; version?: string; + defaultEnabledWhenBundled?: boolean; uiHints?: Record; }; @@ -106,6 +107,9 @@ export function loadPluginManifest( uiHints = raw.uiHints as Record; } + const defaultEnabledWhenBundled = + typeof raw.defaultEnabledWhenBundled === "boolean" ? raw.defaultEnabledWhenBundled : undefined; + return { ok: true, manifest: { @@ -121,6 +125,7 @@ export function loadPluginManifest( name, description, version, + defaultEnabledWhenBundled, uiHints, }, manifestPath, diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 0d5aca723db..be9b2aa22f3 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -155,6 +155,7 @@ export type PluginRecord = { configSchema: boolean; configUiHints?: Record; configJsonSchema?: Record; + defaultEnabledWhenBundled?: boolean; }; export type PluginRegistry = { diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 0e953458d6d..b8f2770dadd 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -310,14 +310,20 @@ export type SearchProviderInstallMetadata = { defaultChoice?: "npm" | "local"; }; +export type SearchProviderRequestSchemaResolver = (params: { + config?: OpenClawConfig; + runtimeMetadata?: Record; +}) => Record; + export type SearchProviderSetupMetadata = { hint?: string; credentials?: SearchProviderCredentialMetadata; install?: SearchProviderInstallMetadata; + autodetectPriority?: number; + requestSchema?: Record; + resolveRequestSchema?: SearchProviderRequestSchemaResolver; }; -export type SearchProviderLegacyConfigMetadata = SearchProviderCredentialMetadata; - export type SearchProviderRuntimeMetadata = Record; export type SearchProviderRuntimeMetadataResolver = (params: { @@ -336,7 +342,6 @@ export type SearchProviderPlugin = { docsUrl?: string; configFieldOrder?: string[]; setup?: SearchProviderSetupMetadata; - legacyConfig?: SearchProviderLegacyConfigMetadata; resolveRuntimeMetadata?: SearchProviderRuntimeMetadataResolver; isAvailable?: (config?: OpenClawConfig) => boolean; search: ( @@ -544,11 +549,8 @@ export type PluginHookName = | "before_prompt_build" | "before_agent_start" | "before_provider_configure" - | "before_search_provider_configure" | "after_provider_configure" - | "after_search_provider_configure" | "after_provider_activate" - | "after_search_provider_activate" | "llm_input" | "llm_output" | "agent_end" @@ -576,11 +578,8 @@ export const PLUGIN_HOOK_NAMES = [ "before_prompt_build", "before_agent_start", "before_provider_configure", - "before_search_provider_configure", "after_provider_configure", - "after_search_provider_configure", "after_provider_activate", - "after_search_provider_activate", "llm_input", "llm_output", "agent_end", @@ -729,7 +728,7 @@ export type PluginHookProviderLifecycleContext = { workspaceDir?: string; }; -export type PluginHookSearchProviderSource = "builtin" | "plugin"; +export type PluginHookSearchProviderSource = "plugin"; export type PluginHookProviderKind = "search"; @@ -749,20 +748,6 @@ export type PluginHookBeforeProviderConfigureResult = { note?: string; }; -export type PluginHookBeforeSearchProviderConfigureEvent = { - providerId: string; - providerLabel: string; - providerSource: PluginHookSearchProviderSource; - pluginId?: string; - intent: "switch-active" | "configure-provider"; - activeProviderId?: string | null; - configured: boolean; -}; - -export type PluginHookBeforeSearchProviderConfigureResult = { - note?: string; -}; - export type PluginHookAfterProviderConfigureEvent = { providerKind: PluginHookProviderKind; slot: string; @@ -775,16 +760,6 @@ export type PluginHookAfterProviderConfigureEvent = { configured: boolean; }; -export type PluginHookAfterSearchProviderConfigureEvent = { - providerId: string; - providerLabel: string; - providerSource: PluginHookSearchProviderSource; - pluginId?: string; - intent: "switch-active" | "configure-provider"; - activeProviderId?: string | null; - configured: boolean; -}; - export type PluginHookAfterProviderActivateEvent = { providerKind: PluginHookProviderKind; slot: string; @@ -796,15 +771,6 @@ export type PluginHookAfterProviderActivateEvent = { intent: "switch-active" | "configure-provider"; }; -export type PluginHookAfterSearchProviderActivateEvent = { - providerId: string; - providerLabel: string; - providerSource: PluginHookSearchProviderSource; - pluginId?: string; - previousProviderId?: string | null; - intent: "switch-active" | "configure-provider"; -}; - // llm_input hook export type PluginHookLlmInputEvent = { runId: string; @@ -1125,29 +1091,14 @@ export type PluginHookHandlerMap = { | Promise | PluginHookBeforeProviderConfigureResult | void; - before_search_provider_configure: ( - event: PluginHookBeforeSearchProviderConfigureEvent, - ctx: PluginHookSearchProviderContext, - ) => - | Promise - | PluginHookBeforeSearchProviderConfigureResult - | void; after_provider_configure: ( event: PluginHookAfterProviderConfigureEvent, ctx: PluginHookProviderLifecycleContext, ) => Promise | void; - after_search_provider_configure: ( - event: PluginHookAfterSearchProviderConfigureEvent, - ctx: PluginHookSearchProviderContext, - ) => Promise | void; after_provider_activate: ( event: PluginHookAfterProviderActivateEvent, ctx: PluginHookProviderLifecycleContext, ) => Promise | void; - after_search_provider_activate: ( - event: PluginHookAfterSearchProviderActivateEvent, - ctx: PluginHookSearchProviderContext, - ) => Promise | void; llm_input: (event: PluginHookLlmInputEvent, ctx: PluginHookAgentContext) => Promise | void; llm_output: ( event: PluginHookLlmOutputEvent, diff --git a/src/secrets/runtime-web-tools.ts b/src/secrets/runtime-web-tools.ts index 8cbe4368f9f..361ea255193 100644 --- a/src/secrets/runtime-web-tools.ts +++ b/src/secrets/runtime-web-tools.ts @@ -3,7 +3,6 @@ import { resolveSecretInputRef } from "../config/types.secrets.js"; import { loadOpenClawPlugins } from "../plugins/loader.js"; import type { SearchProviderCredentialMetadata, - SearchProviderLegacyConfigMetadata, SearchProviderPlugin, SearchProviderSetupMetadata, } from "../plugins/types.js"; @@ -93,18 +92,8 @@ type RegisteredSearchProviderRuntimeSupport = { function resolveProviderSetupMetadata( setup?: SearchProviderSetupMetadata, - legacyConfig?: SearchProviderLegacyConfigMetadata, ): SearchProviderSetupMetadata | undefined { - if (setup) { - return setup; - } - if (!legacyConfig) { - return undefined; - } - return { - hint: legacyConfig.hint, - credentials: legacyConfig, - }; + return setup; } function resolveProviderCredentialMetadata( @@ -128,7 +117,7 @@ function resolveRegisteredSearchProviderMetadata( .map((entry) => [ entry.provider.id, { - setup: resolveProviderSetupMetadata(entry.provider.setup, entry.provider.legacyConfig), + setup: resolveProviderSetupMetadata(entry.provider.setup), resolveRuntimeMetadata: entry.provider.resolveRuntimeMetadata, }, ]),