diff --git a/extensions/tavily-search/index.test.ts b/extensions/tavily-search/index.test.ts new file mode 100644 index 00000000000..9c7aead2f73 --- /dev/null +++ b/extensions/tavily-search/index.test.ts @@ -0,0 +1,138 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import plugin from "./index.js"; + +function createApi(params?: { + config?: OpenClawPluginApi["config"]; + pluginConfig?: Record; +}) { + let registeredProvider: Parameters[0] | undefined; + const api = { + id: "tavily-search", + name: "Tavily Search", + source: "/tmp/tavily-search/index.ts", + config: params?.config ?? {}, + pluginConfig: params?.pluginConfig, + runtime: {} as never, + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + registerSearchProvider: vi.fn((provider) => { + registeredProvider = provider; + }), + } as unknown as OpenClawPluginApi; + + plugin.register?.(api); + if (!registeredProvider) { + throw new Error("search provider was not registered"); + } + return registeredProvider; +} + +afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +describe("tavily-search plugin", () => { + it("registers a tavily search provider and detects availability from plugin config", () => { + const provider = createApi({ + config: { + plugins: { + entries: { + "tavily-search": { + config: { + apiKey: "tvly-test-key", + }, + }, + }, + }, + }, + }); + + expect(provider.id).toBe("tavily"); + expect(provider.isAvailable?.({})).toBe(false); + expect( + provider.isAvailable?.({ + plugins: { + entries: { + "tavily-search": { + config: { + apiKey: "tvly-test-key", + }, + }, + }, + }, + }), + ).toBe(true); + }); + + it("maps Tavily responses into plugin search results", async () => { + const provider = createApi({ + pluginConfig: { + apiKey: "tvly-test-key", + searchDepth: "advanced", + }, + }); + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => ({ + answer: "Tavily says hello", + results: [ + { + title: "Example", + url: "https://example.com/article", + content: "Snippet", + published_date: "2026-03-10", + }, + ], + }), + })); + vi.stubGlobal("fetch", fetchMock); + + const result = await provider.search( + { + query: "hello", + count: 3, + country: "US", + freshness: "week", + }, + { + config: {}, + timeoutSeconds: 5, + cacheTtlMs: 1000, + pluginConfig: { + apiKey: "tvly-test-key", + searchDepth: "advanced", + }, + }, + ); + + expect(fetchMock).toHaveBeenCalled(); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(String(init.body))).toMatchObject({ + api_key: "tvly-test-key", + query: "hello", + max_results: 3, + search_depth: "advanced", + topic: "news", + days: 7, + country: "US", + }); + expect(result).toEqual({ + content: "Tavily says hello", + citations: ["https://example.com/article"], + results: [ + { + title: "Example", + url: "https://example.com/article", + description: "Snippet", + published: "2026-03-10", + }, + ], + }); + }); +}); diff --git a/extensions/tavily-search/index.ts b/extensions/tavily-search/index.ts new file mode 100644 index 00000000000..d0996d602f2 --- /dev/null +++ b/extensions/tavily-search/index.ts @@ -0,0 +1,155 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; + +const TAVILY_SEARCH_ENDPOINT = "https://api.tavily.com/search"; + +type TavilyPluginConfig = { + apiKey?: string; + searchDepth?: "basic" | "advanced"; +}; + +type TavilySearchResult = { + title?: string; + url?: string; + content?: string; + published_date?: string; +}; + +type TavilySearchResponse = { + answer?: string; + results?: TavilySearchResult[]; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function resolvePluginConfig(value: unknown): TavilyPluginConfig { + if (!isRecord(value)) { + return {}; + } + return value as TavilyPluginConfig; +} + +function resolveRootPluginConfig( + config: OpenClawPluginApi["config"], + pluginId: string, +): TavilyPluginConfig { + return resolvePluginConfig(config?.plugins?.entries?.[pluginId]?.config); +} + +function resolveApiKey(config: TavilyPluginConfig): string | undefined { + return normalizeString(config.apiKey) ?? normalizeString(process.env.TAVILY_API_KEY); +} + +function resolveSearchDepth(config: TavilyPluginConfig): "basic" | "advanced" { + return config.searchDepth === "advanced" ? "advanced" : "basic"; +} + +function resolveFreshnessDays(freshness?: string): number | undefined { + const normalized = normalizeString(freshness)?.toLowerCase(); + if (normalized === "day") { + return 1; + } + if (normalized === "week") { + return 7; + } + if (normalized === "month") { + return 30; + } + if (normalized === "year") { + return 365; + } + return undefined; +} + +const plugin = { + id: "tavily-search", + name: "Tavily Search", + description: "External Tavily web_search provider 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.", + isAvailable: (config) => + Boolean(resolveApiKey(resolveRootPluginConfig(config ?? {}, api.id))), + search: async (params, ctx) => { + const pluginConfig = resolvePluginConfig(ctx.pluginConfig); + const apiKey = resolveApiKey(pluginConfig); + if (!apiKey) { + return { + error: "missing_tavily_api_key", + message: + "Tavily search provider needs an API key. Set plugins.entries.tavily-search.config.apiKey or TAVILY_API_KEY in the Gateway environment.", + }; + } + + const freshnessDays = resolveFreshnessDays(params.freshness); + const body: Record = { + api_key: apiKey, + query: params.query, + max_results: params.count, + search_depth: resolveSearchDepth(pluginConfig), + include_answer: true, + include_raw_content: false, + topic: freshnessDays ? "news" : "general", + }; + if (freshnessDays !== undefined) { + body.days = freshnessDays; + } + if (params.country) { + body.country = params.country; + } + + const response = await fetch(TAVILY_SEARCH_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(Math.max(ctx.timeoutSeconds, 1) * 1000), + }); + + if (!response.ok) { + const detail = await response.text(); + return { + error: "search_failed", + message: `Tavily search failed (${response.status}): ${detail || response.statusText}`, + }; + } + + const data = (await response.json()) as TavilySearchResponse; + const results = Array.isArray(data.results) ? data.results : []; + + return { + content: normalizeString(data.answer), + citations: results + .map((entry) => normalizeString(entry.url)) + .filter((entry): entry is string => Boolean(entry)), + results: results + .map((entry) => { + const url = normalizeString(entry.url); + if (!url) { + return undefined; + } + return { + url, + title: normalizeString(entry.title), + description: normalizeString(entry.content), + published: normalizeString(entry.published_date), + }; + }) + .filter((entry): entry is NonNullable => Boolean(entry)), + }; + }, + }); + }, +}; + +export default plugin; diff --git a/extensions/tavily-search/openclaw.plugin.json b/extensions/tavily-search/openclaw.plugin.json new file mode 100644 index 00000000000..fd758031ea9 --- /dev/null +++ b/extensions/tavily-search/openclaw.plugin.json @@ -0,0 +1,16 @@ +{ + "id": "tavily-search", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + }, + "searchDepth": { + "type": "string", + "enum": ["basic", "advanced"] + } + } + } +} diff --git a/extensions/tavily-search/package.json b/extensions/tavily-search/package.json new file mode 100644 index 00000000000..c54793ce678 --- /dev/null +++ b/extensions/tavily-search/package.json @@ -0,0 +1,11 @@ +{ + "name": "@openclaw/tavily-search", + "version": "2026.3.9", + "description": "OpenClaw Tavily external search provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 91bcd9d99c4..0a948a97cd4 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -2632,6 +2632,13 @@ function resolveSearchProviderPluginConfig( return pluginConfig && typeof pluginConfig === "object" ? pluginConfig : undefined; } +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}"`; +} + export function createWebSearchTool(options?: { config?: OpenClawConfig; sandboxed?: boolean; @@ -2699,6 +2706,7 @@ export function createWebSearchTool(options?: { } const providerId = normalizeSearchProviderId(provider.id); + logVerbose(formatWebSearchExecutionLog(provider)); const result = !provider.pluginId && isBuiltinSearchProviderId(providerId) ? await executeBuiltinSearchProvider({