mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 17:30:21 +00:00
fix(web-search): align Exa plugin with current API
This commit is contained in:
@@ -33,6 +33,72 @@ describe("exa web search provider", () => {
|
||||
expect(Number.isNaN(Date.parse(iso))).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts current Exa contents object options from the docs", () => {
|
||||
expect(
|
||||
__testing.parseExaContents({
|
||||
text: { maxCharacters: 1200 },
|
||||
highlights: {
|
||||
maxCharacters: 4000,
|
||||
query: "latest model launches",
|
||||
numSentences: 4,
|
||||
highlightsPerUrl: 2,
|
||||
},
|
||||
summary: { query: "launch details" },
|
||||
}),
|
||||
).toEqual({
|
||||
value: {
|
||||
text: { maxCharacters: 1200 },
|
||||
highlights: {
|
||||
maxCharacters: 4000,
|
||||
query: "latest model launches",
|
||||
numSentences: 4,
|
||||
highlightsPerUrl: 2,
|
||||
},
|
||||
summary: { query: "launch details" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid Exa contents objects", () => {
|
||||
expect(
|
||||
__testing.parseExaContents({
|
||||
highlights: { numSentences: 0 },
|
||||
}),
|
||||
).toMatchObject({
|
||||
error: "invalid_contents",
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes newer documented Exa search types and count limits", () => {
|
||||
const provider = createExaWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {},
|
||||
searchConfig: { exa: { apiKey: "exa-secret" } },
|
||||
});
|
||||
if (!tool) {
|
||||
throw new Error("Expected tool definition");
|
||||
}
|
||||
|
||||
const parameters = tool.parameters as {
|
||||
properties?: {
|
||||
count?: { maximum?: number };
|
||||
type?: { enum?: string[] };
|
||||
};
|
||||
};
|
||||
|
||||
expect(parameters.properties?.count?.maximum).toBe(100);
|
||||
expect(parameters.properties?.type?.enum).toEqual([
|
||||
"auto",
|
||||
"neural",
|
||||
"fast",
|
||||
"deep",
|
||||
"deep-reasoning",
|
||||
"instant",
|
||||
]);
|
||||
expect(__testing.resolveExaSearchCount(80, 10)).toBe(80);
|
||||
expect(__testing.resolveExaSearchCount(120, 10)).toBe(100);
|
||||
});
|
||||
|
||||
it("returns validation errors for conflicting time filters", async () => {
|
||||
const provider = createExaWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
DEFAULT_SEARCH_COUNT,
|
||||
enablePluginInConfig,
|
||||
getScopedCredentialValue,
|
||||
MAX_SEARCH_COUNT,
|
||||
mergeScopedSearchConfig,
|
||||
normalizeToIsoDate,
|
||||
readCachedSearchPayload,
|
||||
@@ -14,7 +13,6 @@ import {
|
||||
readStringParam,
|
||||
resolveProviderWebSearchPluginConfig,
|
||||
resolveSearchCacheTtlMs,
|
||||
resolveSearchCount,
|
||||
resolveSearchTimeoutSeconds,
|
||||
resolveSiteName,
|
||||
setProviderWebSearchPluginConfigValue,
|
||||
@@ -28,8 +26,9 @@ import {
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
|
||||
const EXA_SEARCH_ENDPOINT = "https://api.exa.ai/search";
|
||||
const EXA_SEARCH_TYPES = ["auto", "keyword", "neural"] as const;
|
||||
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;
|
||||
@@ -38,9 +37,21 @@ type ExaConfig = {
|
||||
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?: boolean;
|
||||
text?: boolean;
|
||||
highlights?: ExaHighlightsContentsOption;
|
||||
text?: ExaTextContentsOption;
|
||||
summary?: ExaSummaryContentsOption;
|
||||
};
|
||||
|
||||
type ExaSearchResult = {
|
||||
@@ -48,6 +59,8 @@ type ExaSearchResult = {
|
||||
url?: unknown;
|
||||
publishedDate?: unknown;
|
||||
highlights?: unknown;
|
||||
highlightScores?: unknown;
|
||||
summary?: unknown;
|
||||
text?: unknown;
|
||||
};
|
||||
|
||||
@@ -98,9 +111,185 @@ function resolveExaDescription(result: ExaSearchResult): string {
|
||||
return highlightText;
|
||||
}
|
||||
}
|
||||
if (typeof result.summary === "string" && result.summary.trim()) {
|
||||
return result.summary.trim();
|
||||
}
|
||||
return typeof result.text === "string" ? result.text.trim() : "";
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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 [];
|
||||
@@ -200,9 +389,9 @@ function createExaSchema() {
|
||||
query: Type.String({ description: "Search query string." }),
|
||||
count: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Number of results to return (1-10).",
|
||||
description: "Number of results to return (1-100, subject to Exa search-type limits).",
|
||||
minimum: 1,
|
||||
maximum: MAX_SEARCH_COUNT,
|
||||
maximum: EXA_MAX_SEARCH_COUNT,
|
||||
}),
|
||||
),
|
||||
freshness: optionalStringEnum(
|
||||
@@ -221,15 +410,27 @@ function createExaSchema() {
|
||||
),
|
||||
type: optionalStringEnum(
|
||||
EXA_SEARCH_TYPES,
|
||||
'Exa search mode: "auto", "keyword", or "neural".',
|
||||
'Exa search mode: "auto", "neural", "fast", "deep", "deep-reasoning", or "instant".',
|
||||
),
|
||||
contents: Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
highlights: Type.Optional(
|
||||
Type.Boolean({ description: "Include Exa highlights in results." }),
|
||||
Type.Unsafe<ExaHighlightsContentsOption>({
|
||||
description:
|
||||
"Highlights config: true, or an object with maxCharacters, query, numSentences, or highlightsPerUrl.",
|
||||
}),
|
||||
),
|
||||
text: Type.Optional(
|
||||
Type.Unsafe<ExaTextContentsOption>({
|
||||
description: "Text config: true, or an object with maxCharacters.",
|
||||
}),
|
||||
),
|
||||
summary: Type.Optional(
|
||||
Type.Unsafe<ExaSummaryContentsOption>({
|
||||
description: "Summary config: true, or an object with query.",
|
||||
}),
|
||||
),
|
||||
text: Type.Optional(Type.Boolean({ description: "Include full text in results." })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
@@ -265,8 +466,9 @@ function createExaToolDefinition(
|
||||
|
||||
const query = readStringParam(params, "query", { required: true });
|
||||
const rawType = readStringParam(params, "type");
|
||||
const type: ExaSearchType =
|
||||
rawType === "keyword" || rawType === "neural" || rawType === "auto" ? rawType : "auto";
|
||||
const type: ExaSearchType = EXA_SEARCH_TYPES.includes(rawType as ExaSearchType)
|
||||
? (rawType as ExaSearchType)
|
||||
: "auto";
|
||||
const count =
|
||||
readNumberParam(params, "count", { integer: true }) ??
|
||||
searchConfig?.maxResults ??
|
||||
@@ -318,22 +520,26 @@ function createExaToolDefinition(
|
||||
};
|
||||
}
|
||||
|
||||
const rawContents = params.contents;
|
||||
const parsedContents = parseExaContents(params.contents);
|
||||
if (isErrorPayload(parsedContents)) {
|
||||
return parsedContents;
|
||||
}
|
||||
const contents =
|
||||
rawContents && typeof rawContents === "object" && !Array.isArray(rawContents)
|
||||
? (rawContents as ExaContentsArgs)
|
||||
parsedContents.value && Object.keys(parsedContents.value).length > 0
|
||||
? parsedContents.value
|
||||
: undefined;
|
||||
|
||||
const cacheKey = buildSearchCacheKey([
|
||||
"exa",
|
||||
type,
|
||||
query,
|
||||
resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
|
||||
resolveExaSearchCount(count, DEFAULT_SEARCH_COUNT),
|
||||
freshness,
|
||||
dateAfter,
|
||||
dateBefore,
|
||||
contents?.highlights,
|
||||
contents?.text,
|
||||
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) {
|
||||
@@ -344,7 +550,7 @@ function createExaToolDefinition(
|
||||
const results = await runExaSearch({
|
||||
apiKey,
|
||||
query,
|
||||
count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
|
||||
count: resolveExaSearchCount(count, DEFAULT_SEARCH_COUNT),
|
||||
freshness,
|
||||
dateAfter,
|
||||
dateBefore,
|
||||
@@ -368,6 +574,12 @@ function createExaToolDefinition(
|
||||
const title = typeof entry.title === "string" ? entry.title : "";
|
||||
const url = typeof entry.url === "string" ? entry.url : "";
|
||||
const description = resolveExaDescription(entry);
|
||||
const summary = typeof entry.summary === "string" ? entry.summary.trim() : "";
|
||||
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
|
||||
@@ -378,6 +590,8 @@ function createExaToolDefinition(
|
||||
description: description ? wrapWebContent(description, "web_search") : "",
|
||||
published,
|
||||
siteName: resolveSiteName(url) || undefined,
|
||||
...(summary ? { summary: wrapWebContent(summary, "web_search") } : {}),
|
||||
...(highlightScores.length > 0 ? { highlightScores } : {}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
@@ -423,8 +637,11 @@ export function createExaWebSearchProvider(): WebSearchProviderPlugin {
|
||||
|
||||
export const __testing = {
|
||||
normalizeExaResults,
|
||||
normalizeExaFreshness,
|
||||
parseExaContents,
|
||||
resolveExaApiKey,
|
||||
resolveExaConfig,
|
||||
resolveExaDescription,
|
||||
resolveExaSearchCount,
|
||||
resolveFreshnessStartDate,
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user