Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
# Tavily OpenClaw plugin
Official OpenClaw plugin for Tavily.
## Install
```sh
openclaw plugins install @openclaw/tavily-plugin
```
## Docs
See `docs/tools/tavily.md` in the OpenClaw repository, or the published docs at `https://docs.openclaw.ai/tools/tavily`.

View File

@@ -0,0 +1,16 @@
// Tavily plugin entrypoint registers its OpenClaw integration.
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
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((ctx) => createTavilySearchTool(api, ctx), { name: "tavily_search" });
api.registerTool((ctx) => createTavilyExtractTool(api, ctx), { name: "tavily_extract" });
},
});

21
extensions/tavily/npm-shrinkwrap.json generated Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "@openclaw/tavily-plugin",
"version": "2026.6.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/tavily-plugin",
"version": "2026.6.11",
"dependencies": {
"typebox": "1.3.3"
}
},
"node_modules/typebox": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.3.tgz",
"integrity": "sha512-URXGUE31PJDQC+PtRMJeLdF4kmmOdFoVPikPCtV2oOIhUpNpppEdIz7W8bH8cFYPYHdDpaRvqwdegMTmHliudg==",
"license": "MIT"
}
}
}

View File

@@ -0,0 +1,52 @@
{
"id": "tavily",
"activation": {
"onStartup": false
},
"skills": ["./skills"],
"setup": {
"providers": [
{
"id": "tavily",
"envVars": ["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."
}
},
"contracts": {
"webSearchProviders": ["tavily"],
"tools": ["tavily_search", "tavily_extract"]
},
"configContracts": {
"compatibilityRuntimePaths": ["tools.web.search.apiKey"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"webSearch": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": ["string", "object"]
},
"baseUrl": {
"type": "string"
}
}
}
}
}
}

View File

@@ -0,0 +1,39 @@
{
"name": "@openclaw/tavily-plugin",
"version": "2026.6.11",
"description": "OpenClaw Tavily plugin",
"type": "module",
"dependencies": {
"typebox": "1.3.3"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
],
"install": {
"clawhubSpec": "clawhub:@openclaw/tavily-plugin",
"npmSpec": "@openclaw/tavily-plugin",
"defaultChoice": "npm",
"minHostVersion": ">=2026.6.9",
"allowInvalidConfigRecovery": true
},
"compat": {
"pluginApi": ">=2026.6.11"
},
"build": {
"openclawVersion": "2026.6.11",
"bundledDist": false
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
},
"repository": {
"type": "git",
"url": "https://github.com/openclaw/openclaw"
}
}

View File

@@ -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.

View File

@@ -0,0 +1,70 @@
// Tavily helper module supports config behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolvePositiveTimeoutSeconds } from "openclaw/plugin-sdk/provider-web-search";
import {
normalizeResolvedSecretInputString,
normalizeSecretInput,
} from "openclaw/plugin-sdk/secret-input";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
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 =
(normalizeOptionalString(search?.baseUrl) ?? "") ||
normalizeSecretInput(process.env.TAVILY_BASE_URL) ||
"";
return configured || DEFAULT_TAVILY_BASE_URL;
}
export function resolveTavilySearchTimeoutSeconds(override?: number): number {
return resolvePositiveTimeoutSeconds(override, DEFAULT_TAVILY_SEARCH_TIMEOUT_SECONDS);
}
export function resolveTavilyExtractTimeoutSeconds(override?: number): number {
return resolvePositiveTimeoutSeconds(override, DEFAULT_TAVILY_EXTRACT_TIMEOUT_SECONDS);
}

View File

@@ -0,0 +1,106 @@
// Tavily tests cover tavily client plugin behavior.
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createStreamingResponse } from "../../test-support/streaming-error-response.js";
// Capture every call to postTrustedWebToolsJson so we can assert on extraHeaders.
const postTrustedWebToolsJson = vi.fn();
vi.mock("openclaw/plugin-sdk/provider-web-search", () => ({
DEFAULT_CACHE_TTL_MINUTES: 5,
normalizeCacheKey: (k: string) => k,
postTrustedWebToolsJson,
readCache: () => undefined,
resolveCacheTtlMs: () => 300_000,
writeCache: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/security-runtime", () => ({
wrapExternalContent: (v: string) => v,
wrapWebContent: (v: string) => v,
}));
vi.mock("./config.js", () => ({
DEFAULT_TAVILY_BASE_URL: "https://api.tavily.com",
resolveTavilyApiKey: () => "test-key",
resolveTavilyBaseUrl: () => "https://api.tavily.com",
resolveTavilySearchTimeoutSeconds: () => 30,
resolveTavilyExtractTimeoutSeconds: () => 60,
}));
describe("tavily client X-Client-Source header", () => {
let runTavilySearch: typeof import("./tavily-client.js").runTavilySearch;
let runTavilyExtract: typeof import("./tavily-client.js").runTavilyExtract;
beforeAll(async () => {
({ runTavilySearch, runTavilyExtract } = await import("./tavily-client.js"));
});
beforeEach(() => {
postTrustedWebToolsJson.mockReset();
postTrustedWebToolsJson.mockImplementation(
async (_params: unknown, parse: (r: Response) => Promise<unknown>) =>
parse(Response.json({ results: [] })),
);
});
it("runTavilySearch sends X-Client-Source: openclaw", async () => {
await runTavilySearch({ query: "test query" });
expect(postTrustedWebToolsJson).toHaveBeenCalledOnce();
const params = postTrustedWebToolsJson.mock.calls[0]?.[0];
expect(params.extraHeaders).toEqual({ "X-Client-Source": "openclaw" });
});
it("runTavilySearch reports malformed JSON with a stable provider error", async () => {
postTrustedWebToolsJson.mockImplementationOnce(
async (_params: unknown, parse: (r: Response) => Promise<unknown>) =>
parse(new Response("{ nope")),
);
await expect(runTavilySearch({ query: "test query" })).rejects.toThrow(
"Tavily Search: malformed JSON response",
);
});
it("bounds successful Tavily JSON bodies before parsing", async () => {
const streamed = createStreamingResponse({
chunkCount: 32,
chunkSize: 1024 * 1024,
text: "x",
headers: { "content-type": "application/json" },
});
const jsonSpy = vi.spyOn(streamed.response, "json").mockRejectedValue(new Error("unbounded"));
postTrustedWebToolsJson.mockImplementationOnce(
async (_params: unknown, parse: (r: Response) => Promise<unknown>) =>
parse(streamed.response),
);
await expect(runTavilySearch({ query: "test query" })).rejects.toThrow(
"Tavily Search: JSON response exceeds 16777216 bytes",
);
expect(streamed.getReadCount()).toBeLessThan(32);
expect(streamed.wasCanceled()).toBe(true);
expect(jsonSpy).not.toHaveBeenCalled();
});
it("runTavilyExtract sends X-Client-Source: openclaw", async () => {
await runTavilyExtract({ urls: ["https://example.com"] });
expect(postTrustedWebToolsJson).toHaveBeenCalledOnce();
const params = postTrustedWebToolsJson.mock.calls[0]?.[0];
expect(params.extraHeaders).toEqual({ "X-Client-Source": "openclaw" });
});
it("runTavilyExtract reports malformed JSON with a stable provider error", async () => {
postTrustedWebToolsJson.mockImplementationOnce(
async (_params: unknown, parse: (r: Response) => Promise<unknown>) =>
parse(new Response("{ nope")),
);
await expect(runTavilyExtract({ urls: ["https://example.com"] })).rejects.toThrow(
"Tavily Extract: malformed JSON response",
);
});
});

View File

