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
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:
70
extensions/tavily/src/config.ts
Normal file
70
extensions/tavily/src/config.ts
Normal 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);
|
||||
}
|
||||
106
extensions/tavily/src/tavily-client.test.ts
Normal file
106
extensions/tavily/src/tavily-client.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
319
extensions/tavily/src/tavily-client.ts
Normal file
319
extensions/tavily/src/tavily-client.ts
Normal 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 };
|
||||
81
extensions/tavily/src/tavily-extract-tool.ts
Normal file
81
extensions/tavily/src/tavily-extract-tool.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
32
extensions/tavily/src/tavily-search-provider.ts
Normal file
32
extensions/tavily/src/tavily-search-provider.ts
Normal 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,
|
||||
}),
|
||||
});
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
90
extensions/tavily/src/tavily-search-tool.ts
Normal file
90
extensions/tavily/src/tavily-search-tool.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
16
extensions/tavily/src/tavily-tool-config.ts
Normal file
16
extensions/tavily/src/tavily-tool-config.ts
Normal 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;
|
||||
}
|
||||
2
extensions/tavily/src/tavily-tool-schema.ts
Normal file
2
extensions/tavily/src/tavily-tool-schema.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Tavily helper module supports tavily tool schema behavior.
|
||||
export { optionalStringEnum } from "openclaw/plugin-sdk/channel-actions";
|
||||
458
extensions/tavily/src/tavily-tools.test.ts
Normal file
458
extensions/tavily/src/tavily-tools.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user