diff --git a/extensions/exa/src/exa-web-search-provider.runtime.ts b/extensions/exa/src/exa-web-search-provider.runtime.ts new file mode 100644 index 00000000000..84b1f2c0e7a --- /dev/null +++ b/extensions/exa/src/exa-web-search-provider.runtime.ts @@ -0,0 +1,525 @@ +import { + buildSearchCacheKey, + DEFAULT_SEARCH_COUNT, + mergeScopedSearchConfig, + parseIsoDateRange, + readCachedSearchPayload, + readConfiguredSecretString, + readNumberParam, + readProviderEnvValue, + readStringParam, + resolveProviderWebSearchPluginConfig, + resolveSearchCacheTtlMs, + resolveSearchTimeoutSeconds, + resolveSiteName, + type SearchConfigRecord, + withTrustedWebSearchEndpoint, + wrapWebContent, + writeCachedSearchPayload, +} from "openclaw/plugin-sdk/provider-web-search"; +import { + normalizeOptionalLowercaseString, + normalizeOptionalString, +} from "openclaw/plugin-sdk/text-runtime"; + +const EXA_SEARCH_ENDPOINT = "https://api.exa.ai/search"; +const EXA_SEARCH_TYPES = ["auto", "neural", "fast", "deep", "deep-reasoning", "instant"] as const; +const EXA_FRESHNESS_VALUES = ["day", "week", "month", "year"] as const; +const EXA_MAX_SEARCH_COUNT = 100; + +type ExaConfig = { + apiKey?: string; +}; + +type ExaSearchType = (typeof EXA_SEARCH_TYPES)[number]; +type ExaFreshness = (typeof EXA_FRESHNESS_VALUES)[number]; + +type ExaTextContentsOption = boolean | { maxCharacters?: number }; +type ExaHighlightsContentsOption = + | boolean + | { + maxCharacters?: number; + query?: string; + numSentences?: number; + highlightsPerUrl?: number; + }; +type ExaSummaryContentsOption = boolean | { query?: string }; + +type ExaContentsArgs = { + highlights?: ExaHighlightsContentsOption; + text?: ExaTextContentsOption; + summary?: ExaSummaryContentsOption; +}; + +type ExaSearchResult = { + title?: unknown; + url?: unknown; + publishedDate?: unknown; + highlights?: unknown; + highlightScores?: unknown; + summary?: unknown; + text?: unknown; +}; + +type ExaSearchResponse = { + results?: unknown; +}; + +function normalizeExaFreshness(value: string | undefined): ExaFreshness | undefined { + const trimmed = normalizeOptionalLowercaseString(value); + if (!trimmed) { + return undefined; + } + return EXA_FRESHNESS_VALUES.includes(trimmed as ExaFreshness) + ? (trimmed as ExaFreshness) + : undefined; +} + +function resolveExaConfig(searchConfig?: SearchConfigRecord): ExaConfig { + const exa = searchConfig?.exa; + return exa && typeof exa === "object" && !Array.isArray(exa) ? (exa as ExaConfig) : {}; +} + +function resolveExaApiKey(exa?: ExaConfig): string | undefined { + return ( + readConfiguredSecretString(exa?.apiKey, "tools.web.search.exa.apiKey") ?? + readProviderEnvValue(["EXA_API_KEY"]) + ); +} + +function resolveExaDescription(result: ExaSearchResult): string { + const highlights = result.highlights; + if (Array.isArray(highlights)) { + const highlightText = highlights + .map((entry) => normalizeOptionalString(entry)) + .filter((entry): entry is string => Boolean(entry)) + .join("\n"); + if (highlightText) { + return highlightText; + } + } + const summary = normalizeOptionalString(result.summary); + if (summary) { + return summary; + } + return normalizeOptionalString(result.text) ?? ""; +} + +function parsePositiveInteger(value: unknown): number | undefined { + return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined; +} + +function invalidContentsPayload(message: string) { + return { + error: "invalid_contents", + message, + docs: "https://docs.openclaw.ai/tools/web", + }; +} + +function isErrorPayload(value: unknown): value is { error: string; message: string; docs: string } { + return Boolean( + value && typeof value === "object" && "error" in value && "message" in value && "docs" in value, + ); +} + +function resolveExaSearchCount(value: unknown, fallback: number): number { + const parsed = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(parsed)) { + return fallback; + } + return Math.max(1, Math.min(EXA_MAX_SEARCH_COUNT, Math.floor(parsed))); +} + +function parseExaContents( + rawContents: unknown, +): { value?: ExaContentsArgs } | { error: string; message: string; docs: string } { + if (rawContents === undefined) { + return { value: undefined }; + } + if (!rawContents || typeof rawContents !== "object" || Array.isArray(rawContents)) { + return invalidContentsPayload( + "contents must be an object with optional text, highlights, and summary fields.", + ); + } + + const raw = rawContents as Record; + const allowedKeys = new Set(["text", "highlights", "summary"]); + for (const key of Object.keys(raw)) { + if (!allowedKeys.has(key)) { + return invalidContentsPayload( + `contents has unknown field "${key}". Only "text", "highlights", and "summary" are allowed.`, + ); + } + } + + const parsed: ExaContentsArgs = {}; + + const parseText = ( + value: unknown, + ): ExaTextContentsOption | { error: string; message: string; docs: string } => { + if (typeof value === "boolean") { + return value; + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + return invalidContentsPayload("contents.text must be a boolean or an object."); + } + const obj = value as Record; + for (const key of Object.keys(obj)) { + if (key !== "maxCharacters") { + return invalidContentsPayload( + `contents.text has unknown field "${key}". Only "maxCharacters" is allowed.`, + ); + } + } + if ("maxCharacters" in obj && parsePositiveInteger(obj.maxCharacters) === undefined) { + return invalidContentsPayload("contents.text.maxCharacters must be a positive integer."); + } + return parsePositiveInteger(obj.maxCharacters) + ? { maxCharacters: parsePositiveInteger(obj.maxCharacters) } + : {}; + }; + + const parseHighlights = ( + value: unknown, + ): ExaHighlightsContentsOption | { error: string; message: string; docs: string } => { + if (typeof value === "boolean") { + return value; + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + return invalidContentsPayload("contents.highlights must be a boolean or an object."); + } + const obj = value as Record; + const allowed = new Set(["maxCharacters", "query", "numSentences", "highlightsPerUrl"]); + for (const key of Object.keys(obj)) { + if (!allowed.has(key)) { + return invalidContentsPayload( + `contents.highlights has unknown field "${key}". Allowed fields are "maxCharacters", "query", "numSentences", and "highlightsPerUrl".`, + ); + } + } + if ("maxCharacters" in obj && parsePositiveInteger(obj.maxCharacters) === undefined) { + return invalidContentsPayload( + "contents.highlights.maxCharacters must be a positive integer.", + ); + } + if ("numSentences" in obj && parsePositiveInteger(obj.numSentences) === undefined) { + return invalidContentsPayload("contents.highlights.numSentences must be a positive integer."); + } + if ("highlightsPerUrl" in obj && parsePositiveInteger(obj.highlightsPerUrl) === undefined) { + return invalidContentsPayload( + "contents.highlights.highlightsPerUrl must be a positive integer.", + ); + } + if ("query" in obj && typeof obj.query !== "string") { + return invalidContentsPayload("contents.highlights.query must be a string."); + } + return { + ...(parsePositiveInteger(obj.maxCharacters) + ? { maxCharacters: parsePositiveInteger(obj.maxCharacters) } + : {}), + ...(typeof obj.query === "string" ? { query: obj.query } : {}), + ...(parsePositiveInteger(obj.numSentences) + ? { numSentences: parsePositiveInteger(obj.numSentences) } + : {}), + ...(parsePositiveInteger(obj.highlightsPerUrl) + ? { highlightsPerUrl: parsePositiveInteger(obj.highlightsPerUrl) } + : {}), + }; + }; + + const parseSummary = ( + value: unknown, + ): ExaSummaryContentsOption | { error: string; message: string; docs: string } => { + if (typeof value === "boolean") { + return value; + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + return invalidContentsPayload("contents.summary must be a boolean or an object."); + } + const obj = value as Record; + for (const key of Object.keys(obj)) { + if (key !== "query") { + return invalidContentsPayload( + `contents.summary has unknown field "${key}". Only "query" is allowed.`, + ); + } + } + if ("query" in obj && typeof obj.query !== "string") { + return invalidContentsPayload("contents.summary.query must be a string."); + } + return typeof obj.query === "string" ? { query: obj.query } : {}; + }; + + if ("text" in raw) { + const parsedText = parseText(raw.text); + if (isErrorPayload(parsedText)) { + return parsedText; + } + parsed.text = parsedText; + } + if ("highlights" in raw) { + const parsedHighlights = parseHighlights(raw.highlights); + if (isErrorPayload(parsedHighlights)) { + return parsedHighlights; + } + parsed.highlights = parsedHighlights; + } + if ("summary" in raw) { + const parsedSummary = parseSummary(raw.summary); + if (isErrorPayload(parsedSummary)) { + return parsedSummary; + } + parsed.summary = parsedSummary; + } + + return { value: parsed }; +} + +function normalizeExaResults(payload: unknown): ExaSearchResult[] { + if (!payload || typeof payload !== "object") { + return []; + } + const results = (payload as ExaSearchResponse).results; + if (!Array.isArray(results)) { + return []; + } + return results.filter((entry): entry is ExaSearchResult => + Boolean(entry && typeof entry === "object" && !Array.isArray(entry)), + ); +} + +function resolveFreshnessStartDate(freshness: ExaFreshness): string { + const now = new Date(); + if (freshness === "day") { + now.setUTCDate(now.getUTCDate() - 1); + return now.toISOString(); + } + if (freshness === "week") { + now.setUTCDate(now.getUTCDate() - 7); + return now.toISOString(); + } + if (freshness === "month") { + const currentDay = now.getUTCDate(); + now.setUTCDate(1); + now.setUTCMonth(now.getUTCMonth() - 1); + const lastDayOfTargetMonth = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0), + ).getUTCDate(); + now.setUTCDate(Math.min(currentDay, lastDayOfTargetMonth)); + return now.toISOString(); + } + now.setUTCFullYear(now.getUTCFullYear() - 1); + return now.toISOString(); +} + +async function runExaSearch(params: { + apiKey: string; + query: string; + count: number; + freshness?: ExaFreshness; + dateAfter?: string; + dateBefore?: string; + type: ExaSearchType; + contents?: ExaContentsArgs; + timeoutSeconds: number; +}): Promise { + const body: Record = { + query: params.query, + numResults: params.count, + type: params.type, + contents: params.contents ?? { highlights: true }, + }; + + if (params.dateAfter) { + body.startPublishedDate = params.dateAfter; + } else if (params.freshness) { + body.startPublishedDate = resolveFreshnessStartDate(params.freshness); + } + if (params.dateBefore) { + body.endPublishedDate = params.dateBefore; + } + + return withTrustedWebSearchEndpoint( + { + url: EXA_SEARCH_ENDPOINT, + timeoutSeconds: params.timeoutSeconds, + init: { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "x-api-key": params.apiKey, + "x-exa-integration": "openclaw", + }, + body: JSON.stringify(body), + }, + }, + async (res) => { + if (!res.ok) { + const detail = await res.text(); + throw new Error(`Exa API error (${res.status}): ${detail || res.statusText}`); + } + try { + return normalizeExaResults(await res.json()); + } catch (error) { + throw new Error(`Exa API returned invalid JSON: ${String(error)}`, { cause: error }); + } + }, + ); +} + +function missingExaKeyPayload() { + return { + error: "missing_exa_api_key", + message: + "web_search (exa) needs an Exa API key. Set EXA_API_KEY in the Gateway environment, or configure tools.web.search.exa.apiKey.", + docs: "https://docs.openclaw.ai/tools/web", + }; +} + +export async function executeExaWebSearchProviderTool( + ctx: { config?: Record; searchConfig?: SearchConfigRecord }, + args: Record, +): Promise> { + const searchConfig = mergeScopedSearchConfig( + ctx.searchConfig, + "exa", + resolveProviderWebSearchPluginConfig(ctx.config, "exa"), + ) as SearchConfigRecord | undefined; + const params = args; + const exaConfig = resolveExaConfig(searchConfig); + const apiKey = resolveExaApiKey(exaConfig); + if (!apiKey) { + return missingExaKeyPayload(); + } + + const query = readStringParam(params, "query", { required: true }); + const rawType = readStringParam(params, "type"); + const type: ExaSearchType = EXA_SEARCH_TYPES.includes(rawType as ExaSearchType) + ? (rawType as ExaSearchType) + : "auto"; + const count = + readNumberParam(params, "count", { integer: true }) ?? searchConfig?.maxResults ?? undefined; + const rawFreshness = readStringParam(params, "freshness"); + const freshness = normalizeExaFreshness(rawFreshness); + if (rawFreshness && !freshness) { + return { + error: "invalid_freshness", + message: 'freshness must be one of "day", "week", "month", or "year".', + docs: "https://docs.openclaw.ai/tools/web", + }; + } + + const rawDateAfter = readStringParam(params, "date_after"); + const rawDateBefore = readStringParam(params, "date_before"); + if (freshness && (rawDateAfter || rawDateBefore)) { + return { + error: "conflicting_time_filters", + message: + "freshness cannot be combined with date_after or date_before. Use one time-filter mode.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + const parsedDateRange = parseIsoDateRange({ + rawDateAfter, + rawDateBefore, + invalidDateAfterMessage: "date_after must be YYYY-MM-DD format.", + invalidDateBeforeMessage: "date_before must be YYYY-MM-DD format.", + invalidDateRangeMessage: "date_after must be earlier than or equal to date_before.", + }); + if ("error" in parsedDateRange) { + return parsedDateRange; + } + const { dateAfter, dateBefore } = parsedDateRange; + + const parsedContents = parseExaContents(params.contents); + if (isErrorPayload(parsedContents)) { + return parsedContents; + } + const contents = + parsedContents.value && Object.keys(parsedContents.value).length > 0 + ? parsedContents.value + : undefined; + + const cacheKey = buildSearchCacheKey([ + "exa", + type, + query, + resolveExaSearchCount(count, DEFAULT_SEARCH_COUNT), + freshness, + dateAfter, + dateBefore, + contents?.highlights ? JSON.stringify(contents.highlights) : undefined, + contents?.text ? JSON.stringify(contents.text) : undefined, + contents?.summary ? JSON.stringify(contents.summary) : undefined, + ]); + const cached = readCachedSearchPayload(cacheKey); + if (cached) { + return cached; + } + + const start = Date.now(); + const results = await runExaSearch({ + apiKey, + query, + count: resolveExaSearchCount(count, DEFAULT_SEARCH_COUNT), + freshness, + dateAfter, + dateBefore, + type, + contents, + timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig), + }); + + const payload = { + query, + provider: "exa", + count: results.length, + tookMs: Date.now() - start, + externalContent: { + untrusted: true, + source: "web_search", + provider: "exa", + wrapped: true, + }, + results: results.map((entry) => { + const title = typeof entry.title === "string" ? entry.title : ""; + const url = typeof entry.url === "string" ? entry.url : ""; + const description = resolveExaDescription(entry); + const summary = normalizeOptionalString(entry.summary) ?? ""; + const highlightScores = Array.isArray(entry.highlightScores) + ? entry.highlightScores.filter( + (score): score is number => typeof score === "number" && Number.isFinite(score), + ) + : []; + const published = + typeof entry.publishedDate === "string" && entry.publishedDate + ? entry.publishedDate + : undefined; + return { + title: title ? wrapWebContent(title, "web_search") : "", + url, + description: description ? wrapWebContent(description, "web_search") : "", + published, + siteName: resolveSiteName(url) || undefined, + ...(summary ? { summary: wrapWebContent(summary, "web_search") } : {}), + ...(highlightScores.length > 0 ? { highlightScores } : {}), + }; + }), + }; + + writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig)); + return payload; +} + +export const __testing = { + normalizeExaResults, + normalizeExaFreshness, + parseExaContents, + resolveExaApiKey, + resolveExaConfig, + resolveExaDescription, + resolveExaSearchCount, + resolveFreshnessStartDate, +} as const; diff --git a/extensions/exa/src/exa-web-search-provider.test.ts b/extensions/exa/src/exa-web-search-provider.test.ts index 9de72bc89f2..66511180d00 100644 --- a/extensions/exa/src/exa-web-search-provider.test.ts +++ b/extensions/exa/src/exa-web-search-provider.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; +import { __testing } from "../test-api.js"; import { createExaWebSearchProvider as createContractExaWebSearchProvider } from "../web-search-contract-api.js"; -import { __testing, createExaWebSearchProvider } from "./exa-web-search-provider.js"; +import { createExaWebSearchProvider } from "./exa-web-search-provider.js"; describe("exa web search provider", () => { it("exposes the expected metadata and selection wiring", () => { diff --git a/extensions/exa/src/exa-web-search-provider.ts b/extensions/exa/src/exa-web-search-provider.ts index a4a908bb625..df32eb605f9 100644 --- a/extensions/exa/src/exa-web-search-provider.ts +++ b/extensions/exa/src/exa-web-search-provider.ts @@ -1,594 +1,61 @@ -import { Type } from "@sinclair/typebox"; import { - buildSearchCacheKey, - DEFAULT_SEARCH_COUNT, - enablePluginInConfig, - getScopedCredentialValue, - mergeScopedSearchConfig, - parseIsoDateRange, - readCachedSearchPayload, - readConfiguredSecretString, - readNumberParam, - readProviderEnvValue, - readStringParam, - resolveProviderWebSearchPluginConfig, - resolveSearchCacheTtlMs, - resolveSearchTimeoutSeconds, - resolveSiteName, - setProviderWebSearchPluginConfigValue, - setScopedCredentialValue, - type SearchConfigRecord, + createWebSearchProviderContractFields, type WebSearchProviderPlugin, - type WebSearchProviderToolDefinition, - withTrustedWebSearchEndpoint, - wrapWebContent, - writeCachedSearchPayload, -} from "openclaw/plugin-sdk/provider-web-search"; -import { - normalizeOptionalLowercaseString, - normalizeOptionalString, -} from "openclaw/plugin-sdk/text-runtime"; +} from "openclaw/plugin-sdk/provider-web-search-contract"; -const EXA_SEARCH_ENDPOINT = "https://api.exa.ai/search"; +const EXA_CREDENTIAL_PATH = "plugins.entries.exa.config.webSearch.apiKey"; const EXA_SEARCH_TYPES = ["auto", "neural", "fast", "deep", "deep-reasoning", "instant"] as const; const EXA_FRESHNESS_VALUES = ["day", "week", "month", "year"] as const; const EXA_MAX_SEARCH_COUNT = 100; -type ExaConfig = { - apiKey?: string; -}; - -type ExaSearchType = (typeof EXA_SEARCH_TYPES)[number]; -type ExaFreshness = (typeof EXA_FRESHNESS_VALUES)[number]; - -type ExaTextContentsOption = boolean | { maxCharacters?: number }; -type ExaHighlightsContentsOption = - | boolean - | { - maxCharacters?: number; - query?: string; - numSentences?: number; - highlightsPerUrl?: number; - }; -type ExaSummaryContentsOption = boolean | { query?: string }; - -type ExaContentsArgs = { - highlights?: ExaHighlightsContentsOption; - text?: ExaTextContentsOption; - summary?: ExaSummaryContentsOption; -}; - -type ExaSearchResult = { - title?: unknown; - url?: unknown; - publishedDate?: unknown; - highlights?: unknown; - highlightScores?: unknown; - summary?: unknown; - text?: unknown; -}; - -type ExaSearchResponse = { - results?: unknown; -}; - -function normalizeExaFreshness(value: string | undefined): ExaFreshness | undefined { - const trimmed = normalizeOptionalLowercaseString(value); - if (!trimmed) { - return undefined; - } - return EXA_FRESHNESS_VALUES.includes(trimmed as ExaFreshness) - ? (trimmed as ExaFreshness) - : undefined; -} - -function optionalStringEnum(values: T, description: string) { - return Type.Optional( - Type.Unsafe({ +const ExaSearchSchema = { + type: "object", + properties: { + query: { type: "string", description: "Search query string." }, + count: { + type: "number", + description: "Number of results to return (1-100, subject to Exa search-type limits).", + minimum: 1, + maximum: EXA_MAX_SEARCH_COUNT, + }, + freshness: { type: "string", - enum: [...values], - description, - }), - ); -} - -function resolveExaConfig(searchConfig?: SearchConfigRecord): ExaConfig { - const exa = searchConfig?.exa; - return exa && typeof exa === "object" && !Array.isArray(exa) ? (exa as ExaConfig) : {}; -} - -function resolveExaApiKey(exa?: ExaConfig): string | undefined { - return ( - readConfiguredSecretString(exa?.apiKey, "tools.web.search.exa.apiKey") ?? - readProviderEnvValue(["EXA_API_KEY"]) - ); -} - -function resolveExaDescription(result: ExaSearchResult): string { - const highlights = result.highlights; - if (Array.isArray(highlights)) { - const highlightText = highlights - .map((entry) => normalizeOptionalString(entry)) - .filter((entry): entry is string => Boolean(entry)) - .join("\n"); - if (highlightText) { - return highlightText; - } - } - const summary = normalizeOptionalString(result.summary); - if (summary) { - return summary; - } - return normalizeOptionalString(result.text) ?? ""; -} - -function parsePositiveInteger(value: unknown): number | undefined { - return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined; -} - -function invalidContentsPayload(message: string) { - return { - error: "invalid_contents", - message, - docs: "https://docs.openclaw.ai/tools/web", - }; -} - -function isErrorPayload(value: unknown): value is { error: string; message: string; docs: string } { - return Boolean( - value && typeof value === "object" && "error" in value && "message" in value && "docs" in value, - ); -} - -function resolveExaSearchCount(value: unknown, fallback: number): number { - const parsed = typeof value === "number" ? value : Number(value); - if (!Number.isFinite(parsed)) { - return fallback; - } - return Math.max(1, Math.min(EXA_MAX_SEARCH_COUNT, Math.floor(parsed))); -} - -function parseExaContents( - rawContents: unknown, -): { value?: ExaContentsArgs } | { error: string; message: string; docs: string } { - if (rawContents === undefined) { - return { value: undefined }; - } - if (!rawContents || typeof rawContents !== "object" || Array.isArray(rawContents)) { - return invalidContentsPayload( - "contents must be an object with optional text, highlights, and summary fields.", - ); - } - - const raw = rawContents as Record; - const allowedKeys = new Set(["text", "highlights", "summary"]); - for (const key of Object.keys(raw)) { - if (!allowedKeys.has(key)) { - return invalidContentsPayload( - `contents has unknown field "${key}". Only "text", "highlights", and "summary" are allowed.`, - ); - } - } - - const parsed: ExaContentsArgs = {}; - - const parseText = ( - value: unknown, - ): ExaTextContentsOption | { error: string; message: string; docs: string } => { - if (typeof value === "boolean") { - return value; - } - if (!value || typeof value !== "object" || Array.isArray(value)) { - return invalidContentsPayload("contents.text must be a boolean or an object."); - } - const obj = value as Record; - for (const key of Object.keys(obj)) { - if (key !== "maxCharacters") { - return invalidContentsPayload( - `contents.text has unknown field "${key}". Only "maxCharacters" is allowed.`, - ); - } - } - if ("maxCharacters" in obj && parsePositiveInteger(obj.maxCharacters) === undefined) { - return invalidContentsPayload("contents.text.maxCharacters must be a positive integer."); - } - return parsePositiveInteger(obj.maxCharacters) - ? { maxCharacters: parsePositiveInteger(obj.maxCharacters) } - : {}; - }; - - const parseHighlights = ( - value: unknown, - ): ExaHighlightsContentsOption | { error: string; message: string; docs: string } => { - if (typeof value === "boolean") { - return value; - } - if (!value || typeof value !== "object" || Array.isArray(value)) { - return invalidContentsPayload("contents.highlights must be a boolean or an object."); - } - const obj = value as Record; - const allowed = new Set(["maxCharacters", "query", "numSentences", "highlightsPerUrl"]); - for (const key of Object.keys(obj)) { - if (!allowed.has(key)) { - return invalidContentsPayload( - `contents.highlights has unknown field "${key}". Allowed fields are "maxCharacters", "query", "numSentences", and "highlightsPerUrl".`, - ); - } - } - if ("maxCharacters" in obj && parsePositiveInteger(obj.maxCharacters) === undefined) { - return invalidContentsPayload( - "contents.highlights.maxCharacters must be a positive integer.", - ); - } - if ("numSentences" in obj && parsePositiveInteger(obj.numSentences) === undefined) { - return invalidContentsPayload("contents.highlights.numSentences must be a positive integer."); - } - if ("highlightsPerUrl" in obj && parsePositiveInteger(obj.highlightsPerUrl) === undefined) { - return invalidContentsPayload( - "contents.highlights.highlightsPerUrl must be a positive integer.", - ); - } - if ("query" in obj && typeof obj.query !== "string") { - return invalidContentsPayload("contents.highlights.query must be a string."); - } - return { - ...(parsePositiveInteger(obj.maxCharacters) - ? { maxCharacters: parsePositiveInteger(obj.maxCharacters) } - : {}), - ...(typeof obj.query === "string" ? { query: obj.query } : {}), - ...(parsePositiveInteger(obj.numSentences) - ? { numSentences: parsePositiveInteger(obj.numSentences) } - : {}), - ...(parsePositiveInteger(obj.highlightsPerUrl) - ? { highlightsPerUrl: parsePositiveInteger(obj.highlightsPerUrl) } - : {}), - }; - }; - - const parseSummary = ( - value: unknown, - ): ExaSummaryContentsOption | { error: string; message: string; docs: string } => { - if (typeof value === "boolean") { - return value; - } - if (!value || typeof value !== "object" || Array.isArray(value)) { - return invalidContentsPayload("contents.summary must be a boolean or an object."); - } - const obj = value as Record; - for (const key of Object.keys(obj)) { - if (key !== "query") { - return invalidContentsPayload( - `contents.summary has unknown field "${key}". Only "query" is allowed.`, - ); - } - } - if ("query" in obj && typeof obj.query !== "string") { - return invalidContentsPayload("contents.summary.query must be a string."); - } - return typeof obj.query === "string" ? { query: obj.query } : {}; - }; - - if ("text" in raw) { - const parsedText = parseText(raw.text); - if (isErrorPayload(parsedText)) { - return parsedText; - } - parsed.text = parsedText; - } - if ("highlights" in raw) { - const parsedHighlights = parseHighlights(raw.highlights); - if (isErrorPayload(parsedHighlights)) { - return parsedHighlights; - } - parsed.highlights = parsedHighlights; - } - if ("summary" in raw) { - const parsedSummary = parseSummary(raw.summary); - if (isErrorPayload(parsedSummary)) { - return parsedSummary; - } - parsed.summary = parsedSummary; - } - - return { value: parsed }; -} - -function normalizeExaResults(payload: unknown): ExaSearchResult[] { - if (!payload || typeof payload !== "object") { - return []; - } - const results = (payload as ExaSearchResponse).results; - if (!Array.isArray(results)) { - return []; - } - return results.filter((entry): entry is ExaSearchResult => - Boolean(entry && typeof entry === "object" && !Array.isArray(entry)), - ); -} - -function resolveFreshnessStartDate(freshness: ExaFreshness): string { - const now = new Date(); - if (freshness === "day") { - now.setUTCDate(now.getUTCDate() - 1); - return now.toISOString(); - } - if (freshness === "week") { - now.setUTCDate(now.getUTCDate() - 7); - return now.toISOString(); - } - if (freshness === "month") { - const currentDay = now.getUTCDate(); - now.setUTCDate(1); - now.setUTCMonth(now.getUTCMonth() - 1); - const lastDayOfTargetMonth = new Date( - Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0), - ).getUTCDate(); - now.setUTCDate(Math.min(currentDay, lastDayOfTargetMonth)); - return now.toISOString(); - } - now.setUTCFullYear(now.getUTCFullYear() - 1); - return now.toISOString(); -} - -async function runExaSearch(params: { - apiKey: string; - query: string; - count: number; - freshness?: ExaFreshness; - dateAfter?: string; - dateBefore?: string; - type: ExaSearchType; - contents?: ExaContentsArgs; - timeoutSeconds: number; -}): Promise { - const body: Record = { - query: params.query, - numResults: params.count, - type: params.type, - contents: params.contents ?? { highlights: true }, - }; - - if (params.dateAfter) { - body.startPublishedDate = params.dateAfter; - } else if (params.freshness) { - body.startPublishedDate = resolveFreshnessStartDate(params.freshness); - } - if (params.dateBefore) { - body.endPublishedDate = params.dateBefore; - } - - return withTrustedWebSearchEndpoint( - { - url: EXA_SEARCH_ENDPOINT, - timeoutSeconds: params.timeoutSeconds, - init: { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - "x-api-key": params.apiKey, - "x-exa-integration": "openclaw", - }, - body: JSON.stringify(body), - }, + enum: [...EXA_FRESHNESS_VALUES], + description: 'Filter by time: "day", "week", "month", or "year".', }, - async (res) => { - if (!res.ok) { - const detail = await res.text(); - throw new Error(`Exa API error (${res.status}): ${detail || res.statusText}`); - } - try { - return normalizeExaResults(await res.json()); - } catch (error) { - throw new Error(`Exa API returned invalid JSON: ${String(error)}`, { cause: error }); - } + date_after: { + type: "string", + description: "Only results published after this date (YYYY-MM-DD).", }, - ); -} - -function createExaSchema() { - return Type.Object( - { - query: Type.String({ description: "Search query string." }), - count: Type.Optional( - Type.Number({ - description: "Number of results to return (1-100, subject to Exa search-type limits).", - minimum: 1, - maximum: EXA_MAX_SEARCH_COUNT, - }), - ), - freshness: optionalStringEnum( - EXA_FRESHNESS_VALUES, - 'Filter by time: "day", "week", "month", or "year".', - ), - date_after: Type.Optional( - Type.String({ - description: "Only results published after this date (YYYY-MM-DD).", - }), - ), - date_before: Type.Optional( - Type.String({ - description: "Only results published before this date (YYYY-MM-DD).", - }), - ), - type: optionalStringEnum( - EXA_SEARCH_TYPES, + date_before: { + type: "string", + description: "Only results published before this date (YYYY-MM-DD).", + }, + type: { + type: "string", + enum: [...EXA_SEARCH_TYPES], + description: 'Exa search mode: "auto", "neural", "fast", "deep", "deep-reasoning", or "instant".', - ), - contents: Type.Optional( - Type.Object( - { - highlights: Type.Optional( - Type.Unsafe({ - description: - "Highlights config: true, or an object with maxCharacters, query, numSentences, or highlightsPerUrl.", - }), - ), - text: Type.Optional( - Type.Unsafe({ - description: "Text config: true, or an object with maxCharacters.", - }), - ), - summary: Type.Optional( - Type.Unsafe({ - description: "Summary config: true, or an object with query.", - }), - ), - }, - { additionalProperties: false }, - ), - ), }, - { additionalProperties: false }, - ); -} - -function missingExaKeyPayload() { - return { - error: "missing_exa_api_key", - message: - "web_search (exa) needs an Exa API key. Set EXA_API_KEY in the Gateway environment, or configure tools.web.search.exa.apiKey.", - docs: "https://docs.openclaw.ai/tools/web", - }; -} - -function createExaToolDefinition( - searchConfig?: SearchConfigRecord, -): WebSearchProviderToolDefinition { - return { - description: - "Search the web using Exa AI. Supports neural or keyword search, publication date filters, and optional highlights or text extraction.", - parameters: createExaSchema(), - execute: async (args) => { - const params = args; - const exaConfig = resolveExaConfig(searchConfig); - const apiKey = resolveExaApiKey(exaConfig); - if (!apiKey) { - return missingExaKeyPayload(); - } - - const query = readStringParam(params, "query", { required: true }); - const rawType = readStringParam(params, "type"); - const type: ExaSearchType = EXA_SEARCH_TYPES.includes(rawType as ExaSearchType) - ? (rawType as ExaSearchType) - : "auto"; - const count = - readNumberParam(params, "count", { integer: true }) ?? - searchConfig?.maxResults ?? - undefined; - const rawFreshness = readStringParam(params, "freshness"); - const freshness = normalizeExaFreshness(rawFreshness); - if (rawFreshness && !freshness) { - return { - error: "invalid_freshness", - message: 'freshness must be one of "day", "week", "month", or "year".', - docs: "https://docs.openclaw.ai/tools/web", - }; - } - - const rawDateAfter = readStringParam(params, "date_after"); - const rawDateBefore = readStringParam(params, "date_before"); - if (freshness && (rawDateAfter || rawDateBefore)) { - return { - error: "conflicting_time_filters", - message: - "freshness cannot be combined with date_after or date_before. Use one time-filter mode.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - const parsedDateRange = parseIsoDateRange({ - rawDateAfter, - rawDateBefore, - invalidDateAfterMessage: "date_after must be YYYY-MM-DD format.", - invalidDateBeforeMessage: "date_before must be YYYY-MM-DD format.", - invalidDateRangeMessage: "date_after must be earlier than or equal to date_before.", - }); - if ("error" in parsedDateRange) { - return parsedDateRange; - } - const { dateAfter, dateBefore } = parsedDateRange; - - const parsedContents = parseExaContents(params.contents); - if (isErrorPayload(parsedContents)) { - return parsedContents; - } - const contents = - parsedContents.value && Object.keys(parsedContents.value).length > 0 - ? parsedContents.value - : undefined; - - const cacheKey = buildSearchCacheKey([ - "exa", - type, - query, - resolveExaSearchCount(count, DEFAULT_SEARCH_COUNT), - freshness, - dateAfter, - dateBefore, - contents?.highlights ? JSON.stringify(contents.highlights) : undefined, - contents?.text ? JSON.stringify(contents.text) : undefined, - contents?.summary ? JSON.stringify(contents.summary) : undefined, - ]); - const cached = readCachedSearchPayload(cacheKey); - if (cached) { - return cached; - } - - const start = Date.now(); - const results = await runExaSearch({ - apiKey, - query, - count: resolveExaSearchCount(count, DEFAULT_SEARCH_COUNT), - freshness, - dateAfter, - dateBefore, - type, - contents, - timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig), - }); - - const payload = { - query, - provider: "exa", - count: results.length, - tookMs: Date.now() - start, - externalContent: { - untrusted: true, - source: "web_search", - provider: "exa", - wrapped: true, + contents: { + type: "object", + properties: { + highlights: { + description: + "Highlights config: true, or an object with maxCharacters, query, numSentences, or highlightsPerUrl.", }, - results: results.map((entry) => { - const title = typeof entry.title === "string" ? entry.title : ""; - const url = typeof entry.url === "string" ? entry.url : ""; - const description = resolveExaDescription(entry); - const summary = normalizeOptionalString(entry.summary) ?? ""; - const highlightScores = Array.isArray(entry.highlightScores) - ? entry.highlightScores.filter( - (score): score is number => typeof score === "number" && Number.isFinite(score), - ) - : []; - const published = - typeof entry.publishedDate === "string" && entry.publishedDate - ? entry.publishedDate - : undefined; - return { - title: title ? wrapWebContent(title, "web_search") : "", - url, - description: description ? wrapWebContent(description, "web_search") : "", - published, - siteName: resolveSiteName(url) || undefined, - ...(summary ? { summary: wrapWebContent(summary, "web_search") } : {}), - ...(highlightScores.length > 0 ? { highlightScores } : {}), - }; - }), - }; - - writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig)); - return payload; + text: { + description: "Text config: true, or an object with maxCharacters.", + }, + summary: { + description: "Summary config: true, or an object with query.", + }, + }, + additionalProperties: false, }, - }; -} + }, + additionalProperties: false, +} satisfies Record; export function createExaWebSearchProvider(): WebSearchProviderPlugin { return { @@ -602,35 +69,22 @@ export function createExaWebSearchProvider(): WebSearchProviderPlugin { signupUrl: "https://exa.ai/", docsUrl: "https://docs.openclaw.ai/tools/web", autoDetectOrder: 65, - credentialPath: "plugins.entries.exa.config.webSearch.apiKey", - inactiveSecretPaths: ["plugins.entries.exa.config.webSearch.apiKey"], - getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "exa"), - setCredentialValue: (searchConfigTarget, value) => - setScopedCredentialValue(searchConfigTarget, "exa", value), - getConfiguredCredentialValue: (config) => - resolveProviderWebSearchPluginConfig(config, "exa")?.apiKey, - setConfiguredCredentialValue: (configTarget, value) => { - setProviderWebSearchPluginConfigValue(configTarget, "exa", "apiKey", value); - }, - applySelectionConfig: (config) => enablePluginInConfig(config, "exa").config, - createTool: (ctx) => - createExaToolDefinition( - mergeScopedSearchConfig( - ctx.searchConfig, - "exa", - resolveProviderWebSearchPluginConfig(ctx.config, "exa"), - ), - ), + credentialPath: EXA_CREDENTIAL_PATH, + ...createWebSearchProviderContractFields({ + credentialPath: EXA_CREDENTIAL_PATH, + searchCredential: { type: "scoped", scopeId: "exa" }, + configuredCredential: { pluginId: "exa" }, + selectionPluginId: "exa", + }), + createTool: (ctx) => ({ + description: + "Search the web using Exa AI. Supports neural or keyword search, publication date filters, and optional highlights or text extraction.", + parameters: ExaSearchSchema, + execute: async (args) => { + const { executeExaWebSearchProviderTool } = + await import("./exa-web-search-provider.runtime.js"); + return await executeExaWebSearchProviderTool(ctx, args); + }, + }), }; } - -export const __testing = { - normalizeExaResults, - normalizeExaFreshness, - parseExaContents, - resolveExaApiKey, - resolveExaConfig, - resolveExaDescription, - resolveExaSearchCount, - resolveFreshnessStartDate, -} as const; diff --git a/extensions/exa/test-api.ts b/extensions/exa/test-api.ts new file mode 100644 index 00000000000..8ce2f5e0e80 --- /dev/null +++ b/extensions/exa/test-api.ts @@ -0,0 +1 @@ +export { __testing } from "./src/exa-web-search-provider.runtime.js"; diff --git a/extensions/exa/web-search-provider.ts b/extensions/exa/web-search-provider.ts index 88802359652..55302ff6ebe 100644 --- a/extensions/exa/web-search-provider.ts +++ b/extensions/exa/web-search-provider.ts @@ -1 +1 @@ -export { __testing, createExaWebSearchProvider } from "./src/exa-web-search-provider.js"; +export { createExaWebSearchProvider } from "./src/exa-web-search-provider.js";