@@ -0,0 +1,319 @@
// Tavily plugin module implements tavily client behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import {
DEFAULT_CACHE_TTL_MINUTES,
normalizeCacheKey,
postTrustedWebToolsJson,
readCache,
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<string, unknown>; expiresAt: number; insertedAt: number }
>();
const EXTRACT_CACHE = new Map<
string,
{ value: Record<string, unknown>; expiresAt: number; insertedAt: number }
>();
const DEFAULT_SEARCH_COUNT = 5;
const TAVILY_EXTRACT_RESPONSE_MAX_BYTES = 64 * 1024 * 1024;
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: "/extract" | "/search";
timeoutSeconds: number;
apiKey: string;
body: Record<string, unknown>;
errorLabel: string;
responseMaxBytes?: number;
}): Promise<Record<string, unknown>> {
return postTrustedWebToolsJson(
{
url: resolveEndpoint(params.baseUrl, params.pathname),
timeoutSeconds: params.timeoutSeconds,
apiKey: params.apiKey,
body: params.body,
errorLabel: params.errorLabel,
extraHeaders: { "X-Client-Source": "openclaw" },
},
async (response) =>
readTavilyJsonResponse(response, params.errorLabel, {
maxBytes: params.responseMaxBytes,
}),
);
}
async function readTavilyJsonResponse(
response: Response,
label: string,
opts?: { maxBytes?: number },
): Promise<Record<string, unknown>> {
return await readProviderJsonResponse<Record<string, unknown>>(response, label, opts);
}
export async function runTavilySearch(
params: TavilySearchParams,
): Promise<Record<string, unknown>> {
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<string, unknown> = {
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",
timeoutSeconds,
apiKey,
body,
errorLabel: "Tavily Search",
});
const rawResults = Array.isArray(payload.results) ? payload.results : [];
const results = rawResults.map((r: Record<string, unknown>) =>
Object.assign(
{
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<string, unknown> = {
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<Record<string, unknown>> {
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<string, unknown> = { 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",
timeoutSeconds,
apiKey,
body,
errorLabel: "Tavily Extract",
// Extract can include raw page content and image lists, unlike search metadata.
responseMaxBytes: TAVILY_EXTRACT_RESPONSE_MAX_BYTES,
});
const rawResults = Array.isArray(payload.results) ? payload.results : [];
const results = rawResults.map((r: Record<string, unknown>) =>
Object.assign(
{
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(img, { source: `web_fetch`, includeWarning: false }),
),
}
: {},
),
);
const failedResults = Array.isArray(payload.failed_results) ? payload.failed_results : [];
const result: Record<string, unknown> = {
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 = {
readTavilyJsonResponse,
resolveEndpoint,
};
export { testing as __testing };

View File

@@ -0,0 +1,81 @@
// Tavily plugin module implements tavily extract tool behavior.
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-runtime";
import {
jsonResult,
readPositiveIntegerParam,
readStringParam,
} from "openclaw/plugin-sdk/provider-web-search";
import { Type } from "typebox";
import { runTavilyExtract } from "./tavily-client.js";
import { resolveTavilyToolConfig, type TavilyToolConfigContext } from "./tavily-tool-config.js";
import { optionalStringEnum } from "./tavily-tool-schema.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.Integer({
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, ctx?: TavilyToolConfigContext) {
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<string, unknown>) => {
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 = readPositiveIntegerParam(rawParams, "chunks_per_source", {
max: 5,
message: "chunks_per_source must be an integer from 1 to 5.",
});
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: resolveTavilyToolConfig(api, ctx),
urls,
query,
extractDepth,
chunksPerSource,
includeImages,
}),
);
},
};
}

View File

@@ -0,0 +1,32 @@
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
// Tavily provider module implements model/runtime integration.
import { readPositiveIntegerParam } from "openclaw/plugin-sdk/param-readers";
import type { WebSearchProviderPlugin } from "openclaw/plugin-sdk/provider-web-search-contract";
import {
buildTavilyWebSearchProviderBase,
TAVILY_GENERIC_SEARCH_DESCRIPTION,
TAVILY_GENERIC_SEARCH_SCHEMA,
} from "../web-search-shared.js";
const loadTavilyClientModule = createLazyRuntimeModule(() => import("./tavily-client.js"));
export function createTavilyWebSearchProvider(): WebSearchProviderPlugin {
return {
...buildTavilyWebSearchProviderBase(),
createTool: (ctx) => ({
description: TAVILY_GENERIC_SEARCH_DESCRIPTION,
parameters: TAVILY_GENERIC_SEARCH_SCHEMA,
execute: async (args) => {
const { runTavilySearch } = await loadTavilyClientModule();
return await runTavilySearch({
cfg: ctx.config,
query: typeof args.query === "string" ? args.query : "",
maxResults: readPositiveIntegerParam(args, "count", {
message: "count must be an integer from 1 to 20",
max: 20,
}),
});
},
}),
};
}

