mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-11 09:20:49 +08:00
重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。
同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。 Made-with: Cursor
This commit is contained in:
parent
4a23b715a2
commit
dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions
74
openclaw/extensions/tavily/src/config.ts
Normal file
74
openclaw/extensions/tavily/src/config.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
normalizeResolvedSecretInputString,
|
||||
normalizeSecretInput,
|
||||
} from "openclaw/plugin-sdk/secret-input";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-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 {
|
||||
if (typeof override === "number" && Number.isFinite(override) && override > 0) {
|
||||
return Math.floor(override);
|
||||
}
|
||||
return DEFAULT_TAVILY_SEARCH_TIMEOUT_SECONDS;
|
||||
}
|
||||
|
||||
export function resolveTavilyExtractTimeoutSeconds(override?: number): number {
|
||||
if (typeof override === "number" && Number.isFinite(override) && override > 0) {
|
||||
return Math.floor(override);
|
||||
}
|
||||
return DEFAULT_TAVILY_EXTRACT_TIMEOUT_SECONDS;
|
||||
}
|
||||
59
openclaw/extensions/tavily/src/tavily-client.test.ts
Normal file
59
openclaw/extensions/tavily/src/tavily-client.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// 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("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" });
|
||||
});
|
||||
});
|
||||
285
openclaw/extensions/tavily/src/tavily-client.ts
Normal file
285
openclaw/extensions/tavily/src/tavily-client.ts
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
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;
|
||||
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
|
||||
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 postTrustedWebToolsJson(
|
||||
{
|
||||
url: resolveEndpoint(baseUrl, "/search"),
|
||||
timeoutSeconds,
|
||||
apiKey,
|
||||
body,
|
||||
errorLabel: "Tavily Search",
|
||||
extraHeaders: { "X-Client-Source": "openclaw" },
|
||||
},
|
||||
async (response) => (await response.json()) as Record<string, unknown>,
|
||||
);
|
||||
|
||||
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 postTrustedWebToolsJson(
|
||||
{
|
||||
url: resolveEndpoint(baseUrl, "/extract"),
|
||||
timeoutSeconds,
|
||||
apiKey,
|
||||
body,
|
||||
errorLabel: "Tavily Extract",
|
||||
extraHeaders: { "X-Client-Source": "openclaw" },
|
||||
},
|
||||
async (response) => (await response.json()) as Record<string, unknown>,
|
||||
);
|
||||
|
||||
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 = {
|
||||
resolveEndpoint,
|
||||
};
|
||||
90
openclaw/extensions/tavily/src/tavily-extract-tool.ts
Normal file
90
openclaw/extensions/tavily/src/tavily-extract-tool.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { Type } from "@sinclair/typebox";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import {
|
||||
jsonResult,
|
||||
readNumberParam,
|
||||
readStringParam,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import { runTavilyExtract } from "./tavily-client.js";
|
||||
|
||||
function optionalStringEnum<const T extends readonly string[]>(
|
||||
values: T,
|
||||
options: { description?: string } = {},
|
||||
) {
|
||||
return Type.Optional(
|
||||
Type.Unsafe<T[number]>({
|
||||
type: "string",
|
||||
enum: [...values],
|
||||
...options,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const TavilyExtractToolSchema = Type.Object(
|
||||
{
|
||||
urls: Type.Array(Type.String(), {
|
||||
description: "One or more URLs to extract content from (max 20).",
|
||||
minItems: 1,
|
||||
maxItems: 20,
|
||||
}),
|
||||
query: Type.Optional(
|
||||
Type.String({
|
||||
description: "Rerank extracted chunks by relevance to this query.",
|
||||
}),
|
||||
),
|
||||
extract_depth: optionalStringEnum(["basic", "advanced"] as const, {
|
||||
description: '"basic" (default) or "advanced" (for JS-heavy pages).',
|
||||
}),
|
||||
chunks_per_source: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Chunks per URL (1-5, requires query).",
|
||||
minimum: 1,
|
||||
maximum: 5,
|
||||
}),
|
||||
),
|
||||
include_images: Type.Optional(
|
||||
Type.Boolean({
|
||||
description: "Include image URLs in extraction results.",
|
||||
}),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export function createTavilyExtractTool(api: OpenClawPluginApi) {
|
||||
return {
|
||||
name: "tavily_extract",
|
||||
label: "Tavily Extract",
|
||||
description:
|
||||
"Extract clean content from one or more URLs using Tavily. Handles JS-rendered pages. Supports query-focused chunking.",
|
||||
parameters: TavilyExtractToolSchema,
|
||||
execute: async (_toolCallId: string, rawParams: Record<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 = readNumberParam(rawParams, "chunks_per_source", {
|
||||
integer: true,
|
||||
});
|
||||
if (chunksPerSource !== undefined && !query) {
|
||||
throw new Error("tavily_extract requires query when chunks_per_source is set.");
|
||||
}
|
||||
const includeImages = rawParams.include_images === true;
|
||||
|
||||
return jsonResult(
|
||||
await runTavilyExtract({
|
||||
cfg: api.config,
|
||||
urls,
|
||||
query,
|
||||
extractDepth,
|
||||
chunksPerSource,
|
||||
includeImages,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
64
openclaw/extensions/tavily/src/tavily-search-provider.ts
Normal file
64
openclaw/extensions/tavily/src/tavily-search-provider.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import {
|
||||
createWebSearchProviderContractFields,
|
||||
type WebSearchProviderPlugin,
|
||||
} from "openclaw/plugin-sdk/provider-web-search-contract";
|
||||
|
||||
const TAVILY_CREDENTIAL_PATH = "plugins.entries.tavily.config.webSearch.apiKey";
|
||||
|
||||
type TavilyClientModule = typeof import("./tavily-client.js");
|
||||
|
||||
let tavilyClientModulePromise: Promise<TavilyClientModule> | undefined;
|
||||
|
||||
function loadTavilyClientModule(): Promise<TavilyClientModule> {
|
||||
tavilyClientModulePromise ??= import("./tavily-client.js");
|
||||
return tavilyClientModulePromise;
|
||||
}
|
||||
|
||||
const GenericTavilySearchSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Search query string." },
|
||||
count: {
|
||||
type: "number",
|
||||
description: "Number of results to return (1-20).",
|
||||
minimum: 1,
|
||||
maximum: 20,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
} satisfies Record<string, unknown>;
|
||||
|
||||
export function createTavilyWebSearchProvider(): WebSearchProviderPlugin {
|
||||
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",
|
||||
}),
|
||||
createTool: (ctx) => ({
|
||||
description:
|
||||
"Search the web using Tavily. Returns structured results with snippets. Use tavily_search for Tavily-specific options like search depth, topic filtering, or AI answers.",
|
||||
parameters: GenericTavilySearchSchema,
|
||||
execute: async (args) => {
|
||||
const { runTavilySearch } = await loadTavilyClientModule();
|
||||
return await runTavilySearch({
|
||||
cfg: ctx.config,
|
||||
query: typeof args.query === "string" ? args.query : "",
|
||||
maxResults: typeof args.count === "number" ? args.count : undefined,
|
||||
});
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
97
openclaw/extensions/tavily/src/tavily-search-tool.ts
Normal file
97
openclaw/extensions/tavily/src/tavily-search-tool.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { Type } from "@sinclair/typebox";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import {
|
||||
jsonResult,
|
||||
readNumberParam,
|
||||
readStringParam,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import { runTavilySearch } from "./tavily-client.js";
|
||||
|
||||
function optionalStringEnum<const T extends readonly string[]>(
|
||||
values: T,
|
||||
options: { description?: string } = {},
|
||||
) {
|
||||
return Type.Optional(
|
||||
Type.Unsafe<T[number]>({
|
||||
type: "string",
|
||||
enum: [...values],
|
||||
...options,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const TavilySearchToolSchema = Type.Object(
|
||||
{
|
||||
query: Type.String({ description: "Search query string." }),
|
||||
search_depth: optionalStringEnum(["basic", "advanced"] as const, {
|
||||
description: 'Search depth: "basic" (default, faster) or "advanced" (more thorough).',
|
||||
}),
|
||||
topic: optionalStringEnum(["general", "news", "finance"] as const, {
|
||||
description: 'Search topic: "general" (default), "news", or "finance".',
|
||||
}),
|
||||
max_results: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Number of results to return (1-20).",
|
||||
minimum: 1,
|
||||
maximum: 20,
|
||||
}),
|
||||
),
|
||||
include_answer: Type.Optional(
|
||||
Type.Boolean({
|
||||
description: "Include an AI-generated answer summary (default: false).",
|
||||
}),
|
||||
),
|
||||
time_range: optionalStringEnum(["day", "week", "month", "year"] as const, {
|
||||
description: "Filter results by recency: 'day', 'week', 'month', or 'year'.",
|
||||
}),
|
||||
include_domains: Type.Optional(
|
||||
Type.Array(Type.String(), {
|
||||
description: "Only include results from these domains.",
|
||||
}),
|
||||
),
|
||||
exclude_domains: Type.Optional(
|
||||
Type.Array(Type.String(), {
|
||||
description: "Exclude results from these domains.",
|
||||
}),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export function createTavilySearchTool(api: OpenClawPluginApi) {
|
||||
return {
|
||||
name: "tavily_search",
|
||||
label: "Tavily Search",
|
||||
description:
|
||||
"Search the web using Tavily Search API. Supports search depth, topic filtering, domain filters, time ranges, and AI answer summaries.",
|
||||
parameters: TavilySearchToolSchema,
|
||||
execute: async (_toolCallId: string, rawParams: Record<string, unknown>) => {
|
||||
const query = readStringParam(rawParams, "query", { required: true });
|
||||
const searchDepth = readStringParam(rawParams, "search_depth") || undefined;
|
||||
const topic = readStringParam(rawParams, "topic") || undefined;
|
||||
const maxResults = readNumberParam(rawParams, "max_results", { integer: true });
|
||||
const includeAnswer = rawParams.include_answer === true;
|
||||
const timeRange = readStringParam(rawParams, "time_range") || undefined;
|
||||
const includeDomains = Array.isArray(rawParams.include_domains)
|
||||
? (rawParams.include_domains as string[]).filter(Boolean)
|
||||
: undefined;
|
||||
const excludeDomains = Array.isArray(rawParams.exclude_domains)
|
||||
? (rawParams.exclude_domains as string[]).filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
return jsonResult(
|
||||
await runTavilySearch({
|
||||
cfg: api.config,
|
||||
query,
|
||||
searchDepth,
|
||||
topic,
|
||||
maxResults,
|
||||
includeAnswer,
|
||||
timeRange,
|
||||
includeDomains: includeDomains?.length ? includeDomains : undefined,
|
||||
excludeDomains: excludeDomains?.length ? excludeDomains : undefined,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
263
openclaw/extensions/tavily/src/tavily-tools.test.ts
Normal file
263
openclaw/extensions/tavily/src/tavily-tools.test.ts
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
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: unknown) => ({ ok: true, params })),
|
||||
}));
|
||||
|
||||
vi.mock("./tavily-client.js", () => ({
|
||||
runTavilySearch,
|
||||
runTavilyExtract,
|
||||
}));
|
||||
|
||||
function fakeApi(): OpenClawPluginApi {
|
||||
return {
|
||||
config: {},
|
||||
} as OpenClawPluginApi;
|
||||
}
|
||||
|
||||
describe("tavily tools", () => {
|
||||
let createTavilyWebSearchProvider: typeof import("./tavily-search-provider.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;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ createTavilyWebSearchProvider } = await import("./tavily-search-provider.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"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
runTavilySearch.mockReset();
|
||||
runTavilySearch.mockImplementation(async (params: Record<string, unknown>) => params);
|
||||
runTavilyExtract.mockReset();
|
||||
runTavilyExtract.mockImplementation(async (params: 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("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"],
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
details: {
|
||||
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.content[0]).toMatchObject({
|
||||
type: "text",
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
await expect(
|
||||
searchTool.execute("call-2", {
|
||||
query: "simple",
|
||||
include_domains: [""],
|
||||
exclude_domains: [],
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
details: {
|
||||
ok: true,
|
||||
params: {
|
||||
cfg: { env: "test" },
|
||||
query: "simple",
|
||||
includeAnswer: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const extractTool = createTavilyExtractTool(fakeApi());
|
||||
await extractTool.execute("id", {
|
||||
urls: ["https://example.com"],
|
||||
query: "pricing",
|
||||
chunks_per_source: 2,
|
||||
});
|
||||
|
||||
expect(runTavilyExtract).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cfg: {},
|
||||
urls: ["https://example.com"],
|
||||
query: "pricing",
|
||||
chunksPerSource: 2,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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("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)).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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue