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:
12
extensions/exa/README.md
Normal file
12
extensions/exa/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# OpenClaw Exa Plugin
|
||||
|
||||
Official OpenClaw plugin for Exa.
|
||||
|
||||
Install from OpenClaw:
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/exa-plugin
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
See <https://docs.openclaw.ai/tools/exa-search> for setup and configuration.
|
||||
12
extensions/exa/index.ts
Normal file
12
extensions/exa/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// Exa plugin entrypoint registers its OpenClaw integration.
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createExaWebSearchProvider } from "./src/exa-web-search-provider.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "exa",
|
||||
name: "Exa Plugin",
|
||||
description: "Bundled Exa web search plugin",
|
||||
register(api) {
|
||||
api.registerWebSearchProvider(createExaWebSearchProvider());
|
||||
},
|
||||
});
|
||||
12
extensions/exa/npm-shrinkwrap.json
generated
Normal file
12
extensions/exa/npm-shrinkwrap.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/exa-plugin",
|
||||
"version": "2026.6.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/exa-plugin",
|
||||
"version": "2026.6.11"
|
||||
}
|
||||
}
|
||||
}
|
||||
50
extensions/exa/openclaw.plugin.json
Normal file
50
extensions/exa/openclaw.plugin.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"id": "exa",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
"setup": {
|
||||
"providers": [
|
||||
{
|
||||
"id": "exa",
|
||||
"envVars": ["EXA_API_KEY"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"uiHints": {
|
||||
"webSearch.apiKey": {
|
||||
"label": "Exa API Key",
|
||||
"help": "Exa Search API key (fallback: EXA_API_KEY env var).",
|
||||
"sensitive": true,
|
||||
"placeholder": "exa-..."
|
||||
},
|
||||
"webSearch.baseUrl": {
|
||||
"label": "Exa Search Base URL",
|
||||
"help": "Optional Exa Search API base URL override. OpenClaw appends /search when the URL does not already end there."
|
||||
}
|
||||
},
|
||||
"contracts": {
|
||||
"webSearchProviders": ["exa"]
|
||||
},
|
||||
"configContracts": {
|
||||
"compatibilityRuntimePaths": ["tools.web.search.apiKey"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"webSearch": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"type": ["string", "object"]
|
||||
},
|
||||
"baseUrl": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
35
extensions/exa/package.json
Normal file
35
extensions/exa/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@openclaw/exa-plugin",
|
||||
"version": "2026.6.11",
|
||||
"description": "OpenClaw Exa plugin.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/openclaw/openclaw"
|
||||
},
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"install": {
|
||||
"clawhubSpec": "clawhub:@openclaw/exa-plugin",
|
||||
"npmSpec": "@openclaw/exa-plugin",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.6.8"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.11"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.11",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true
|
||||
}
|
||||
}
|
||||
}
|
||||
632
extensions/exa/src/exa-web-search-provider.runtime.ts
Normal file
632
extensions/exa/src/exa-web-search-provider.runtime.ts
Normal file
@@ -0,0 +1,632 @@
|
||||
// Exa provider module implements model/runtime integration.
|
||||
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
buildSearchCacheKey,
|
||||
DEFAULT_SEARCH_COUNT,
|
||||
mergeScopedSearchConfig,
|
||||
parseIsoDateRange,
|
||||
readCachedSearchPayload,
|
||||
readConfiguredSecretString,
|
||||
readPositiveIntegerParam,
|
||||
readProviderEnvValue,
|
||||
readStringParam,
|
||||
resolveProviderWebSearchPluginConfig,
|
||||
resolveSearchCacheTtlMs,
|
||||
resolveSearchTimeoutSeconds,
|
||||
resolveSiteName,
|
||||
type SearchConfigRecord,
|
||||
withTrustedWebSearchEndpoint,
|
||||
wrapWebContent,
|
||||
writeCachedSearchPayload,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
|
||||
import {
|
||||
normalizeOptionalLowercaseString,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-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;
|
||||
const EXA_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
|
||||
// Exa search responses are untrusted external bodies. Cap the success JSON the
|
||||
// same way other bundled providers do (16 MiB) so a misbehaving or hostile
|
||||
// endpoint cannot stream an unbounded body into memory before we parse it.
|
||||
const EXA_SEARCH_JSON_MAX_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
type ExaConfig = {
|
||||
apiKey?: string;
|
||||
baseUrl?: 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;
|
||||
};
|
||||
|
||||
async function readExaSearchResults(
|
||||
response: Response,
|
||||
opts?: { maxBytes?: number },
|
||||
): Promise<ExaSearchResult[]> {
|
||||
const maxBytes = opts?.maxBytes ?? EXA_SEARCH_JSON_MAX_BYTES;
|
||||
const bytes = await readResponseWithLimit(response, maxBytes, {
|
||||
onOverflow: ({ maxBytes: maxBytesLocal }) =>
|
||||
new Error(`Exa API response exceeds ${maxBytesLocal} bytes`),
|
||||
});
|
||||
try {
|
||||
return normalizeExaResults(JSON.parse(new TextDecoder().decode(bytes)));
|
||||
} catch (cause) {
|
||||
throw new Error("Exa API returned malformed JSON", { cause });
|
||||
}
|
||||
}
|
||||
|
||||
async function readExaErrorDetail(response: Response): Promise<string> {
|
||||
return await readResponseTextLimited(response, EXA_ERROR_BODY_LIMIT_BYTES);
|
||||
}
|
||||
|
||||
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 invalidBaseUrlPayload(value: string) {
|
||||
return {
|
||||
error: "invalid_base_url",
|
||||
message: `plugins.entries.exa.config.webSearch.baseUrl must be a valid http(s) URL. Got: ${value}`,
|
||||
docs: "https://docs.openclaw.ai/tools/exa-search",
|
||||
};
|
||||
}
|
||||
|
||||
function resolveExaSearchEndpoint(
|
||||
exa?: ExaConfig,
|
||||
): { endpoint: string } | { error: string; message: string; docs: string } {
|
||||
const configured = normalizeOptionalString(exa?.baseUrl);
|
||||
if (!configured) {
|
||||
return { endpoint: EXA_SEARCH_ENDPOINT };
|
||||
}
|
||||
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(configured) && !/^https?:\/\//i.test(configured)) {
|
||||
return invalidBaseUrlPayload(configured);
|
||||
}
|
||||
const candidate = /^https?:\/\//i.test(configured) ? configured : `https://${configured}`;
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(candidate);
|
||||
} catch {
|
||||
return invalidBaseUrlPayload(configured);
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
return invalidBaseUrlPayload(configured);
|
||||
}
|
||||
|
||||
const pathname = parsed.pathname.replace(/\/+$/, "");
|
||||
parsed.pathname = pathname.endsWith("/search")
|
||||
? pathname
|
||||
: `${pathname === "" ? "" : pathname}/search`;
|
||||
parsed.hash = "";
|
||||
return { endpoint: parsed.toString() };
|
||||
}
|
||||
|
||||
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 = parseStrictPositiveInteger(value);
|
||||
if (parsed === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.min(EXA_MAX_SEARCH_COUNT, 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 [];
|
||||
}
|
||||
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;
|
||||
endpoint: string;
|
||||
query: string;
|
||||
count: number;
|
||||
freshness?: ExaFreshness;
|
||||
dateAfter?: string;
|
||||
dateBefore?: string;
|
||||
type: ExaSearchType;
|
||||
contents?: ExaContentsArgs;
|
||||
timeoutSeconds: number;
|
||||
}): Promise<ExaSearchResult[]> {
|
||||
const body: Record<string, unknown> = {
|
||||
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: params.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 readExaErrorDetail(res);
|
||||
throw new Error(`Exa API error (${res.status}): ${detail || res.statusText}`);
|
||||
}
|
||||
return readExaSearchResults(res);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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 buildExaCacheKey(params: {
|
||||
endpoint: string;
|
||||
type: ExaSearchType;
|
||||
query: string;
|
||||
count: number;
|
||||
freshness?: ExaFreshness;
|
||||
dateAfter?: string;
|
||||
dateBefore?: string;
|
||||
contents?: ExaContentsArgs;
|
||||
}): string {
|
||||
return buildSearchCacheKey([
|
||||
"exa",
|
||||
params.endpoint,
|
||||
params.type,
|
||||
params.query,
|
||||
params.count,
|
||||
params.freshness,
|
||||
params.dateAfter,
|
||||
params.dateBefore,
|
||||
params.contents?.highlights ? JSON.stringify(params.contents.highlights) : undefined,
|
||||
params.contents?.text ? JSON.stringify(params.contents.text) : undefined,
|
||||
params.contents?.summary ? JSON.stringify(params.contents.summary) : undefined,
|
||||
]);
|
||||
}
|
||||
|
||||
export async function executeExaWebSearchProviderTool(
|
||||
ctx: { config?: Record<string, unknown>; searchConfig?: SearchConfigRecord },
|
||||
args: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
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 endpointResult = resolveExaSearchEndpoint(exaConfig);
|
||||
if ("error" in endpointResult) {
|
||||
return endpointResult;
|
||||
}
|
||||
const endpoint = endpointResult.endpoint;
|
||||
|
||||
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 =
|
||||
readPositiveIntegerParam(params, "count", {
|
||||
max: EXA_MAX_SEARCH_COUNT,
|
||||
message: `count must be an integer from 1 to ${EXA_MAX_SEARCH_COUNT}.`,
|
||||
}) ??
|
||||
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 resolvedCount = resolveExaSearchCount(count, DEFAULT_SEARCH_COUNT);
|
||||
const cacheKey = buildExaCacheKey({
|
||||
endpoint,
|
||||
type,
|
||||
query,
|
||||
count: resolvedCount,
|
||||
freshness,
|
||||
dateAfter,
|
||||
dateBefore,
|
||||
contents,
|
||||
});
|
||||
const cached = readCachedSearchPayload(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
const results = await runExaSearch({
|
||||
apiKey,
|
||||
endpoint,
|
||||
query,
|
||||
count: resolvedCount,
|
||||
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 Object.assign(
|
||||
{
|
||||
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,
|
||||
buildExaCacheKey,
|
||||
resolveExaApiKey,
|
||||
resolveExaConfig,
|
||||
resolveExaDescription,
|
||||
resolveExaSearchCount,
|
||||
resolveExaSearchEndpoint,
|
||||
resolveFreshnessStartDate,
|
||||
readExaErrorDetail,
|
||||
readExaSearchResults,
|
||||
} as const;
|
||||
export { testing as __testing };
|
||||
27
extensions/exa/src/exa-web-search-provider.shared.ts
Normal file
27
extensions/exa/src/exa-web-search-provider.shared.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Exa provider module implements model/runtime integration.
|
||||
import { createWebSearchProviderContractFields } from "openclaw/plugin-sdk/provider-web-search-contract";
|
||||
|
||||
const EXA_CREDENTIAL_PATH = "plugins.entries.exa.config.webSearch.apiKey";
|
||||
const EXA_ONBOARDING_SCOPES: Array<"text-inference"> = ["text-inference"];
|
||||
|
||||
export function createExaWebSearchProviderBase() {
|
||||
return {
|
||||
id: "exa",
|
||||
label: "Exa Search",
|
||||
hint: "Neural + keyword search with date filters and content extraction",
|
||||
onboardingScopes: [...EXA_ONBOARDING_SCOPES],
|
||||
credentialLabel: "Exa API key",
|
||||
envVars: ["EXA_API_KEY"],
|
||||
placeholder: "exa-...",
|
||||
signupUrl: "https://exa.ai/",
|
||||
docsUrl: "https://docs.openclaw.ai/tools/web",
|
||||
autoDetectOrder: 65,
|
||||
credentialPath: EXA_CREDENTIAL_PATH,
|
||||
...createWebSearchProviderContractFields({
|
||||
credentialPath: EXA_CREDENTIAL_PATH,
|
||||
searchCredential: { type: "scoped", scopeId: "exa" },
|
||||
configuredCredential: { pluginId: "exa" },
|
||||
selectionPluginId: "exa",
|
||||
}),
|
||||
};
|
||||
}
|
||||
331
extensions/exa/src/exa-web-search-provider.test.ts
Normal file
331
extensions/exa/src/exa-web-search-provider.test.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
// Exa tests cover exa web search provider plugin behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { testing } from "../test-api.js";
|
||||
import { createExaWebSearchProvider as createContractExaWebSearchProvider } from "../web-search-contract-api.js";
|
||||
import { createExaWebSearchProvider } from "./exa-web-search-provider.js";
|
||||
|
||||
function cancelTrackedResponse(
|
||||
text: string,
|
||||
init: ResponseInit,
|
||||
): {
|
||||
response: Response;
|
||||
wasCanceled: () => boolean;
|
||||
} {
|
||||
let canceled = false;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(text));
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
});
|
||||
return {
|
||||
response: new Response(stream, init),
|
||||
wasCanceled: () => canceled,
|
||||
};
|
||||
}
|
||||
|
||||
function streamingJsonResponse(params: { chunkCount: number; chunkSize: number }): {
|
||||
response: Response;
|
||||
getReadCount: () => number;
|
||||
} {
|
||||
// Streaming fixture proves an oversized success body stops being read before
|
||||
// the whole payload is buffered into memory.
|
||||
let reads = 0;
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (reads >= params.chunkCount) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
reads += 1;
|
||||
controller.enqueue(encoder.encode("a".repeat(params.chunkSize)));
|
||||
},
|
||||
});
|
||||
return {
|
||||
response: new Response(stream, {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
getReadCount: () => reads,
|
||||
};
|
||||
}
|
||||
|
||||
describe("exa web search provider", () => {
|
||||
it("exposes the expected metadata and selection wiring", () => {
|
||||
const provider = createExaWebSearchProvider();
|
||||
if (!provider.applySelectionConfig) {
|
||||
throw new Error("Expected applySelectionConfig to be defined");
|
||||
}
|
||||
const applied = provider.applySelectionConfig({});
|
||||
|
||||
expect(provider.id).toBe("exa");
|
||||
expect(provider.onboardingScopes).toEqual(["text-inference"]);
|
||||
expect(provider.credentialPath).toBe("plugins.entries.exa.config.webSearch.apiKey");
|
||||
const pluginEntry = applied.plugins?.entries?.exa;
|
||||
if (!pluginEntry) {
|
||||
throw new Error("expected Exa plugin entry");
|
||||
}
|
||||
expect(pluginEntry.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the lightweight contract surface aligned with provider metadata", () => {
|
||||
const provider = createExaWebSearchProvider();
|
||||
const contractProvider = createContractExaWebSearchProvider();
|
||||
if (!contractProvider.applySelectionConfig) {
|
||||
throw new Error("Expected contract applySelectionConfig to be defined");
|
||||
}
|
||||
const applied = contractProvider.applySelectionConfig({});
|
||||
|
||||
expect({
|
||||
id: contractProvider.id,
|
||||
label: contractProvider.label,
|
||||
hint: contractProvider.hint,
|
||||
onboardingScopes: contractProvider.onboardingScopes,
|
||||
credentialLabel: contractProvider.credentialLabel,
|
||||
envVars: contractProvider.envVars,
|
||||
placeholder: contractProvider.placeholder,
|
||||
signupUrl: contractProvider.signupUrl,
|
||||
docsUrl: contractProvider.docsUrl,
|
||||
autoDetectOrder: contractProvider.autoDetectOrder,
|
||||
credentialPath: contractProvider.credentialPath,
|
||||
}).toEqual({
|
||||
id: provider.id,
|
||||
label: provider.label,
|
||||
hint: provider.hint,
|
||||
onboardingScopes: provider.onboardingScopes,
|
||||
credentialLabel: provider.credentialLabel,
|
||||
envVars: provider.envVars,
|
||||
placeholder: provider.placeholder,
|
||||
signupUrl: provider.signupUrl,
|
||||
docsUrl: provider.docsUrl,
|
||||
autoDetectOrder: provider.autoDetectOrder,
|
||||
credentialPath: provider.credentialPath,
|
||||
});
|
||||
expect(contractProvider.createTool({ config: {}, searchConfig: {} })).toBeNull();
|
||||
const pluginEntry = applied.plugins?.entries?.exa;
|
||||
if (!pluginEntry) {
|
||||
throw new Error("expected contract Exa plugin entry");
|
||||
}
|
||||
expect(pluginEntry.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("prefers scoped configured api keys over environment fallbacks", () => {
|
||||
expect(testing.resolveExaApiKey({ apiKey: "exa-secret" })).toBe("exa-secret");
|
||||
});
|
||||
|
||||
it("resolves Exa search base URL overrides", () => {
|
||||
expect(testing.resolveExaSearchEndpoint()).toEqual({
|
||||
endpoint: "https://api.exa.ai/search",
|
||||
});
|
||||
expect(testing.resolveExaSearchEndpoint({ baseUrl: "https://proxy.example/exa" })).toEqual({
|
||||
endpoint: "https://proxy.example/exa/search",
|
||||
});
|
||||
expect(testing.resolveExaSearchEndpoint({ baseUrl: "proxy.example/exa/search/" })).toEqual({
|
||||
endpoint: "https://proxy.example/exa/search",
|
||||
});
|
||||
expect(testing.resolveExaSearchEndpoint({ baseUrl: "ftp://proxy.example/exa" })).toEqual({
|
||||
docs: "https://docs.openclaw.ai/tools/exa-search",
|
||||
error: "invalid_base_url",
|
||||
message:
|
||||
"plugins.entries.exa.config.webSearch.baseUrl must be a valid http(s) URL. Got: ftp://proxy.example/exa",
|
||||
});
|
||||
});
|
||||
|
||||
it("partitions Exa cache keys by resolved endpoint", () => {
|
||||
const base = {
|
||||
type: "auto" as const,
|
||||
query: "openclaw",
|
||||
count: 5,
|
||||
};
|
||||
expect(
|
||||
testing.buildExaCacheKey({
|
||||
...base,
|
||||
endpoint: "https://api.exa.ai/search",
|
||||
}),
|
||||
).not.toBe(
|
||||
testing.buildExaCacheKey({
|
||||
...base,
|
||||
endpoint: "https://proxy.example/exa/search",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes Exa result descriptions from highlights before text", () => {
|
||||
expect(
|
||||
testing.resolveExaDescription({
|
||||
highlights: ["first", "", "second"],
|
||||
text: "full text",
|
||||
}),
|
||||
).toBe("first\nsecond");
|
||||
expect(testing.resolveExaDescription({ text: "full text" })).toBe("full text");
|
||||
});
|
||||
|
||||
it("handles month freshness without date overflow", () => {
|
||||
const iso = testing.resolveFreshnessStartDate("month");
|
||||
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 },
|
||||
}),
|
||||
).toEqual({
|
||||
error: "invalid_contents",
|
||||
message: "contents.highlights.numSentences must be a positive integer.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
expect(testing.resolveExaSearchCount("+05", 10)).toBe(5);
|
||||
expect(testing.resolveExaSearchCount("0x10", 10)).toBe(10);
|
||||
expect(testing.resolveExaSearchCount("1e2", 10)).toBe(10);
|
||||
expect(testing.resolveExaSearchCount(1.5, 10)).toBe(10);
|
||||
});
|
||||
|
||||
it("returns validation errors for conflicting time filters", async () => {
|
||||
const provider = createExaWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {},
|
||||
searchConfig: { exa: { apiKey: "exa-secret" } },
|
||||
});
|
||||
if (!tool) {
|
||||
throw new Error("Expected tool definition");
|
||||
}
|
||||
|
||||
const result = await tool.execute({
|
||||
query: "latest gpu news",
|
||||
freshness: "day",
|
||||
date_after: "2026-03-01",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
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",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns validation errors for invalid date input", async () => {
|
||||
const provider = createExaWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {},
|
||||
searchConfig: { exa: { apiKey: "exa-secret" } },
|
||||
});
|
||||
if (!tool) {
|
||||
throw new Error("Expected tool definition");
|
||||
}
|
||||
|
||||
const result = await tool.execute({
|
||||
query: "latest gpu news",
|
||||
date_after: "2026-02-31",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
error: "invalid_date",
|
||||
message: "date_after must be YYYY-MM-DD format.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports malformed Exa API JSON with a stable provider error", async () => {
|
||||
await expect(testing.readExaSearchResults(new Response("{ nope"))).rejects.toThrow(
|
||||
"Exa API returned malformed JSON",
|
||||
);
|
||||
});
|
||||
|
||||
it("parses well-formed Exa search JSON under the byte cap", async () => {
|
||||
const response = new Response(
|
||||
JSON.stringify({ results: [{ url: "https://example.com", title: "Example" }] }),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
|
||||
await expect(testing.readExaSearchResults(response)).resolves.toEqual([
|
||||
{ url: "https://example.com", title: "Example" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("caps oversized Exa search JSON instead of buffering the whole body", async () => {
|
||||
const streamed = streamingJsonResponse({ chunkCount: 64, chunkSize: 1024 });
|
||||
|
||||
await expect(
|
||||
testing.readExaSearchResults(streamed.response, { maxBytes: 4096 }),
|
||||
).rejects.toThrow(/Exa API response exceeds 4096 bytes/);
|
||||
|
||||
expect(streamed.getReadCount()).toBeLessThan(64);
|
||||
});
|
||||
|
||||
it("bounds Exa API error bodies without using response.text()", async () => {
|
||||
const tracked = cancelTrackedResponse(`${"exa upstream unavailable ".repeat(1024)}tail`, {
|
||||
status: 503,
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
|
||||
|
||||
const detail = await testing.readExaErrorDetail(tracked.response);
|
||||
|
||||
expect(detail).toContain("exa upstream unavailable");
|
||||
expect(detail).not.toContain("tail");
|
||||
expect(await testing.readExaErrorDetail(new Response("short"))).toBe("short");
|
||||
expect(tracked.wasCanceled()).toBe(true);
|
||||
expect(textSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
76
extensions/exa/src/exa-web-search-provider.ts
Normal file
76
extensions/exa/src/exa-web-search-provider.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
// Exa provider module implements model/runtime integration.
|
||||
import type { WebSearchProviderPlugin } from "openclaw/plugin-sdk/provider-web-search-contract";
|
||||
import { createExaWebSearchProviderBase } from "./exa-web-search-provider.shared.js";
|
||||
|
||||
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;
|
||||
|
||||
const loadExaWebSearchRuntime = createLazyRuntimeModule(
|
||||
() => import("./exa-web-search-provider.runtime.js"),
|
||||
);
|
||||
|
||||
const ExaSearchSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Search query string." },
|
||||
count: {
|
||||
type: "integer",
|
||||
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: [...EXA_FRESHNESS_VALUES],
|
||||
description: 'Filter by time: "day", "week", "month", or "year".',
|
||||
},
|
||||
date_after: {
|
||||
type: "string",
|
||||
description: "Only results published after this date (YYYY-MM-DD).",
|
||||
},
|
||||
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: "object",
|
||||
properties: {
|
||||
highlights: {
|
||||
description:
|
||||
"Highlights config: true, or an object with maxCharacters, query, numSentences, or highlightsPerUrl.",
|
||||
},
|
||||
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<string, unknown>;
|
||||
|
||||
export function createExaWebSearchProvider(): WebSearchProviderPlugin {
|
||||
return {
|
||||
...createExaWebSearchProviderBase(),
|
||||
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 loadExaWebSearchRuntime();
|
||||
return await executeExaWebSearchProviderTool(ctx, args);
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
2
extensions/exa/test-api.ts
Normal file
2
extensions/exa/test-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Exa API module exposes the plugin public contract.
|
||||
export { testing, testing as __testing } from "./src/exa-web-search-provider.runtime.js";
|
||||
16
extensions/exa/tsconfig.json
Normal file
16
extensions/exa/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
10
extensions/exa/web-search-contract-api.ts
Normal file
10
extensions/exa/web-search-contract-api.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Exa API module exposes the plugin public contract.
|
||||
import type { WebSearchProviderPlugin } from "openclaw/plugin-sdk/provider-web-search-contract";
|
||||
import { createExaWebSearchProviderBase } from "./src/exa-web-search-provider.shared.js";
|
||||
|
||||
export function createExaWebSearchProvider(): WebSearchProviderPlugin {
|
||||
return {
|
||||
...createExaWebSearchProviderBase(),
|
||||
createTool: () => null,
|
||||
};
|
||||
}
|
||||
2
extensions/exa/web-search-provider.ts
Normal file
2
extensions/exa/web-search-provider.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Exa provider module implements model/runtime integration.
|
||||
export { createExaWebSearchProvider } from "./src/exa-web-search-provider.js";
|
||||
Reference in New Issue
Block a user