View File

@@ -0,0 +1,90 @@
// Tavily plugin module implements tavily search tool behavior.
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-runtime";
import {
jsonResult,
readPositiveIntegerParam,
readStringParam,
} from "openclaw/plugin-sdk/provider-web-search";
import { Type } from "typebox";
import { runTavilySearch } from "./tavily-client.js";
import { resolveTavilyToolConfig, type TavilyToolConfigContext } from "./tavily-tool-config.js";
import { optionalStringEnum } from "./tavily-tool-schema.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.Integer({
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, ctx?: TavilyToolConfigContext) {
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<string, unknown>) => {
const query = readStringParam(rawParams, "query", { required: true });
const searchDepth = readStringParam(rawParams, "search_depth") || undefined;
const topic = readStringParam(rawParams, "topic") || undefined;
const maxResults = readPositiveIntegerParam(rawParams, "max_results", {
max: 20,
message: "max_results must be an integer from 1 to 20.",
});
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: resolveTavilyToolConfig(api, ctx),
query,
searchDepth,
topic,
maxResults,
includeAnswer,
timeRange,
includeDomains: includeDomains?.length ? includeDomains : undefined,
excludeDomains: excludeDomains?.length ? excludeDomains : undefined,
}),
);
},
};
}

View File

@@ -0,0 +1,16 @@
// Tavily helper module supports tavily tool config behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/plugin-entry";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-runtime";
export type TavilyToolConfigContext = Pick<
OpenClawPluginToolContext,
"config" | "runtimeConfig" | "getRuntimeConfig"
>;
export function resolveTavilyToolConfig(
api: OpenClawPluginApi,
ctx?: TavilyToolConfigContext,
): OpenClawConfig {
return ctx?.getRuntimeConfig?.() ?? ctx?.runtimeConfig ?? ctx?.config ?? api.config;
}

View File

@@ -0,0 +1,2 @@
// Tavily helper module supports tavily tool schema behavior.
export { optionalStringEnum } from "openclaw/plugin-sdk/channel-actions";

View File

