From b36e456b09160b98779826302961c98b0ff09d9d Mon Sep 17 00:00:00 2001 From: Lakshya Agarwal Date: Fri, 20 Mar 2026 01:06:26 -0400 Subject: [PATCH] feat: add Tavily as a bundled web search plugin with search and extract tools (#49200) Merged via squash. Prepared head SHA: ece9226e886004f1e0536dd5de3ddc2946fc118c Co-authored-by: lakshyaag-tavily <266572148+lakshyaag-tavily@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- .github/labeler.yml | 4 + CHANGELOG.md | 1 + docs/docs.json | 1 + .../reference/secretref-credential-surface.md | 1 + ...tref-user-supplied-credentials-matrix.json | 7 + docs/tools/index.md | 2 +- docs/tools/tavily.md | 125 ++++++++ docs/tools/web.md | 44 ++- extensions/brave/openclaw.plugin.json | 3 + extensions/firecrawl/openclaw.plugin.json | 3 + extensions/perplexity/openclaw.plugin.json | 3 + extensions/tavily/index.test.ts | 41 +++ extensions/tavily/index.ts | 15 + extensions/tavily/openclaw.plugin.json | 37 +++ extensions/tavily/package.json | 12 + extensions/tavily/skills/tavily/SKILL.md | 94 ++++++ extensions/tavily/src/config.ts | 71 +++++ extensions/tavily/src/tavily-client.ts | 286 ++++++++++++++++++ .../tavily/src/tavily-extract-tool.test.ts | 53 ++++ extensions/tavily/src/tavily-extract-tool.ts | 74 +++++ .../tavily/src/tavily-search-provider.ts | 76 +++++ extensions/tavily/src/tavily-search-tool.ts | 81 +++++ pnpm-lock.yaml | 2 + src/agents/tools/web-search.ts | 112 +------ src/commands/onboard-search.test.ts | 121 +++++++- src/commands/onboard-search.ts | 11 +- src/config/config.web-search-provider.test.ts | 68 +++++ ...undled-provider-auth-env-vars.generated.ts | 4 + .../bundled-provider-auth-env-vars.test.ts | 7 + src/plugins/bundled-web-search.test.ts | 1 + src/plugins/bundled-web-search.ts | 15 + .../contracts/registry.contract.test.ts | 9 + src/plugins/contracts/registry.ts | 3 +- src/plugins/web-search-providers.test.ts | 5 + src/secrets/provider-env-vars.test.ts | 22 +- src/secrets/target-registry-data.ts | 11 + src/web-search/runtime.test.ts | 77 +++++ 37 files changed, 1378 insertions(+), 124 deletions(-) create mode 100644 docs/tools/tavily.md create mode 100644 extensions/tavily/index.test.ts create mode 100644 extensions/tavily/index.ts create mode 100644 extensions/tavily/openclaw.plugin.json create mode 100644 extensions/tavily/package.json create mode 100644 extensions/tavily/skills/tavily/SKILL.md create mode 100644 extensions/tavily/src/config.ts create mode 100644 extensions/tavily/src/tavily-client.ts create mode 100644 extensions/tavily/src/tavily-extract-tool.test.ts create mode 100644 extensions/tavily/src/tavily-extract-tool.ts create mode 100644 extensions/tavily/src/tavily-search-provider.ts create mode 100644 extensions/tavily/src/tavily-search-tool.ts diff --git a/.github/labeler.yml b/.github/labeler.yml index 4ee43d5e6fa..67a74985465 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -293,6 +293,10 @@ - changed-files: - any-glob-to-any-file: - "extensions/synthetic/**" +"extensions: tavily": + - changed-files: + - any-glob-to-any-file: + - "extensions/tavily/**" "extensions: talk-voice": - changed-files: - any-glob-to-any-file: diff --git a/CHANGELOG.md b/CHANGELOG.md index e0c87b836a9..37ff9e33f36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ Docs: https://docs.openclaw.ai - Plugins/Xiaomi: switch the bundled Xiaomi provider to the `/v1` OpenAI-compatible endpoint and add MiMo V2 Pro plus MiMo V2 Omni to the built-in catalog. (#49214) thanks @DJjjjhao. - Plugins/Matrix: add `allowBots` room policy so configured Matrix bot accounts can talk to each other, with optional mention-only gating. Thanks @gumadeiras. - Plugins/Matrix: add per-account `allowPrivateNetwork` opt-in for private/internal homeservers, while keeping public cleartext homeservers blocked. Thanks @gumadeiras. +- Web tools/Tavily: add Tavily as a bundled web-search provider with dedicated `tavily_search` and `tavily_extract` tools, using canonical plugin-owned config under `plugins.entries.tavily.config.webSearch.*`. (#49200) thanks @lakshyaag-tavily. ### Fixes diff --git a/docs/docs.json b/docs/docs.json index bd7d01fc43b..a941bec2601 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1031,6 +1031,7 @@ "tools/exec", "tools/exec-approvals", "tools/firecrawl", + "tools/tavily", "tools/llm-task", "tools/lobster", "tools/loop-detection", diff --git a/docs/reference/secretref-credential-surface.md b/docs/reference/secretref-credential-surface.md index 39420e335bf..d0a11bc68ef 100644 --- a/docs/reference/secretref-credential-surface.md +++ b/docs/reference/secretref-credential-surface.md @@ -38,6 +38,7 @@ Scope intent: - `plugins.entries.moonshot.config.webSearch.apiKey` - `plugins.entries.perplexity.config.webSearch.apiKey` - `plugins.entries.firecrawl.config.webSearch.apiKey` +- `plugins.entries.tavily.config.webSearch.apiKey` - `tools.web.search.apiKey` - `tools.web.search.gemini.apiKey` - `tools.web.search.grok.apiKey` diff --git a/docs/reference/secretref-user-supplied-credentials-matrix.json b/docs/reference/secretref-user-supplied-credentials-matrix.json index d4706e40304..cca7bb38c4b 100644 --- a/docs/reference/secretref-user-supplied-credentials-matrix.json +++ b/docs/reference/secretref-user-supplied-credentials-matrix.json @@ -551,6 +551,13 @@ "path": "tools.web.search.perplexity.apiKey", "secretShape": "secret_input", "optIn": true + }, + { + "id": "plugins.entries.tavily.config.webSearch.apiKey", + "configFile": "openclaw.json", + "path": "plugins.entries.tavily.config.webSearch.apiKey", + "secretShape": "secret_input", + "optIn": true } ] } diff --git a/docs/tools/index.md b/docs/tools/index.md index 55e52bf46da..91297e5775c 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -256,7 +256,7 @@ Enable with `tools.loopDetection.enabled: true` (default is `false`). ### `web_search` -Search the web using Brave, Firecrawl, Gemini, Grok, Kimi, or Perplexity. +Search the web using Brave, Firecrawl, Gemini, Grok, Kimi, Perplexity, or Tavily. Core parameters: diff --git a/docs/tools/tavily.md b/docs/tools/tavily.md new file mode 100644 index 00000000000..dcf7ce4c1ad --- /dev/null +++ b/docs/tools/tavily.md @@ -0,0 +1,125 @@ +--- +summary: "Tavily search and extract tools" +read_when: + - You want Tavily-backed web search + - You need a Tavily API key + - You want Tavily as a web_search provider + - You want content extraction from URLs +title: "Tavily" +--- + +# Tavily + +OpenClaw can use **Tavily** in two ways: + +- as the `web_search` provider +- as explicit plugin tools: `tavily_search` and `tavily_extract` + +Tavily is a search API designed for AI applications, returning structured results +optimized for LLM consumption. It supports configurable search depth, topic +filtering, domain filters, AI-generated answer summaries, and content extraction +from URLs (including JavaScript-rendered pages). + +## Get an API key + +1. Create a Tavily account at [tavily.com](https://tavily.com/). +2. Generate an API key in the dashboard. +3. Store it in config or set `TAVILY_API_KEY` in the gateway environment. + +## Configure Tavily search + +```json5 +{ + plugins: { + entries: { + tavily: { + enabled: true, + config: { + webSearch: { + apiKey: "tvly-...", // optional if TAVILY_API_KEY is set + baseUrl: "https://api.tavily.com", + }, + }, + }, + }, + }, + tools: { + web: { + search: { + provider: "tavily", + }, + }, + }, +} +``` + +Notes: + +- Choosing Tavily in onboarding or `openclaw configure --section web` enables + the bundled Tavily plugin automatically. +- Store Tavily config under `plugins.entries.tavily.config.webSearch.*`. +- `web_search` with Tavily supports `query` and `count` (up to 20 results). +- For Tavily-specific controls like `search_depth`, `topic`, `include_answer`, + or domain filters, use `tavily_search`. + +## Tavily plugin tools + +### `tavily_search` + +Use this when you want Tavily-specific search controls instead of generic +`web_search`. + +| Parameter | Description | +| ----------------- | --------------------------------------------------------------------- | +| `query` | Search query string (keep under 400 characters) | +| `search_depth` | `basic` (default, balanced) or `advanced` (highest relevance, slower) | +| `topic` | `general` (default), `news` (real-time updates), or `finance` | +| `max_results` | Number of results, 1-20 (default: 5) | +| `include_answer` | Include an AI-generated answer summary (default: false) | +| `time_range` | Filter by recency: `day`, `week`, `month`, or `year` | +| `include_domains` | Array of domains to restrict results to | +| `exclude_domains` | Array of domains to exclude from results | + +**Search depth:** + +| Depth | Speed | Relevance | Best for | +| ---------- | ------ | --------- | ----------------------------------- | +| `basic` | Faster | High | General-purpose queries (default) | +| `advanced` | Slower | Highest | Precision, specific facts, research | + +### `tavily_extract` + +Use this to extract clean content from one or more URLs. Handles +JavaScript-rendered pages and supports query-focused chunking for targeted +extraction. + +| Parameter | Description | +| ------------------- | ---------------------------------------------------------- | +| `urls` | Array of URLs to extract (1-20 per request) | +| `query` | Rerank extracted chunks by relevance to this query | +| `extract_depth` | `basic` (default, fast) or `advanced` (for JS-heavy pages) | +| `chunks_per_source` | Chunks per URL, 1-5 (requires `query`) | +| `include_images` | Include image URLs in results (default: false) | + +**Extract depth:** + +| Depth | When to use | +| ---------- | ----------------------------------------- | +| `basic` | Simple pages - try this first | +| `advanced` | JS-rendered SPAs, dynamic content, tables | + +Tips: + +- Max 20 URLs per request. Batch larger lists into multiple calls. +- Use `query` + `chunks_per_source` to get only relevant content instead of full pages. +- Try `basic` first; fall back to `advanced` if content is missing or incomplete. + +## Choosing the right tool + +| Need | Tool | +| ------------------------------------ | ---------------- | +| Quick web search, no special options | `web_search` | +| Search with depth, topic, AI answers | `tavily_search` | +| Extract content from specific URLs | `tavily_extract` | + +See [Web tools](/tools/web) for the full web tool setup and provider comparison. diff --git a/docs/tools/web.md b/docs/tools/web.md index 313e709c32f..8d5b6bff5f1 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -1,5 +1,5 @@ --- -summary: "Web search + fetch tools (Brave, Firecrawl, Gemini, Grok, Kimi, and Perplexity providers)" +summary: "Web search + fetch tools (Brave, Firecrawl, Gemini, Grok, Kimi, Perplexity, and Tavily providers)" read_when: - You want to enable web_search or web_fetch - You need provider API key setup @@ -11,7 +11,7 @@ title: "Web Tools" OpenClaw ships two lightweight web tools: -- `web_search` — Search the web using Brave Search API, Firecrawl Search, Gemini with Google Search grounding, Grok, Kimi, or Perplexity Search API. +- `web_search` — Search the web using Brave Search API, Firecrawl Search, Gemini with Google Search grounding, Grok, Kimi, Perplexity Search API, or Tavily Search API. - `web_fetch` — HTTP fetch + readable extraction (HTML → markdown/text). These are **not** browser automation. For JS-heavy sites or logins, use the @@ -25,8 +25,9 @@ These are **not** browser automation. For JS-heavy sites or logins, use the (HTML → markdown/text). It does **not** execute JavaScript. - `web_fetch` is enabled by default (unless explicitly disabled). - The bundled Firecrawl plugin also adds `firecrawl_search` and `firecrawl_scrape` when enabled. +- The bundled Tavily plugin also adds `tavily_search` and `tavily_extract` when enabled. -See [Brave Search setup](/tools/brave-search) and [Perplexity Search setup](/tools/perplexity-search) for provider-specific details. +See [Brave Search setup](/tools/brave-search), [Perplexity Search setup](/tools/perplexity-search), and [Tavily Search setup](/tools/tavily) for provider-specific details. ## Choosing a search provider @@ -38,6 +39,7 @@ See [Brave Search setup](/tools/brave-search) and [Perplexity Search setup](/too | **Grok** | AI-synthesized answers + citations | — | Uses xAI web-grounded responses | `XAI_API_KEY` | | **Kimi** | AI-synthesized answers + citations | — | Uses Moonshot web search | `KIMI_API_KEY` / `MOONSHOT_API_KEY` | | **Perplexity Search API** | Structured results with snippets | `country`, `language`, time, `domain_filter` | Supports content extraction controls; OpenRouter uses Sonar compatibility path | `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` | +| **Tavily Search API** | Structured results with snippets | Use `tavily_search` for Tavily-specific search options | Search depth, topic filtering, AI answers, URL extraction via `tavily_extract` | `TAVILY_API_KEY` | ### Auto-detection @@ -49,6 +51,7 @@ The table above is alphabetical. If no `provider` is explicitly set, runtime aut 4. **Kimi** — `KIMI_API_KEY` / `MOONSHOT_API_KEY` env var or `plugins.entries.moonshot.config.webSearch.apiKey` 5. **Perplexity** — `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `plugins.entries.perplexity.config.webSearch.apiKey` 6. **Firecrawl** — `FIRECRAWL_API_KEY` env var or `plugins.entries.firecrawl.config.webSearch.apiKey` +7. **Tavily** — `TAVILY_API_KEY` env var or `plugins.entries.tavily.config.webSearch.apiKey` If no keys are found, it falls back to Brave (you'll get a missing-key error prompting you to configure one). @@ -97,6 +100,7 @@ See [Perplexity Search API Docs](https://docs.perplexity.ai/guides/search-quicks - Grok: `plugins.entries.xai.config.webSearch.apiKey` - Kimi: `plugins.entries.moonshot.config.webSearch.apiKey` - Perplexity: `plugins.entries.perplexity.config.webSearch.apiKey` +- Tavily: `plugins.entries.tavily.config.webSearch.apiKey` All of these fields also support SecretRef objects. @@ -108,6 +112,7 @@ All of these fields also support SecretRef objects. - Grok: `XAI_API_KEY` - Kimi: `KIMI_API_KEY` or `MOONSHOT_API_KEY` - Perplexity: `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY` +- Tavily: `TAVILY_API_KEY` For a gateway install, put these in `~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables). @@ -176,6 +181,36 @@ For a gateway install, put these in `~/.openclaw/.env` (or your service environm When you choose Firecrawl in onboarding or `openclaw configure --section web`, OpenClaw enables the bundled Firecrawl plugin automatically so `web_search`, `firecrawl_search`, and `firecrawl_scrape` are all available. +**Tavily Search:** + +```json5 +{ + plugins: { + entries: { + tavily: { + enabled: true, + config: { + webSearch: { + apiKey: "tvly-...", // optional if TAVILY_API_KEY is set + baseUrl: "https://api.tavily.com", + }, + }, + }, + }, + }, + tools: { + web: { + search: { + enabled: true, + provider: "tavily", + }, + }, + }, +} +``` + +When you choose Tavily in onboarding or `openclaw configure --section web`, OpenClaw enables the bundled Tavily plugin automatically so `web_search`, `tavily_search`, and `tavily_extract` are all available. + **Brave LLM Context mode:** ```json5 @@ -326,6 +361,7 @@ Search the web using your configured provider. - **Grok**: `XAI_API_KEY` or `plugins.entries.xai.config.webSearch.apiKey` - **Kimi**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `plugins.entries.moonshot.config.webSearch.apiKey` - **Perplexity**: `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `plugins.entries.perplexity.config.webSearch.apiKey` + - **Tavily**: `TAVILY_API_KEY` or `plugins.entries.tavily.config.webSearch.apiKey` - All provider key fields above support SecretRef objects. ### Config @@ -369,6 +405,8 @@ If you set `plugins.entries.perplexity.config.webSearch.baseUrl` / `model`, use Firecrawl `web_search` supports `query` and `count`. For Firecrawl-specific controls like `sources`, `categories`, result scraping, or scrape timeout, use `firecrawl_search` from the bundled Firecrawl plugin. +Tavily `web_search` supports `query` and `count` (up to 20 results). For Tavily-specific controls like `search_depth`, `topic`, `include_answer`, or domain filters, use `tavily_search` from the bundled Tavily plugin. For URL content extraction, use `tavily_extract`. See [Tavily](/tools/tavily) for details. + **Examples:** ```javascript diff --git a/extensions/brave/openclaw.plugin.json b/extensions/brave/openclaw.plugin.json index 2077f174d62..791a413ec66 100644 --- a/extensions/brave/openclaw.plugin.json +++ b/extensions/brave/openclaw.plugin.json @@ -1,5 +1,8 @@ { "id": "brave", + "providerAuthEnvVars": { + "brave": ["BRAVE_API_KEY"] + }, "uiHints": { "webSearch.apiKey": { "label": "Brave Search API Key", diff --git a/extensions/firecrawl/openclaw.plugin.json b/extensions/firecrawl/openclaw.plugin.json index e9c50c589d2..adbe2a2a9c8 100644 --- a/extensions/firecrawl/openclaw.plugin.json +++ b/extensions/firecrawl/openclaw.plugin.json @@ -1,5 +1,8 @@ { "id": "firecrawl", + "providerAuthEnvVars": { + "firecrawl": ["FIRECRAWL_API_KEY"] + }, "uiHints": { "webSearch.apiKey": { "label": "Firecrawl Search API Key", diff --git a/extensions/perplexity/openclaw.plugin.json b/extensions/perplexity/openclaw.plugin.json index 89c7a0fb902..32567c76cb2 100644 --- a/extensions/perplexity/openclaw.plugin.json +++ b/extensions/perplexity/openclaw.plugin.json @@ -1,5 +1,8 @@ { "id": "perplexity", + "providerAuthEnvVars": { + "perplexity": ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"] + }, "uiHints": { "webSearch.apiKey": { "label": "Perplexity API Key", diff --git a/extensions/tavily/index.test.ts b/extensions/tavily/index.test.ts new file mode 100644 index 00000000000..5b71aeb6f7b --- /dev/null +++ b/extensions/tavily/index.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import plugin from "./index.js"; + +describe("tavily plugin", () => { + it("exports a valid plugin entry with correct id and name", () => { + expect(plugin.id).toBe("tavily"); + expect(plugin.name).toBe("Tavily Plugin"); + expect(typeof plugin.register).toBe("function"); + }); + + it("registers web search provider and two tools", () => { + const registrations: { + webSearchProviders: unknown[]; + tools: unknown[]; + } = { webSearchProviders: [], tools: [] }; + + const mockApi = { + registerWebSearchProvider(provider: unknown) { + registrations.webSearchProviders.push(provider); + }, + registerTool(tool: unknown) { + registrations.tools.push(tool); + }, + config: {}, + }; + + plugin.register(mockApi as never); + + expect(registrations.webSearchProviders).toHaveLength(1); + expect(registrations.tools).toHaveLength(2); + + const provider = registrations.webSearchProviders[0] as Record; + expect(provider.id).toBe("tavily"); + expect(provider.autoDetectOrder).toBe(70); + expect(provider.envVars).toEqual(["TAVILY_API_KEY"]); + + const toolNames = registrations.tools.map((t) => (t as Record).name); + expect(toolNames).toContain("tavily_search"); + expect(toolNames).toContain("tavily_extract"); + }); +}); diff --git a/extensions/tavily/index.ts b/extensions/tavily/index.ts new file mode 100644 index 00000000000..f35fda3129d --- /dev/null +++ b/extensions/tavily/index.ts @@ -0,0 +1,15 @@ +import { definePluginEntry, type AnyAgentTool } from "openclaw/plugin-sdk/core"; +import { createTavilyExtractTool } from "./src/tavily-extract-tool.js"; +import { createTavilyWebSearchProvider } from "./src/tavily-search-provider.js"; +import { createTavilySearchTool } from "./src/tavily-search-tool.js"; + +export default definePluginEntry({ + id: "tavily", + name: "Tavily Plugin", + description: "Bundled Tavily search and extract plugin", + register(api) { + api.registerWebSearchProvider(createTavilyWebSearchProvider()); + api.registerTool(createTavilySearchTool(api) as AnyAgentTool); + api.registerTool(createTavilyExtractTool(api) as AnyAgentTool); + }, +}); diff --git a/extensions/tavily/openclaw.plugin.json b/extensions/tavily/openclaw.plugin.json new file mode 100644 index 00000000000..9ed930bfe63 --- /dev/null +++ b/extensions/tavily/openclaw.plugin.json @@ -0,0 +1,37 @@ +{ + "id": "tavily", + "skills": ["./skills"], + "providerAuthEnvVars": { + "tavily": ["TAVILY_API_KEY"] + }, + "uiHints": { + "webSearch.apiKey": { + "label": "Tavily API Key", + "help": "Tavily API key for web search and extraction (fallback: TAVILY_API_KEY env var).", + "sensitive": true, + "placeholder": "tvly-..." + }, + "webSearch.baseUrl": { + "label": "Tavily Base URL", + "help": "Tavily API base URL override." + } + }, + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "webSearch": { + "type": "object", + "additionalProperties": false, + "properties": { + "apiKey": { + "type": ["string", "object"] + }, + "baseUrl": { + "type": "string" + } + } + } + } + } +} diff --git a/extensions/tavily/package.json b/extensions/tavily/package.json new file mode 100644 index 00000000000..3d693a6ca38 --- /dev/null +++ b/extensions/tavily/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/tavily-plugin", + "version": "2026.3.17", + "private": true, + "description": "OpenClaw Tavily plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/tavily/skills/tavily/SKILL.md b/extensions/tavily/skills/tavily/SKILL.md new file mode 100644 index 00000000000..4026537362a --- /dev/null +++ b/extensions/tavily/skills/tavily/SKILL.md @@ -0,0 +1,94 @@ +--- +name: tavily +description: Tavily web search, content extraction, and research tools. +metadata: + { "openclaw": { "emoji": "🔍", "requires": { "config": ["plugins.entries.tavily.enabled"] } } } +--- + +# Tavily Tools + +## When to use which tool + +| Need | Tool | When | +| ---------------------------- | ---------------- | ------------------------------------------------------------- | +| Quick web search | `web_search` | Basic queries, no special options needed | +| Search with advanced options | `tavily_search` | Need depth, topic, domain filters, time ranges, or AI answers | +| Extract content from URLs | `tavily_extract` | Have specific URLs, need their content | + +## web_search + +Tavily powers this automatically when selected as the search provider. Use for +straightforward queries where you don't need Tavily-specific options. + +| Parameter | Description | +| --------- | ------------------------ | +| `query` | Search query string | +| `count` | Number of results (1-20) | + +## tavily_search + +Use when you need fine-grained control over search behavior. + +| Parameter | Description | +| ----------------- | --------------------------------------------------------------------- | +| `query` | Search query string (keep under 400 characters) | +| `search_depth` | `basic` (default, balanced) or `advanced` (highest relevance, slower) | +| `topic` | `general` (default), `news` (real-time updates), or `finance` | +| `max_results` | Number of results, 1-20 (default: 5) | +| `include_answer` | Include an AI-generated answer summary (default: false) | +| `time_range` | Filter by recency: `day`, `week`, `month`, or `year` | +| `include_domains` | Array of domains to restrict results to | +| `exclude_domains` | Array of domains to exclude from results | + +### Search depth + +| Depth | Speed | Relevance | Best for | +| ---------- | ------ | --------- | -------------------------------------------- | +| `basic` | Faster | High | General-purpose queries (default) | +| `advanced` | Slower | Highest | Precision, specific facts, detailed research | + +### Tips + +- **Keep queries under 400 characters** — think search query, not prompt. +- **Break complex queries into sub-queries** for better results. +- **Use `include_domains`** to focus on trusted sources. +- **Use `time_range`** for recent information (news, current events). +- **Use `include_answer`** when you need a quick synthesized answer. + +## tavily_extract + +Use when you have specific URLs and need their content. Handles JavaScript-rendered +pages and returns clean markdown. Supports query-focused chunking for targeted +extraction. + +| Parameter | Description | +| ------------------- | ------------------------------------------------------------------ | +| `urls` | Array of URLs to extract (1-20 per request) | +| `query` | Rerank extracted chunks by relevance to this query | +| `extract_depth` | `basic` (default, fast) or `advanced` (for JS-heavy pages, tables) | +| `chunks_per_source` | Chunks per URL, 1-5 (requires `query`) | +| `include_images` | Include image URLs in results (default: false) | + +### Extract depth + +| Depth | When to use | +| ---------- | ----------------------------------------------------------- | +| `basic` | Simple pages — try this first | +| `advanced` | JS-rendered SPAs, dynamic content, tables, embedded content | + +### Tips + +- **Max 20 URLs per request** — batch larger lists into multiple calls. +- **Use `query` + `chunks_per_source`** to get only relevant content instead of full pages. +- **Try `basic` first**, fall back to `advanced` if content is missing or incomplete. +- If `tavily_search` results already contain the snippets you need, skip the extract step. + +## Choosing the right workflow + +Follow this escalation pattern — start simple, escalate only when needed: + +1. **`web_search`** — Quick lookup, no special options needed. +2. **`tavily_search`** — Need depth control, topic filtering, domain filters, time ranges, or AI answers. +3. **`tavily_extract`** — Have specific URLs, need their full content or targeted chunks. + +Combine search + extract when you need to find pages first, then get their full content. diff --git a/extensions/tavily/src/config.ts b/extensions/tavily/src/config.ts new file mode 100644 index 00000000000..752a721d17c --- /dev/null +++ b/extensions/tavily/src/config.ts @@ -0,0 +1,71 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/config-runtime"; +import { normalizeSecretInput } from "openclaw/plugin-sdk/provider-auth"; + +export const DEFAULT_TAVILY_BASE_URL = "https://api.tavily.com"; +export const DEFAULT_TAVILY_SEARCH_TIMEOUT_SECONDS = 30; +export const DEFAULT_TAVILY_EXTRACT_TIMEOUT_SECONDS = 60; + +type TavilySearchConfig = + | { + apiKey?: unknown; + baseUrl?: string; + } + | undefined; + +type PluginEntryConfig = { + webSearch?: { + apiKey?: unknown; + baseUrl?: string; + }; +}; + +export function resolveTavilySearchConfig(cfg?: OpenClawConfig): TavilySearchConfig { + const pluginConfig = cfg?.plugins?.entries?.tavily?.config as PluginEntryConfig; + const pluginWebSearch = pluginConfig?.webSearch; + if (pluginWebSearch && typeof pluginWebSearch === "object" && !Array.isArray(pluginWebSearch)) { + return pluginWebSearch; + } + return undefined; +} + +function normalizeConfiguredSecret(value: unknown, path: string): string | undefined { + return normalizeSecretInput( + normalizeResolvedSecretInputString({ + value, + path, + }), + ); +} + +export function resolveTavilyApiKey(cfg?: OpenClawConfig): string | undefined { + const search = resolveTavilySearchConfig(cfg); + return ( + normalizeConfiguredSecret(search?.apiKey, "plugins.entries.tavily.config.webSearch.apiKey") || + normalizeSecretInput(process.env.TAVILY_API_KEY) || + undefined + ); +} + +export function resolveTavilyBaseUrl(cfg?: OpenClawConfig): string { + const search = resolveTavilySearchConfig(cfg); + const configured = + (typeof search?.baseUrl === "string" ? search.baseUrl.trim() : "") || + normalizeSecretInput(process.env.TAVILY_BASE_URL) || + ""; + return configured || DEFAULT_TAVILY_BASE_URL; +} + +export function resolveTavilySearchTimeoutSeconds(override?: number): number { + if (typeof override === "number" && Number.isFinite(override) && override > 0) { + return Math.floor(override); + } + return DEFAULT_TAVILY_SEARCH_TIMEOUT_SECONDS; +} + +export function resolveTavilyExtractTimeoutSeconds(override?: number): number { + if (typeof override === "number" && Number.isFinite(override) && override > 0) { + return Math.floor(override); + } + return DEFAULT_TAVILY_EXTRACT_TIMEOUT_SECONDS; +} diff --git a/extensions/tavily/src/tavily-client.ts b/extensions/tavily/src/tavily-client.ts new file mode 100644 index 00000000000..8308f8b8772 --- /dev/null +++ b/extensions/tavily/src/tavily-client.ts @@ -0,0 +1,286 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { withTrustedWebToolsEndpoint } from "openclaw/plugin-sdk/provider-web-search"; +import { + DEFAULT_CACHE_TTL_MINUTES, + normalizeCacheKey, + readCache, + readResponseText, + resolveCacheTtlMs, + writeCache, +} from "openclaw/plugin-sdk/provider-web-search"; +import { wrapExternalContent, wrapWebContent } from "openclaw/plugin-sdk/security-runtime"; +import { + DEFAULT_TAVILY_BASE_URL, + resolveTavilyApiKey, + resolveTavilyBaseUrl, + resolveTavilyExtractTimeoutSeconds, + resolveTavilySearchTimeoutSeconds, +} from "./config.js"; + +const SEARCH_CACHE = new Map< + string, + { value: Record; expiresAt: number; insertedAt: number } +>(); +const EXTRACT_CACHE = new Map< + string, + { value: Record; expiresAt: number; insertedAt: number } +>(); +const DEFAULT_SEARCH_COUNT = 5; +const DEFAULT_ERROR_MAX_BYTES = 64_000; + +export type TavilySearchParams = { + cfg?: OpenClawConfig; + query: string; + searchDepth?: string; + topic?: string; + maxResults?: number; + includeAnswer?: boolean; + timeRange?: string; + includeDomains?: string[]; + excludeDomains?: string[]; + timeoutSeconds?: number; +}; + +export type TavilyExtractParams = { + cfg?: OpenClawConfig; + urls: string[]; + query?: string; + extractDepth?: string; + chunksPerSource?: number; + includeImages?: boolean; + timeoutSeconds?: number; +}; + +function resolveEndpoint(baseUrl: string, pathname: string): string { + const trimmed = baseUrl.trim(); + if (!trimmed) { + return `${DEFAULT_TAVILY_BASE_URL}${pathname}`; + } + try { + const url = new URL(trimmed); + // Always append the endpoint pathname to the base URL path, + // supporting both bare hosts and reverse-proxy path prefixes. + url.pathname = url.pathname.replace(/\/$/, "") + pathname; + return url.toString(); + } catch { + return `${DEFAULT_TAVILY_BASE_URL}${pathname}`; + } +} + +async function postTavilyJson(params: { + baseUrl: string; + pathname: string; + apiKey: string; + body: Record; + timeoutSeconds: number; + errorLabel: string; +}): Promise> { + const endpoint = resolveEndpoint(params.baseUrl, params.pathname); + return await withTrustedWebToolsEndpoint( + { + url: endpoint, + timeoutSeconds: params.timeoutSeconds, + init: { + method: "POST", + headers: { + Accept: "application/json", + Authorization: `Bearer ${params.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(params.body), + }, + }, + async ({ response }) => { + if (!response.ok) { + const detail = await readResponseText(response, { maxBytes: DEFAULT_ERROR_MAX_BYTES }); + throw new Error( + `${params.errorLabel} API error (${response.status}): ${detail.text || response.statusText}`, + ); + } + return (await response.json()) as Record; + }, + ); +} + +export async function runTavilySearch( + params: TavilySearchParams, +): Promise> { + const apiKey = resolveTavilyApiKey(params.cfg); + if (!apiKey) { + throw new Error( + "web_search (tavily) needs a Tavily API key. Set TAVILY_API_KEY in the Gateway environment, or configure plugins.entries.tavily.config.webSearch.apiKey.", + ); + } + const count = + typeof params.maxResults === "number" && Number.isFinite(params.maxResults) + ? Math.max(1, Math.min(20, Math.floor(params.maxResults))) + : DEFAULT_SEARCH_COUNT; + const timeoutSeconds = resolveTavilySearchTimeoutSeconds(params.timeoutSeconds); + const baseUrl = resolveTavilyBaseUrl(params.cfg); + + const cacheKey = normalizeCacheKey( + JSON.stringify({ + type: "tavily-search", + q: params.query, + count, + baseUrl, + searchDepth: params.searchDepth, + topic: params.topic, + includeAnswer: params.includeAnswer, + timeRange: params.timeRange, + includeDomains: params.includeDomains, + excludeDomains: params.excludeDomains, + }), + ); + const cached = readCache(SEARCH_CACHE, cacheKey); + if (cached) { + return { ...cached.value, cached: true }; + } + + const body: Record = { + query: params.query, + max_results: count, + }; + if (params.searchDepth) body.search_depth = params.searchDepth; + if (params.topic) body.topic = params.topic; + if (params.includeAnswer) body.include_answer = true; + if (params.timeRange) body.time_range = params.timeRange; + if (params.includeDomains?.length) body.include_domains = params.includeDomains; + if (params.excludeDomains?.length) body.exclude_domains = params.excludeDomains; + + const start = Date.now(); + const payload = await postTavilyJson({ + baseUrl, + pathname: "/search", + apiKey, + body, + timeoutSeconds, + errorLabel: "Tavily Search", + }); + + const rawResults = Array.isArray(payload.results) ? payload.results : []; + const results = rawResults.map((r: Record) => ({ + title: typeof r.title === "string" ? wrapWebContent(r.title, "web_search") : "", + url: typeof r.url === "string" ? r.url : "", + snippet: typeof r.content === "string" ? wrapWebContent(r.content, "web_search") : "", + score: typeof r.score === "number" ? r.score : undefined, + ...(typeof r.published_date === "string" ? { published: r.published_date } : {}), + })); + + const result: Record = { + query: params.query, + provider: "tavily", + count: results.length, + tookMs: Date.now() - start, + externalContent: { + untrusted: true, + source: "web_search", + provider: "tavily", + wrapped: true, + }, + results, + }; + if (typeof payload.answer === "string" && payload.answer) { + result.answer = wrapWebContent(payload.answer, "web_search"); + } + + writeCache( + SEARCH_CACHE, + cacheKey, + result, + resolveCacheTtlMs(undefined, DEFAULT_CACHE_TTL_MINUTES), + ); + return result; +} + +export async function runTavilyExtract( + params: TavilyExtractParams, +): Promise> { + const apiKey = resolveTavilyApiKey(params.cfg); + if (!apiKey) { + throw new Error( + "tavily_extract needs a Tavily API key. Set TAVILY_API_KEY in the Gateway environment, or configure plugins.entries.tavily.config.webSearch.apiKey.", + ); + } + const baseUrl = resolveTavilyBaseUrl(params.cfg); + const timeoutSeconds = resolveTavilyExtractTimeoutSeconds(params.timeoutSeconds); + + const cacheKey = normalizeCacheKey( + JSON.stringify({ + type: "tavily-extract", + urls: params.urls, + baseUrl, + query: params.query, + extractDepth: params.extractDepth, + chunksPerSource: params.chunksPerSource, + includeImages: params.includeImages, + }), + ); + const cached = readCache(EXTRACT_CACHE, cacheKey); + if (cached) { + return { ...cached.value, cached: true }; + } + + const body: Record = { urls: params.urls }; + if (params.query) body.query = params.query; + if (params.extractDepth) body.extract_depth = params.extractDepth; + if (params.chunksPerSource) body.chunks_per_source = params.chunksPerSource; + if (params.includeImages) body.include_images = true; + + const start = Date.now(); + const payload = await postTavilyJson({ + baseUrl, + pathname: "/extract", + apiKey, + body, + timeoutSeconds, + errorLabel: "Tavily Extract", + }); + + const rawResults = Array.isArray(payload.results) ? payload.results : []; + const results = rawResults.map((r: Record) => ({ + url: typeof r.url === "string" ? r.url : "", + rawContent: + typeof r.raw_content === "string" + ? wrapExternalContent(r.raw_content, { source: "web_fetch", includeWarning: false }) + : "", + ...(typeof r.content === "string" + ? { content: wrapExternalContent(r.content, { source: "web_fetch", includeWarning: false }) } + : {}), + ...(Array.isArray(r.images) + ? { + images: (r.images as string[]).map((img) => + wrapExternalContent(String(img), { source: "web_fetch", includeWarning: false }), + ), + } + : {}), + })); + + const failedResults = Array.isArray(payload.failed_results) ? payload.failed_results : []; + + const result: Record = { + provider: "tavily", + count: results.length, + tookMs: Date.now() - start, + externalContent: { + untrusted: true, + source: "web_fetch", + provider: "tavily", + wrapped: true, + }, + results, + ...(failedResults.length > 0 ? { failedResults } : {}), + }; + + writeCache( + EXTRACT_CACHE, + cacheKey, + result, + resolveCacheTtlMs(undefined, DEFAULT_CACHE_TTL_MINUTES), + ); + return result; +} + +export const __testing = { + postTavilyJson, +}; diff --git a/extensions/tavily/src/tavily-extract-tool.test.ts b/extensions/tavily/src/tavily-extract-tool.test.ts new file mode 100644 index 00000000000..f571e196d0b --- /dev/null +++ b/extensions/tavily/src/tavily-extract-tool.test.ts @@ -0,0 +1,53 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-runtime"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./tavily-client.js", () => ({ + runTavilyExtract: vi.fn(async (params: unknown) => ({ ok: true, params })), +})); + +import { runTavilyExtract } from "./tavily-client.js"; +import { createTavilyExtractTool } from "./tavily-extract-tool.js"; + +function fakeApi(): OpenClawPluginApi { + return { + config: {}, + } as OpenClawPluginApi; +} + +describe("tavily_extract", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("rejects chunks_per_source without query", async () => { + const tool = createTavilyExtractTool(fakeApi()); + + await expect( + tool.execute("id", { + urls: ["https://example.com"], + chunks_per_source: 2, + }), + ).rejects.toThrow("tavily_extract requires query when chunks_per_source is set."); + + expect(runTavilyExtract).not.toHaveBeenCalled(); + }); + + it("forwards query-scoped chunking when query is provided", async () => { + const tool = createTavilyExtractTool(fakeApi()); + + await tool.execute("id", { + urls: ["https://example.com"], + query: "pricing", + chunks_per_source: 2, + }); + + expect(runTavilyExtract).toHaveBeenCalledWith( + expect.objectContaining({ + cfg: {}, + urls: ["https://example.com"], + query: "pricing", + chunksPerSource: 2, + }), + ); + }); +}); diff --git a/extensions/tavily/src/tavily-extract-tool.ts b/extensions/tavily/src/tavily-extract-tool.ts new file mode 100644 index 00000000000..1a3c381fc64 --- /dev/null +++ b/extensions/tavily/src/tavily-extract-tool.ts @@ -0,0 +1,74 @@ +import { Type } from "@sinclair/typebox"; +import { optionalStringEnum } from "openclaw/plugin-sdk/agent-runtime"; +import { jsonResult, readNumberParam, readStringParam } from "openclaw/plugin-sdk/agent-runtime"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-runtime"; +import { runTavilyExtract } from "./tavily-client.js"; + +const TavilyExtractToolSchema = Type.Object( + { + urls: Type.Array(Type.String(), { + description: "One or more URLs to extract content from (max 20).", + minItems: 1, + maxItems: 20, + }), + query: Type.Optional( + Type.String({ + description: "Rerank extracted chunks by relevance to this query.", + }), + ), + extract_depth: optionalStringEnum(["basic", "advanced"] as const, { + description: '"basic" (default) or "advanced" (for JS-heavy pages).', + }), + chunks_per_source: Type.Optional( + Type.Number({ + description: "Chunks per URL (1-5, requires query).", + minimum: 1, + maximum: 5, + }), + ), + include_images: Type.Optional( + Type.Boolean({ + description: "Include image URLs in extraction results.", + }), + ), + }, + { additionalProperties: false }, +); + +export function createTavilyExtractTool(api: OpenClawPluginApi) { + return { + name: "tavily_extract", + label: "Tavily Extract", + description: + "Extract clean content from one or more URLs using Tavily. Handles JS-rendered pages. Supports query-focused chunking.", + parameters: TavilyExtractToolSchema, + execute: async (_toolCallId: string, rawParams: Record) => { + const urls = Array.isArray(rawParams.urls) + ? (rawParams.urls as string[]).filter(Boolean) + : []; + if (urls.length === 0) { + throw new Error("tavily_extract requires at least one URL."); + } + const query = readStringParam(rawParams, "query") || undefined; + const extractDepth = readStringParam(rawParams, "extract_depth") || undefined; + const chunksPerSource = readNumberParam(rawParams, "chunks_per_source", { + integer: true, + }); + if (chunksPerSource !== undefined && !query) { + throw new Error("tavily_extract requires query when chunks_per_source is set."); + } + const includeImages = rawParams.include_images === true; + + return jsonResult( + await runTavilyExtract({ + cfg: api.config, + urls, + query, + extractDepth, + chunksPerSource, + includeImages, + }), + ); + }, + }; +} diff --git a/extensions/tavily/src/tavily-search-provider.ts b/extensions/tavily/src/tavily-search-provider.ts new file mode 100644 index 00000000000..2ad33362353 --- /dev/null +++ b/extensions/tavily/src/tavily-search-provider.ts @@ -0,0 +1,76 @@ +import { Type } from "@sinclair/typebox"; +import { + enablePluginInConfig, + resolveProviderWebSearchPluginConfig, + setProviderWebSearchPluginConfigValue, + type WebSearchProviderPlugin, +} from "openclaw/plugin-sdk/provider-web-search"; +import { runTavilySearch } from "./tavily-client.js"; + +const GenericTavilySearchSchema = Type.Object( + { + query: Type.String({ description: "Search query string." }), + count: Type.Optional( + Type.Number({ + description: "Number of results to return (1-20).", + minimum: 1, + maximum: 20, + }), + ), + }, + { additionalProperties: false }, +); + +function getScopedCredentialValue(searchConfig?: Record): unknown { + const scoped = searchConfig?.tavily; + if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) { + return undefined; + } + return (scoped as Record).apiKey; +} + +function setScopedCredentialValue( + searchConfigTarget: Record, + value: unknown, +): void { + const scoped = searchConfigTarget.tavily; + if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) { + searchConfigTarget.tavily = { apiKey: value }; + return; + } + (scoped as Record).apiKey = value; +} + +export function createTavilyWebSearchProvider(): WebSearchProviderPlugin { + return { + id: "tavily", + label: "Tavily Search", + hint: "Structured results with domain filters and AI answer summaries", + envVars: ["TAVILY_API_KEY"], + placeholder: "tvly-...", + signupUrl: "https://tavily.com/", + docsUrl: "https://docs.openclaw.ai/tools/tavily", + autoDetectOrder: 70, + credentialPath: "plugins.entries.tavily.config.webSearch.apiKey", + inactiveSecretPaths: ["plugins.entries.tavily.config.webSearch.apiKey"], + getCredentialValue: getScopedCredentialValue, + setCredentialValue: setScopedCredentialValue, + getConfiguredCredentialValue: (config) => + resolveProviderWebSearchPluginConfig(config, "tavily")?.apiKey, + setConfiguredCredentialValue: (configTarget, value) => { + setProviderWebSearchPluginConfigValue(configTarget, "tavily", "apiKey", value); + }, + applySelectionConfig: (config) => enablePluginInConfig(config, "tavily").config, + createTool: (ctx) => ({ + description: + "Search the web using Tavily. Returns structured results with snippets. Use tavily_search for Tavily-specific options like search depth, topic filtering, or AI answers.", + parameters: GenericTavilySearchSchema, + execute: async (args) => + await runTavilySearch({ + cfg: ctx.config, + query: typeof args.query === "string" ? args.query : "", + maxResults: typeof args.count === "number" ? args.count : undefined, + }), + }), + }; +} diff --git a/extensions/tavily/src/tavily-search-tool.ts b/extensions/tavily/src/tavily-search-tool.ts new file mode 100644 index 00000000000..1d925973fe0 --- /dev/null +++ b/extensions/tavily/src/tavily-search-tool.ts @@ -0,0 +1,81 @@ +import { Type } from "@sinclair/typebox"; +import { optionalStringEnum } from "openclaw/plugin-sdk/agent-runtime"; +import { jsonResult, readNumberParam, readStringParam } from "openclaw/plugin-sdk/agent-runtime"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-runtime"; +import { runTavilySearch } from "./tavily-client.js"; + +const TavilySearchToolSchema = Type.Object( + { + query: Type.String({ description: "Search query string." }), + search_depth: optionalStringEnum(["basic", "advanced"] as const, { + description: 'Search depth: "basic" (default, faster) or "advanced" (more thorough).', + }), + topic: optionalStringEnum(["general", "news", "finance"] as const, { + description: 'Search topic: "general" (default), "news", or "finance".', + }), + max_results: Type.Optional( + Type.Number({ + description: "Number of results to return (1-20).", + minimum: 1, + maximum: 20, + }), + ), + include_answer: Type.Optional( + Type.Boolean({ + description: "Include an AI-generated answer summary (default: false).", + }), + ), + time_range: optionalStringEnum(["day", "week", "month", "year"] as const, { + description: "Filter results by recency: 'day', 'week', 'month', or 'year'.", + }), + include_domains: Type.Optional( + Type.Array(Type.String(), { + description: "Only include results from these domains.", + }), + ), + exclude_domains: Type.Optional( + Type.Array(Type.String(), { + description: "Exclude results from these domains.", + }), + ), + }, + { additionalProperties: false }, +); + +export function createTavilySearchTool(api: OpenClawPluginApi) { + return { + name: "tavily_search", + label: "Tavily Search", + description: + "Search the web using Tavily Search API. Supports search depth, topic filtering, domain filters, time ranges, and AI answer summaries.", + parameters: TavilySearchToolSchema, + execute: async (_toolCallId: string, rawParams: Record) => { + const query = readStringParam(rawParams, "query", { required: true }); + const searchDepth = readStringParam(rawParams, "search_depth") || undefined; + const topic = readStringParam(rawParams, "topic") || undefined; + const maxResults = readNumberParam(rawParams, "max_results", { integer: true }); + const includeAnswer = rawParams.include_answer === true; + const timeRange = readStringParam(rawParams, "time_range") || undefined; + const includeDomains = Array.isArray(rawParams.include_domains) + ? (rawParams.include_domains as string[]).filter(Boolean) + : undefined; + const excludeDomains = Array.isArray(rawParams.exclude_domains) + ? (rawParams.exclude_domains as string[]).filter(Boolean) + : undefined; + + return jsonResult( + await runTavilySearch({ + cfg: api.config, + query, + searchDepth, + topic, + maxResults, + includeAnswer, + timeRange, + includeDomains: includeDomains?.length ? includeDomains : undefined, + excludeDomains: excludeDomains?.length ? excludeDomains : undefined, + }), + ); + }, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0d503f2346..f821a4aa3c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -519,6 +519,8 @@ importers: extensions/synthetic: {} + extensions/tavily: {} + extensions/telegram: dependencies: '@grammyjs/runner': diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 151cfc4e6c4..11955d4a9b0 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -1,123 +1,35 @@ import type { OpenClawConfig } from "../../config/config.js"; -import { normalizeResolvedSecretInputString } from "../../config/types.secrets.js"; -import { logVerbose } from "../../globals.js"; -import type { PluginWebSearchProviderEntry } from "../../plugins/types.js"; -import { resolvePluginWebSearchProviders } from "../../plugins/web-search-providers.js"; import type { RuntimeWebSearchMetadata } from "../../secrets/runtime-web-tools.types.js"; -import { normalizeSecretInput } from "../../utils/normalize-secret-input.js"; +import { + resolveWebSearchDefinition, + resolveWebSearchProviderId, +} from "../../web-search/runtime.js"; import type { AnyAgentTool } from "./common.js"; import { jsonResult } from "./common.js"; import { SEARCH_CACHE } from "./web-search-provider-common.js"; -import { - resolveSearchConfig, - resolveSearchEnabled, - type WebSearchConfig, -} from "./web-search-provider-config.js"; - -function readProviderEnvValue(envVars: string[]): string | undefined { - for (const envVar of envVars) { - const value = normalizeSecretInput(process.env[envVar]); - if (value) { - return value; - } - } - return undefined; -} - -function hasProviderCredential( - provider: PluginWebSearchProviderEntry, - search: WebSearchConfig | undefined, -): boolean { - const rawValue = provider.getCredentialValue(search as Record | undefined); - const fromConfig = normalizeSecretInput( - normalizeResolvedSecretInputString({ - value: rawValue, - path: provider.credentialPath, - }), - ); - return Boolean(fromConfig || readProviderEnvValue(provider.envVars)); -} - -function resolveSearchProvider(search?: WebSearchConfig): string { - const providers = resolvePluginWebSearchProviders({ - bundledAllowlistCompat: true, - }); - const raw = - search && "provider" in search && typeof search.provider === "string" - ? search.provider.trim().toLowerCase() - : ""; - - if (raw) { - const explicit = providers.find((provider) => provider.id === raw); - if (explicit) { - return explicit.id; - } - } - - if (!raw) { - for (const provider of providers) { - if (!hasProviderCredential(provider, search)) { - continue; - } - logVerbose( - `web_search: no provider configured, auto-detected "${provider.id}" from available API keys`, - ); - return provider.id; - } - } - - return providers[0]?.id ?? ""; -} export function createWebSearchTool(options?: { config?: OpenClawConfig; sandboxed?: boolean; runtimeWebSearch?: RuntimeWebSearchMetadata; }): AnyAgentTool | null { - const search = resolveSearchConfig(options?.config); - if (!resolveSearchEnabled({ search, sandboxed: options?.sandboxed })) { - return null; - } - - const providers = resolvePluginWebSearchProviders({ - config: options?.config, - bundledAllowlistCompat: true, - }); - if (providers.length === 0) { - return null; - } - - const providerId = - options?.runtimeWebSearch?.selectedProvider ?? - options?.runtimeWebSearch?.providerConfigured ?? - resolveSearchProvider(search); - const provider = - providers.find((entry) => entry.id === providerId) ?? - providers.find((entry) => entry.id === resolveSearchProvider(search)) ?? - providers[0]; - if (!provider) { - return null; - } - - const definition = provider.createTool({ - config: options?.config, - searchConfig: search as Record | undefined, - runtimeMetadata: options?.runtimeWebSearch, - }); - if (!definition) { + const resolved = resolveWebSearchDefinition(options); + if (!resolved) { return null; } return { label: "Web Search", name: "web_search", - description: definition.description, - parameters: definition.parameters, - execute: async (_toolCallId, args) => jsonResult(await definition.execute(args)), + description: resolved.definition.description, + parameters: resolved.definition.parameters, + execute: async (_toolCallId, args) => jsonResult(await resolved.definition.execute(args)), }; } export const __testing = { SEARCH_CACHE, - resolveSearchProvider, + resolveSearchProvider: ( + search?: NonNullable["web"]>["search"], + ) => resolveWebSearchProviderId({ search }), }; diff --git a/src/commands/onboard-search.test.ts b/src/commands/onboard-search.test.ts index 00bfd6382a6..c15fdefcf72 100644 --- a/src/commands/onboard-search.test.ts +++ b/src/commands/onboard-search.test.ts @@ -48,6 +48,15 @@ function createPerplexityConfig(apiKey: string, enabled?: boolean): OpenClawConf }; } +function pluginWebSearchApiKey(config: OpenClawConfig, pluginId: string): unknown { + const entry = ( + config.plugins?.entries as + | Record + | undefined + )?.[pluginId]; + return entry?.config?.webSearch?.apiKey; +} + async function runBlankPerplexityKeyEntry( apiKey: string, enabled?: boolean, @@ -88,8 +97,9 @@ describe("setupSearch", () => { }); const result = await setupSearch(cfg, runtime, prompter); expect(result.tools?.web?.search?.provider).toBe("perplexity"); - expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("pplx-test-key"); + expect(pluginWebSearchApiKey(result, "perplexity")).toBe("pplx-test-key"); expect(result.tools?.web?.search?.enabled).toBe(true); + expect(result.plugins?.entries?.perplexity?.enabled).toBe(true); }); it("sets provider and key for brave", async () => { @@ -101,7 +111,8 @@ describe("setupSearch", () => { const result = await setupSearch(cfg, runtime, prompter); expect(result.tools?.web?.search?.provider).toBe("brave"); expect(result.tools?.web?.search?.enabled).toBe(true); - expect(result.tools?.web?.search?.apiKey).toBe("BSA-test-key"); + expect(pluginWebSearchApiKey(result, "brave")).toBe("BSA-test-key"); + expect(result.plugins?.entries?.brave?.enabled).toBe(true); }); it("sets provider and key for gemini", async () => { @@ -113,7 +124,8 @@ describe("setupSearch", () => { const result = await setupSearch(cfg, runtime, prompter); expect(result.tools?.web?.search?.provider).toBe("gemini"); expect(result.tools?.web?.search?.enabled).toBe(true); - expect(result.tools?.web?.search?.gemini?.apiKey).toBe("AIza-test"); + expect(pluginWebSearchApiKey(result, "google")).toBe("AIza-test"); + expect(result.plugins?.entries?.google?.enabled).toBe(true); }); it("sets provider and key for firecrawl and enables the plugin", async () => { @@ -125,7 +137,7 @@ describe("setupSearch", () => { const result = await setupSearch(cfg, runtime, prompter); expect(result.tools?.web?.search?.provider).toBe("firecrawl"); expect(result.tools?.web?.search?.enabled).toBe(true); - expect(result.tools?.web?.search?.firecrawl?.apiKey).toBe("fc-test-key"); + expect(pluginWebSearchApiKey(result, "firecrawl")).toBe("fc-test-key"); expect(result.plugins?.entries?.firecrawl?.enabled).toBe(true); }); @@ -150,7 +162,21 @@ describe("setupSearch", () => { const result = await setupSearch(cfg, runtime, prompter); expect(result.tools?.web?.search?.provider).toBe("kimi"); expect(result.tools?.web?.search?.enabled).toBe(true); - expect(result.tools?.web?.search?.kimi?.apiKey).toBe("sk-moonshot"); + expect(pluginWebSearchApiKey(result, "moonshot")).toBe("sk-moonshot"); + expect(result.plugins?.entries?.moonshot?.enabled).toBe(true); + }); + + it("sets provider and key for tavily and enables the plugin", async () => { + const cfg: OpenClawConfig = {}; + const { prompter } = createPrompter({ + selectValue: "tavily", + textValue: "tvly-test-key", + }); + const result = await setupSearch(cfg, runtime, prompter); + expect(result.tools?.web?.search?.provider).toBe("tavily"); + expect(result.tools?.web?.search?.enabled).toBe(true); + expect(pluginWebSearchApiKey(result, "tavily")).toBe("tvly-test-key"); + expect(result.plugins?.entries?.tavily?.enabled).toBe(true); }); it("shows missing-key note when no key is provided and no env var", async () => { @@ -198,7 +224,7 @@ describe("setupSearch", () => { "stored-pplx-key", // pragma: allowlist secret ); expect(result.tools?.web?.search?.provider).toBe("perplexity"); - expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("stored-pplx-key"); + expect(pluginWebSearchApiKey(result, "perplexity")).toBe("stored-pplx-key"); expect(result.tools?.web?.search?.enabled).toBe(true); expect(prompter.text).not.toHaveBeenCalled(); }); @@ -209,11 +235,43 @@ describe("setupSearch", () => { false, ); expect(result.tools?.web?.search?.provider).toBe("perplexity"); - expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("stored-pplx-key"); + expect(pluginWebSearchApiKey(result, "perplexity")).toBe("stored-pplx-key"); expect(result.tools?.web?.search?.enabled).toBe(false); expect(prompter.text).not.toHaveBeenCalled(); }); + it("quickstart skips key prompt when canonical plugin config key exists", async () => { + const cfg: OpenClawConfig = { + tools: { + web: { + search: { + provider: "tavily", + }, + }, + }, + plugins: { + entries: { + tavily: { + enabled: true, + config: { + webSearch: { + apiKey: "tvly-existing-key", + }, + }, + }, + }, + }, + }; + const { prompter } = createPrompter({ selectValue: "tavily" }); + const result = await setupSearch(cfg, runtime, prompter, { + quickstartDefaults: true, + }); + expect(result.tools?.web?.search?.provider).toBe("tavily"); + expect(pluginWebSearchApiKey(result, "tavily")).toBe("tvly-existing-key"); + expect(result.tools?.web?.search?.enabled).toBe(true); + expect(prompter.text).not.toHaveBeenCalled(); + }); + it("quickstart falls through to key prompt when no key and no env var", async () => { const original = process.env.XAI_API_KEY; delete process.env.XAI_API_KEY; @@ -268,7 +326,7 @@ describe("setupSearch", () => { secretInputMode: "ref", // pragma: allowlist secret }); expect(result.tools?.web?.search?.provider).toBe("perplexity"); - expect(result.tools?.web?.search?.perplexity?.apiKey).toEqual({ + expect(pluginWebSearchApiKey(result, "perplexity")).toEqual({ source: "env", provider: "default", id: "PERPLEXITY_API_KEY", // pragma: allowlist secret @@ -299,7 +357,7 @@ describe("setupSearch", () => { const result = await setupSearch(cfg, runtime, prompter, { secretInputMode: "ref", // pragma: allowlist secret }); - expect(result.tools?.web?.search?.perplexity?.apiKey).toEqual({ + expect(pluginWebSearchApiKey(result, "perplexity")).toEqual({ source: "env", provider: "default", id: "OPENROUTER_API_KEY", // pragma: allowlist secret @@ -326,14 +384,41 @@ describe("setupSearch", () => { secretInputMode: "ref", // pragma: allowlist secret }); expect(result.tools?.web?.search?.provider).toBe("brave"); - expect(result.tools?.web?.search?.apiKey).toEqual({ + expect(pluginWebSearchApiKey(result, "brave")).toEqual({ source: "env", provider: "default", id: "BRAVE_API_KEY", }); + expect(result.plugins?.entries?.brave?.enabled).toBe(true); expect(prompter.text).not.toHaveBeenCalled(); }); + it("stores env-backed SecretRef when secretInputMode=ref for tavily", async () => { + const original = process.env.TAVILY_API_KEY; + delete process.env.TAVILY_API_KEY; + const cfg: OpenClawConfig = {}; + try { + const { prompter } = createPrompter({ selectValue: "tavily" }); + const result = await setupSearch(cfg, runtime, prompter, { + secretInputMode: "ref", // pragma: allowlist secret + }); + expect(result.tools?.web?.search?.provider).toBe("tavily"); + expect(pluginWebSearchApiKey(result, "tavily")).toEqual({ + source: "env", + provider: "default", + id: "TAVILY_API_KEY", + }); + expect(result.plugins?.entries?.tavily?.enabled).toBe(true); + expect(prompter.text).not.toHaveBeenCalled(); + } finally { + if (original === undefined) { + delete process.env.TAVILY_API_KEY; + } else { + process.env.TAVILY_API_KEY = original; + } + } + }); + it("stores plaintext key when secretInputMode is unset", async () => { const cfg: OpenClawConfig = {}; const { prompter } = createPrompter({ @@ -341,12 +426,20 @@ describe("setupSearch", () => { textValue: "BSA-plain", }); const result = await setupSearch(cfg, runtime, prompter); - expect(result.tools?.web?.search?.apiKey).toBe("BSA-plain"); + expect(pluginWebSearchApiKey(result, "brave")).toBe("BSA-plain"); }); - it("exports all 6 providers in SEARCH_PROVIDER_OPTIONS", () => { - expect(SEARCH_PROVIDER_OPTIONS).toHaveLength(6); + it("exports all 7 providers in SEARCH_PROVIDER_OPTIONS", () => { + expect(SEARCH_PROVIDER_OPTIONS).toHaveLength(7); const values = SEARCH_PROVIDER_OPTIONS.map((e) => e.value); - expect(values).toEqual(["brave", "gemini", "grok", "kimi", "perplexity", "firecrawl"]); + expect(values).toEqual([ + "brave", + "gemini", + "grok", + "kimi", + "perplexity", + "firecrawl", + "tavily", + ]); }); }); diff --git a/src/commands/onboard-search.ts b/src/commands/onboard-search.ts index 566362f9f03..0d414017c31 100644 --- a/src/commands/onboard-search.ts +++ b/src/commands/onboard-search.ts @@ -53,7 +53,10 @@ function rawKeyValue(config: OpenClawConfig, provider: SearchProvider): unknown config, bundledAllowlistCompat: true, }).find((candidate) => candidate.id === provider); - return entry?.getCredentialValue(search as Record | undefined); + return ( + entry?.getConfiguredCredentialValue?.(config) ?? + entry?.getCredentialValue(search as Record | undefined) + ); } /** Returns the plaintext key string, or undefined for SecretRefs/missing. */ @@ -104,7 +107,7 @@ export function applySearchKey( bundledAllowlistCompat: true, }).find((candidate) => candidate.id === provider); const search: MutableSearchConfig = { ...config.tools?.web?.search, provider, enabled: true }; - if (providerEntry) { + if (providerEntry && !providerEntry.setConfiguredCredentialValue) { providerEntry.setCredentialValue(search, key); } const nextBase: OpenClawConfig = { @@ -114,7 +117,9 @@ export function applySearchKey( web: { ...config.tools?.web, search }, }, }; - return providerEntry?.applySelectionConfig?.(nextBase) ?? nextBase; + const next = providerEntry?.applySelectionConfig?.(nextBase) ?? nextBase; + providerEntry?.setConfiguredCredentialValue?.(next, key); + return next; } function applyProviderOnly(config: OpenClawConfig, provider: SearchProvider): OpenClawConfig { diff --git a/src/config/config.web-search-provider.test.ts b/src/config/config.web-search-provider.test.ts index 85ce1c2700a..d89d913fcba 100644 --- a/src/config/config.web-search-provider.test.ts +++ b/src/config/config.web-search-provider.test.ts @@ -59,6 +59,13 @@ vi.mock("../plugins/web-search-providers.js", () => { getCredentialValue: getScoped("perplexity"), getConfiguredCredentialValue: getConfigured("perplexity"), }, + { + id: "tavily", + envVars: ["TAVILY_API_KEY"], + credentialPath: "plugins.entries.tavily.config.webSearch.apiKey", + getCredentialValue: getScoped("tavily"), + getConfiguredCredentialValue: getConfigured("tavily"), + }, ], }; }); @@ -66,6 +73,17 @@ vi.mock("../plugins/web-search-providers.js", () => { const { __testing } = await import("../agents/tools/web-search.js"); const { resolveSearchProvider } = __testing; +function pluginWebSearchApiKey( + config: Record | undefined, + pluginId: string, +): unknown { + return ( + config?.plugins as + | { entries?: Record } + | undefined + )?.entries?.[pluginId]?.config?.webSearch?.apiKey; +} + describe("web search provider config", () => { it("accepts perplexity provider and config", () => { const res = validateConfigObjectWithPlugins( @@ -113,6 +131,50 @@ describe("web search provider config", () => { expect(res.ok).toBe(true); }); + it("accepts tavily provider config on the plugin-owned path", () => { + const res = validateConfigObjectWithPlugins( + buildWebSearchProviderConfig({ + enabled: true, + provider: "tavily", + providerConfig: { + apiKey: { + source: "env", + provider: "default", + id: "TAVILY_API_KEY", + }, + baseUrl: "https://api.tavily.com", + }, + }), + ); + + expect(res.ok).toBe(true); + }); + + it("does not migrate the nonexistent legacy Tavily scoped config", () => { + const res = validateConfigObjectWithPlugins({ + tools: { + web: { + search: { + provider: "tavily", + tavily: { + apiKey: "tvly-test-key", + }, + }, + }, + }, + }); + + expect(res.ok).toBe(true); + if (!res.ok) { + return; + } + expect(res.config.tools?.web?.search?.provider).toBe("tavily"); + expect((res.config.tools?.web?.search as Record | undefined)?.tavily).toBe( + undefined, + ); + expect(pluginWebSearchApiKey(res.config as Record, "tavily")).toBe(undefined); + }); + it("accepts gemini provider with no extra config", () => { const res = validateConfigObjectWithPlugins( buildWebSearchProviderConfig({ @@ -161,6 +223,7 @@ describe("web search provider auto-detection", () => { delete process.env.MOONSHOT_API_KEY; delete process.env.PERPLEXITY_API_KEY; delete process.env.OPENROUTER_API_KEY; + delete process.env.TAVILY_API_KEY; delete process.env.XAI_API_KEY; delete process.env.KIMI_API_KEY; delete process.env.MOONSHOT_API_KEY; @@ -185,6 +248,11 @@ describe("web search provider auto-detection", () => { expect(resolveSearchProvider({})).toBe("gemini"); }); + it("auto-detects tavily when only TAVILY_API_KEY is set", () => { + process.env.TAVILY_API_KEY = "tvly-test-key"; // pragma: allowlist secret + expect(resolveSearchProvider({})).toBe("tavily"); + }); + it("auto-detects firecrawl when only FIRECRAWL_API_KEY is set", () => { process.env.FIRECRAWL_API_KEY = "fc-test-key"; // pragma: allowlist secret expect(resolveSearchProvider({})).toBe("firecrawl"); diff --git a/src/plugins/bundled-provider-auth-env-vars.generated.ts b/src/plugins/bundled-provider-auth-env-vars.generated.ts index 416036b28ea..80ebcedc2b9 100644 --- a/src/plugins/bundled-provider-auth-env-vars.generated.ts +++ b/src/plugins/bundled-provider-auth-env-vars.generated.ts @@ -2,10 +2,12 @@ export const BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES = { anthropic: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"], + brave: ["BRAVE_API_KEY"], byteplus: ["BYTEPLUS_API_KEY"], chutes: ["CHUTES_API_KEY", "CHUTES_OAUTH_TOKEN"], "cloudflare-ai-gateway": ["CLOUDFLARE_AI_GATEWAY_API_KEY"], fal: ["FAL_KEY"], + firecrawl: ["FIRECRAWL_API_KEY"], "github-copilot": ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"], google: ["GEMINI_API_KEY", "GOOGLE_API_KEY"], huggingface: ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"], @@ -23,10 +25,12 @@ export const BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES = { opencode: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"], "opencode-go": ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"], openrouter: ["OPENROUTER_API_KEY"], + perplexity: ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"], qianfan: ["QIANFAN_API_KEY"], "qwen-portal": ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"], sglang: ["SGLANG_API_KEY"], synthetic: ["SYNTHETIC_API_KEY"], + tavily: ["TAVILY_API_KEY"], together: ["TOGETHER_API_KEY"], venice: ["VENICE_API_KEY"], "vercel-ai-gateway": ["AI_GATEWAY_API_KEY"], diff --git a/src/plugins/bundled-provider-auth-env-vars.test.ts b/src/plugins/bundled-provider-auth-env-vars.test.ts index a41b60d7b6d..bf0d481834b 100644 --- a/src/plugins/bundled-provider-auth-env-vars.test.ts +++ b/src/plugins/bundled-provider-auth-env-vars.test.ts @@ -31,15 +31,22 @@ describe("bundled provider auth env vars", () => { }); it("reads bundled provider auth env vars from plugin manifests", () => { + expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES.brave).toEqual(["BRAVE_API_KEY"]); + expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES.firecrawl).toEqual(["FIRECRAWL_API_KEY"]); expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES["github-copilot"]).toEqual([ "COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN", ]); + expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES.perplexity).toEqual([ + "PERPLEXITY_API_KEY", + "OPENROUTER_API_KEY", + ]); expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES["qwen-portal"]).toEqual([ "QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY", ]); + expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES.tavily).toEqual(["TAVILY_API_KEY"]); expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES["minimax-portal"]).toEqual([ "MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY", diff --git a/src/plugins/bundled-web-search.test.ts b/src/plugins/bundled-web-search.test.ts index 921bd66868e..b8d5c6142ad 100644 --- a/src/plugins/bundled-web-search.test.ts +++ b/src/plugins/bundled-web-search.test.ts @@ -71,6 +71,7 @@ describe("bundled web search metadata", () => { "google", "moonshot", "perplexity", + "tavily", "xai", ]); }); diff --git a/src/plugins/bundled-web-search.ts b/src/plugins/bundled-web-search.ts index d1f2ce342f8..4b9594caaf8 100644 --- a/src/plugins/bundled-web-search.ts +++ b/src/plugins/bundled-web-search.ts @@ -191,6 +191,21 @@ const BUNDLED_WEB_SEARCH_PROVIDER_DESCRIPTORS = [ credentialScope: { kind: "scoped", key: "firecrawl" }, applySelectionConfig: (config) => enablePluginInConfig(config, "firecrawl").config, }, + { + pluginId: "tavily", + id: "tavily", + label: "Tavily Search", + hint: "Structured results with domain filters and AI answer summaries", + envVars: ["TAVILY_API_KEY"], + placeholder: "tvly-...", + signupUrl: "https://tavily.com/", + docsUrl: "https://docs.openclaw.ai/tools/tavily", + autoDetectOrder: 70, + credentialPath: "plugins.entries.tavily.config.webSearch.apiKey", + inactiveSecretPaths: ["plugins.entries.tavily.config.webSearch.apiKey"], + credentialScope: { kind: "scoped", key: "tavily" }, + applySelectionConfig: (config) => enablePluginInConfig(config, "tavily").config, + }, ] as const satisfies ReadonlyArray; export const BUNDLED_WEB_SEARCH_PLUGIN_IDS = [ diff --git a/src/plugins/contracts/registry.contract.test.ts b/src/plugins/contracts/registry.contract.test.ts index a5214106d52..f2cfd9e1392 100644 --- a/src/plugins/contracts/registry.contract.test.ts +++ b/src/plugins/contracts/registry.contract.test.ts @@ -146,6 +146,7 @@ describe("plugin contract registry", () => { expect(findWebSearchIdsForPlugin("google")).toEqual(["gemini"]); expect(findWebSearchIdsForPlugin("moonshot")).toEqual(["kimi"]); expect(findWebSearchIdsForPlugin("perplexity")).toEqual(["perplexity"]); + expect(findWebSearchIdsForPlugin("tavily")).toEqual(["tavily"]); expect(findWebSearchIdsForPlugin("xai")).toEqual(["grok"]); }); @@ -183,6 +184,14 @@ describe("plugin contract registry", () => { webSearchProviderIds: ["firecrawl"], toolNames: ["firecrawl_search", "firecrawl_scrape"], }); + expect(findRegistrationForPlugin("tavily")).toMatchObject({ + providerIds: [], + speechProviderIds: [], + mediaUnderstandingProviderIds: [], + imageGenerationProviderIds: [], + webSearchProviderIds: ["tavily"], + toolNames: ["tavily_search", "tavily_extract"], + }); }); it("tracks speech registrations on bundled provider plugins", () => { diff --git a/src/plugins/contracts/registry.ts b/src/plugins/contracts/registry.ts index 60d6f96dc3d..cde5b8e8e2d 100644 --- a/src/plugins/contracts/registry.ts +++ b/src/plugins/contracts/registry.ts @@ -29,6 +29,7 @@ import qianfanPlugin from "../../../extensions/qianfan/index.js"; import qwenPortalAuthPlugin from "../../../extensions/qwen-portal-auth/index.js"; import sglangPlugin from "../../../extensions/sglang/index.js"; import syntheticPlugin from "../../../extensions/synthetic/index.js"; +import tavilyPlugin from "../../../extensions/tavily/index.js"; import togetherPlugin from "../../../extensions/together/index.js"; import venicePlugin from "../../../extensions/venice/index.js"; import vercelAiGatewayPlugin from "../../../extensions/vercel-ai-gateway/index.js"; @@ -84,9 +85,9 @@ const bundledWebSearchPlugins: Array ({ @@ -96,6 +97,7 @@ describe("resolvePluginWebSearchProviders", () => { "moonshot:kimi", "perplexity:perplexity", "firecrawl:firecrawl", + "tavily:tavily", ]); expect(providers.map((provider) => provider.credentialPath)).toEqual([ "plugins.entries.brave.config.webSearch.apiKey", @@ -104,6 +106,7 @@ describe("resolvePluginWebSearchProviders", () => { "plugins.entries.moonshot.config.webSearch.apiKey", "plugins.entries.perplexity.config.webSearch.apiKey", "plugins.entries.firecrawl.config.webSearch.apiKey", + "plugins.entries.tavily.config.webSearch.apiKey", ]); expect(providers.find((provider) => provider.id === "firecrawl")?.applySelectionConfig).toEqual( expect.any(Function), @@ -130,6 +133,7 @@ describe("resolvePluginWebSearchProviders", () => { "moonshot", "perplexity", "firecrawl", + "tavily", ]); }); @@ -183,6 +187,7 @@ describe("resolvePluginWebSearchProviders", () => { "moonshot:kimi", "perplexity:perplexity", "firecrawl:firecrawl", + "tavily:tavily", ]); expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); }); diff --git a/src/secrets/provider-env-vars.test.ts b/src/secrets/provider-env-vars.test.ts index 6405d322e2f..63d12fd6c0e 100644 --- a/src/secrets/provider-env-vars.test.ts +++ b/src/secrets/provider-env-vars.test.ts @@ -8,10 +8,28 @@ import { describe("provider env vars", () => { it("keeps the auth scrub list broader than the global secret env list", () => { expect(listKnownProviderAuthEnvVarNames()).toEqual( - expect.arrayContaining(["GITHUB_TOKEN", "GH_TOKEN", "ANTHROPIC_OAUTH_TOKEN"]), + expect.arrayContaining([ + "GITHUB_TOKEN", + "GH_TOKEN", + "ANTHROPIC_OAUTH_TOKEN", + "BRAVE_API_KEY", + "FIRECRAWL_API_KEY", + "PERPLEXITY_API_KEY", + "OPENROUTER_API_KEY", + "TAVILY_API_KEY", + ]), ); expect(listKnownSecretEnvVarNames()).toEqual( - expect.arrayContaining(["GITHUB_TOKEN", "GH_TOKEN", "ANTHROPIC_OAUTH_TOKEN"]), + expect.arrayContaining([ + "GITHUB_TOKEN", + "GH_TOKEN", + "ANTHROPIC_OAUTH_TOKEN", + "BRAVE_API_KEY", + "FIRECRAWL_API_KEY", + "PERPLEXITY_API_KEY", + "OPENROUTER_API_KEY", + "TAVILY_API_KEY", + ]), ); expect(listKnownProviderAuthEnvVarNames()).toEqual( expect.arrayContaining(["MINIMAX_CODE_PLAN_KEY"]), diff --git a/src/secrets/target-registry-data.ts b/src/secrets/target-registry-data.ts index 30aa096004b..7d1a7854867 100644 --- a/src/secrets/target-registry-data.ts +++ b/src/secrets/target-registry-data.ts @@ -843,6 +843,17 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ includeInConfigure: true, includeInAudit: true, }, + { + id: "plugins.entries.tavily.config.webSearch.apiKey", + targetType: "plugins.entries.tavily.config.webSearch.apiKey", + configFile: "openclaw.json", + pathPattern: "plugins.entries.tavily.config.webSearch.apiKey", + secretShape: SECRET_INPUT_SHAPE, + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, ]; export { SECRET_TARGET_REGISTRY }; diff --git a/src/web-search/runtime.test.ts b/src/web-search/runtime.test.ts index 72d1e4ad3f3..ab5a59ca993 100644 --- a/src/web-search/runtime.test.ts +++ b/src/web-search/runtime.test.ts @@ -1,8 +1,15 @@ import { afterEach, describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; import { createEmptyPluginRegistry } from "../plugins/registry.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { runWebSearch } from "./runtime.js"; +type TestPluginWebSearchConfig = { + webSearch?: { + apiKey?: unknown; + }; +}; + describe("web search runtime", () => { afterEach(() => { setActivePluginRegistry(createEmptyPluginRegistry()); @@ -44,4 +51,74 @@ describe("web search runtime", () => { result: { query: "hello", ok: true }, }); }); + + it("auto-detects a provider from canonical plugin-owned credentials", async () => { + const registry = createEmptyPluginRegistry(); + registry.webSearchProviders.push({ + pluginId: "custom-search", + pluginName: "Custom Search", + provider: { + id: "custom", + label: "Custom Search", + hint: "Custom runtime provider", + envVars: ["CUSTOM_SEARCH_API_KEY"], + placeholder: "custom-...", + signupUrl: "https://example.com/signup", + credentialPath: "plugins.entries.custom-search.config.webSearch.apiKey", + autoDetectOrder: 1, + getCredentialValue: () => undefined, + setCredentialValue: () => {}, + getConfiguredCredentialValue: (config) => { + const pluginConfig = config?.plugins?.entries?.["custom-search"]?.config as + | TestPluginWebSearchConfig + | undefined; + return pluginConfig?.webSearch?.apiKey; + }, + setConfiguredCredentialValue: (configTarget, value) => { + configTarget.plugins = { + ...configTarget.plugins, + entries: { + ...configTarget.plugins?.entries, + "custom-search": { + enabled: true, + config: { webSearch: { apiKey: value } }, + }, + }, + }; + }, + createTool: () => ({ + description: "custom", + parameters: {}, + execute: async (args) => ({ ...args, ok: true }), + }), + }, + source: "test", + }); + setActivePluginRegistry(registry); + + const config: OpenClawConfig = { + plugins: { + entries: { + "custom-search": { + enabled: true, + config: { + webSearch: { + apiKey: "custom-config-key", + }, + }, + }, + }, + }, + }; + + await expect( + runWebSearch({ + config, + args: { query: "hello" }, + }), + ).resolves.toEqual({ + provider: "custom", + result: { query: "hello", ok: true }, + }); + }); });