@@ -0,0 +1,458 @@
// Tavily tests cover tavily tools plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-runtime";
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
DEFAULT_TAVILY_BASE_URL,
DEFAULT_TAVILY_EXTRACT_TIMEOUT_SECONDS,
DEFAULT_TAVILY_SEARCH_TIMEOUT_SECONDS,
resolveTavilyApiKey,
resolveTavilyBaseUrl,
resolveTavilyExtractTimeoutSeconds,
resolveTavilySearchConfig,
resolveTavilySearchTimeoutSeconds,
} from "./config.js";
const { runTavilySearch, runTavilyExtract } = vi.hoisted(() => ({
runTavilySearch: vi.fn(async (params: Record<string, unknown>) => params),
runTavilyExtract: vi.fn(async (params: Record<string, unknown>) => ({ ok: true, params })),
}));
type TavilyExtractParams = {
cfg?: unknown;
urls?: string[];
query?: string;
chunksPerSource?: number;
};
vi.mock("./tavily-client.js", () => ({
runTavilySearch,
runTavilyExtract,
}));
function requireFirstMockArg(mock: ReturnType<typeof vi.fn>, label: string): unknown {
const [call] = mock.mock.calls;
if (!call) {
throw new Error(`Expected ${label}`);
}
return call[0];
}
function fakeApi(): OpenClawPluginApi {
return {
config: {},
} as OpenClawPluginApi;
}
describe("tavily tools", () => {
let createTavilyWebSearchProvider: typeof import("./tavily-search-provider.js").createTavilyWebSearchProvider;
let createTavilyContractWebSearchProvider: typeof import("../web-search-contract-api.js").createTavilyWebSearchProvider;
let createTavilySearchTool: typeof import("./tavily-search-tool.js").createTavilySearchTool;
let createTavilyExtractTool: typeof import("./tavily-extract-tool.js").createTavilyExtractTool;
let tavilyClientTesting: typeof import("./tavily-client.js").testing;
let tavilyPlugin: typeof import("../index.js").default;
beforeAll(async () => {
({ createTavilyWebSearchProvider } = await import("./tavily-search-provider.js"));
({ createTavilyWebSearchProvider: createTavilyContractWebSearchProvider } =
await import("../web-search-contract-api.js"));
({ createTavilySearchTool } = await import("./tavily-search-tool.js"));
({ createTavilyExtractTool } = await import("./tavily-extract-tool.js"));
({ testing: tavilyClientTesting } =
await vi.importActual<typeof import("./tavily-client.js")>("./tavily-client.js"));
({ default: tavilyPlugin } = await import("../index.js"));
});
beforeEach(() => {
runTavilySearch.mockReset();
runTavilySearch.mockImplementation(async (params: Record<string, unknown>) => params);
runTavilyExtract.mockReset();
runTavilyExtract.mockImplementation(async (params: Record<string, unknown>) => ({
ok: true,
params,
}));
vi.unstubAllEnvs();
});
it("exposes the expected metadata and selection wiring", () => {
const provider = createTavilyWebSearchProvider();
if (!provider.applySelectionConfig) {
throw new Error("Expected applySelectionConfig to be defined");
}
const applied = provider.applySelectionConfig({});
expect(provider.id).toBe("tavily");
expect(provider.credentialPath).toBe("plugins.entries.tavily.config.webSearch.apiKey");
expect(applied.plugins?.entries?.tavily?.enabled).toBe(true);
});
it("maps generic provider args into Tavily search params", async () => {
const provider = createTavilyWebSearchProvider();
const tool = provider.createTool({
config: { test: true },
} as never);
if (!tool) {
throw new Error("Expected tool definition");
}
const result = await tool.execute({
query: "weather sf",
count: 7,
});
expect(runTavilySearch).toHaveBeenCalledWith({
cfg: { test: true },
query: "weather sf",
maxResults: 7,
});
expect(result).toEqual({
cfg: { test: true },
query: "weather sf",
maxResults: 7,
});
});
it("keeps the contract web search provider executable", async () => {
const provider = createTavilyContractWebSearchProvider();
const tool = provider.createTool({
config: { test: "contract" },
} as never);
if (!tool) {
throw new Error("Expected contract provider tool definition");
}
const result = await tool.execute({
query: "runtime registration",
count: 3,
});
expect(runTavilySearch).toHaveBeenCalledWith({
cfg: { test: "contract" },
query: "runtime registration",
maxResults: 3,
});
expect(result).toEqual({
cfg: { test: "contract" },
query: "runtime registration",
maxResults: 3,
});
});
it("normalizes generic Tavily search count before dispatch", async () => {
const provider = createTavilyWebSearchProvider();
const tool = provider.createTool({
config: { test: true },
} as never);
if (!tool) {
throw new Error("Expected tool definition");
}
await tool.execute({
query: "weather sf",
count: "7",
});
expect(runTavilySearch).toHaveBeenCalledWith({
cfg: { test: true },
query: "weather sf",
maxResults: 7,
});
await expect(
tool.execute({
query: "weather sf",
count: "7.5",
}),
).rejects.toThrow("count must be an integer from 1 to 20");
});
it("normalizes optional parameters before invoking Tavily", async () => {
runTavilySearch.mockImplementationOnce(async (params: Record<string, unknown>) => ({
ok: true,
params,
}));
const tool = createTavilySearchTool({
config: { env: "test" },
} as never);
const result = await tool.execute("call-1", {
query: "best docs",
search_depth: "advanced",
topic: "news",
max_results: 5,
include_answer: true,
time_range: "week",
include_domains: ["docs.openclaw.ai", "", "openclaw.ai"],
exclude_domains: ["bad.example", ""],
});
expect(runTavilySearch).toHaveBeenCalledWith({
cfg: { env: "test" },
query: "best docs",
searchDepth: "advanced",
topic: "news",
maxResults: 5,
includeAnswer: true,
timeRange: "week",
includeDomains: ["docs.openclaw.ai", "openclaw.ai"],
excludeDomains: ["bad.example"],
});
const expectedResult = {
ok: true,
params: {
cfg: { env: "test" },
query: "best docs",
searchDepth: "advanced",
topic: "news",
maxResults: 5,
includeAnswer: true,
timeRange: "week",
includeDomains: ["docs.openclaw.ai", "openclaw.ai"],
excludeDomains: ["bad.example"],
},
};
expect(result).toEqual({
content: [{ type: "text", text: JSON.stringify(expectedResult, null, 2) }],
details: expectedResult,
});
});
it("late-binds dedicated tools to the resolved runtime config snapshot", async () => {
const rawConfig = {
plugins: {
entries: {
tavily: {
config: {
webSearch: {
apiKey: { source: "exec", provider: "default", id: "printf resolved-key" },
},
},
},
},
},
} as OpenClawConfig;
const runtimeConfig = {
plugins: {
entries: {
tavily: {
config: {
webSearch: {
apiKey: "resolved-key",
},
},
},
},
},
} as OpenClawConfig;
const registeredTools: Array<Parameters<OpenClawPluginApi["registerTool"]>[0]> = [];
const registeredOptions: Array<Parameters<OpenClawPluginApi["registerTool"]>[1]> = [];
const api = createTestPluginApi({
config: rawConfig,
registerTool(tool, opts) {
registeredTools.push(tool);
registeredOptions.push(opts);
},
});
tavilyPlugin.register(api);
const searchFactory = registeredTools.find(
(tool, index) =>
registeredOptions[index]?.name === "tavily_search" && typeof tool === "function",
);
const extractFactory = registeredTools.find(
(tool, index) =>
registeredOptions[index]?.name === "tavily_extract" && typeof tool === "function",
);
if (typeof searchFactory !== "function" || typeof extractFactory !== "function") {
throw new Error("Expected Tavily tools to register as runtime-context factories");
}
const searchTool = searchFactory({
config: rawConfig,
runtimeConfig,
});
const extractTool = extractFactory({
config: rawConfig,
getRuntimeConfig: () => runtimeConfig,
});
if (Array.isArray(searchTool) || !searchTool || Array.isArray(extractTool) || !extractTool) {
throw new Error("Expected single Tavily tool definitions");
}
await searchTool.execute("search-call", { query: "openclaw" });
await extractTool.execute("extract-call", { urls: ["https://example.com"] });
const searchParams = requireFirstMockArg(runTavilySearch, "Tavily search params") as Record<
string,
unknown
>;
expect(searchParams.cfg).toBe(runtimeConfig);
expect(searchParams.query).toBe("openclaw");
const extractParams = requireFirstMockArg(
runTavilyExtract,
"Tavily extract params",
) as TavilyExtractParams;
expect(extractParams.cfg).toBe(runtimeConfig);
expect(extractParams.urls).toEqual(["https://example.com"]);
});
it("drops empty domain arrays and forwards query-scoped chunking", async () => {
runTavilySearch.mockImplementationOnce(async (params: Record<string, unknown>) => ({
ok: true,
params,
}));
const searchTool = createTavilySearchTool({
config: { env: "test" },
} as never);
const expectedResult = {
ok: true,
params: {
cfg: { env: "test" },
query: "simple",
includeAnswer: false,
},
};
await expect(
searchTool.execute("call-2", {
query: "simple",
include_domains: [""],
exclude_domains: [],
}),
).resolves.toEqual({
content: [{ type: "text", text: JSON.stringify(expectedResult, null, 2) }],
details: expectedResult,
});
const extractTool = createTavilyExtractTool(fakeApi());
await extractTool.execute("id", {
urls: ["https://example.com"],
query: "pricing",
chunks_per_source: 2,
});
const extractParams = requireFirstMockArg(
runTavilyExtract,
"Tavily extract params",
) as TavilyExtractParams;
expect(extractParams.cfg).toEqual({});
expect(extractParams.urls).toEqual(["https://example.com"]);
expect(extractParams.query).toBe("pricing");
expect(extractParams.chunksPerSource).toBe(2);
});
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("rejects fractional and out-of-range integer options before Tavily calls", async () => {
const searchTool = createTavilySearchTool(fakeApi());
await expect(
searchTool.execute("search-call", {
query: "openclaw",
max_results: 5.5,
}),
).rejects.toThrow("max_results must be an integer from 1 to 20.");
await expect(
searchTool.execute("search-call", {
query: "openclaw",
max_results: 21,
}),
).rejects.toThrow("max_results must be an integer from 1 to 20.");
const extractTool = createTavilyExtractTool(fakeApi());
await expect(
extractTool.execute("extract-call", {
urls: ["https://example.com"],
query: "pricing",
chunks_per_source: 2.5,
}),
).rejects.toThrow("chunks_per_source must be an integer from 1 to 5.");
await expect(
extractTool.execute("extract-call", {
urls: ["https://example.com"],
query: "pricing",
chunks_per_source: 6,
}),
).rejects.toThrow("chunks_per_source must be an integer from 1 to 5.");
expect(runTavilySearch).not.toHaveBeenCalled();
expect(runTavilyExtract).not.toHaveBeenCalled();
});
it("reads plugin web search config and prefers it over env defaults", () => {
vi.stubEnv("TAVILY_API_KEY", "env-key");
vi.stubEnv("TAVILY_BASE_URL", "https://env.tavily.test");
const cfg = {
plugins: {
entries: {
tavily: {
config: {
webSearch: {
apiKey: "plugin-key",
baseUrl: "https://plugin.tavily.test",
},
},
},
},
},
} as OpenClawConfig;
expect(resolveTavilySearchConfig(cfg)).toEqual({
apiKey: "plugin-key",
baseUrl: "https://plugin.tavily.test",
});
expect(resolveTavilyApiKey(cfg)).toBe("plugin-key");
expect(resolveTavilyBaseUrl(cfg)).toBe("https://plugin.tavily.test");
});
it("falls back to environment values and defaults", () => {
vi.stubEnv("TAVILY_API_KEY", "env-key");
vi.stubEnv("TAVILY_BASE_URL", "https://env.tavily.test");
expect(resolveTavilyApiKey()).toBe("env-key");
expect(resolveTavilyBaseUrl()).toBe("https://env.tavily.test");
expect(resolveTavilyBaseUrl({} as OpenClawConfig)).not.toBe(DEFAULT_TAVILY_BASE_URL);
expect(resolveTavilySearchTimeoutSeconds()).toBe(DEFAULT_TAVILY_SEARCH_TIMEOUT_SECONDS);
expect(resolveTavilyExtractTimeoutSeconds()).toBe(DEFAULT_TAVILY_EXTRACT_TIMEOUT_SECONDS);
});
it("accepts positive numeric timeout overrides and floors them", () => {
expect(resolveTavilySearchTimeoutSeconds(19.9)).toBe(19);
expect(resolveTavilyExtractTimeoutSeconds(42.7)).toBe(42);
expect(resolveTavilySearchTimeoutSeconds(0.5)).toBe(1);
expect(resolveTavilyExtractTimeoutSeconds(0.5)).toBe(1);
expect(resolveTavilySearchTimeoutSeconds(0)).toBe(DEFAULT_TAVILY_SEARCH_TIMEOUT_SECONDS);
expect(resolveTavilyExtractTimeoutSeconds(Number.NaN)).toBe(
DEFAULT_TAVILY_EXTRACT_TIMEOUT_SECONDS,
);
});
it("appends endpoints to reverse-proxy base urls", () => {
expect(tavilyClientTesting.resolveEndpoint("https://proxy.example/api/tavily", "/search")).toBe(
"https://proxy.example/api/tavily/search",
);
expect(
tavilyClientTesting.resolveEndpoint("https://proxy.example/api/tavily/", "/extract"),
).toBe("https://proxy.example/api/tavily/extract");
});
it("falls back to the default host for invalid base urls", () => {
expect(tavilyClientTesting.resolveEndpoint("not a url", "/search")).toBe(
"https://api.tavily.com/search",
);
expect(tavilyClientTesting.resolveEndpoint("", "/extract")).toBe(
"https://api.tavily.com/extract",
);
});
});

View File

@@ -0,0 +1,16 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}

View File

@@ -0,0 +1,31 @@
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
// Tavily API module exposes the plugin public contract.
import type { WebSearchProviderPlugin } from "openclaw/plugin-sdk/provider-web-search-config-contract";
import {
buildTavilyWebSearchProviderBase,
TAVILY_GENERIC_SEARCH_DESCRIPTION,
TAVILY_GENERIC_SEARCH_SCHEMA,
} from "./web-search-shared.js";
const loadTavilySearchProviderModule = createLazyRuntimeModule(
() => import("./src/tavily-search-provider.js"),
);
export function createTavilyWebSearchProvider(): WebSearchProviderPlugin {
return {
...buildTavilyWebSearchProviderBase(),
createTool: (ctx) => ({
description: TAVILY_GENERIC_SEARCH_DESCRIPTION,
parameters: TAVILY_GENERIC_SEARCH_SCHEMA,
execute: async (args) => {
const { createTavilyWebSearchProvider: createRuntimeProvider } =
await loadTavilySearchProviderModule();
const tool = createRuntimeProvider().createTool(ctx);
if (!tool) {
throw new Error("Tavily web_search provider did not create a runtime tool.");
}
return await tool.execute(args);
},
}),
};
}

View File

@@ -0,0 +1,2 @@
// Tavily provider module implements model/runtime integration.
export { createTavilyWebSearchProvider } from "./src/tavily-search-provider.js";

View File

@@ -0,0 +1,46 @@
// Tavily plugin module implements web search shared behavior.
import {
createWebSearchProviderContractFields,
type WebSearchProviderPlugin,
} from "openclaw/plugin-sdk/provider-web-search-contract";
export const TAVILY_CREDENTIAL_PATH = "plugins.entries.tavily.config.webSearch.apiKey";
export const TAVILY_GENERIC_SEARCH_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.";
export const TAVILY_GENERIC_SEARCH_SCHEMA = {
type: "object",
properties: {
query: { type: "string", description: "Search query string." },
count: {
type: "integer",
description: "Number of results to return (1-20).",
minimum: 1,
maximum: 20,
},
},
additionalProperties: false,
} satisfies Record<string, unknown>;
export function buildTavilyWebSearchProviderBase(): Omit<WebSearchProviderPlugin, "createTool"> {
return {
id: "tavily",
label: "Tavily Search",
hint: "Structured results with domain filters and AI answer summaries",
onboardingScopes: ["text-inference"],
credentialLabel: "Tavily API key",
envVars: ["TAVILY_API_KEY"],
placeholder: "tvly-...",
signupUrl: "https://tavily.com/",
docsUrl: "https://docs.openclaw.ai/tools/tavily",
autoDetectOrder: 70,
credentialPath: TAVILY_CREDENTIAL_PATH,
...createWebSearchProviderContractFields({
credentialPath: TAVILY_CREDENTIAL_PATH,
searchCredential: { type: "scoped", scopeId: "tavily" },
configuredCredential: { pluginId: "tavily" },
selectionPluginId: "tavily",
}),
};
}