Skip to content

feat(tools): add Exa as native web search provider extension#49319

Closed
V-Gutierrez wants to merge 2 commits intoopenclaw:mainfrom
V-Gutierrez:feat/exa-clean
Closed

feat(tools): add Exa as native web search provider extension#49319
V-Gutierrez wants to merge 2 commits intoopenclaw:mainfrom
V-Gutierrez:feat/exa-clean

Conversation

@V-Gutierrez
Copy link
Copy Markdown

Summary

Adds Exa as a native web search provider, implemented as a plugin extension following the same architecture used by firecrawl and moonshot.

Supersedes #29322. The original PR was built against the legacy inline provider pattern in web-search-core.ts. Since then, the project migrated to a plugin-based extension architecture (extensions/). This PR reimplements Exa as a native extension following the same pattern used by firecrawl and moonshot, aligning with the project's current direction.

Features

  • Neural, keyword, and auto search modes via Exa's hybrid engine
  • Publication date filters (date_after, date_before, freshness)
  • Optional content extraction (highlights and full text)
  • Scoped credential support (tools.web.search.exa.apiKey) + EXA_API_KEY env fallback
  • Auto-detect with order 25
  • Full onboarding flow integration (openclaw onboard)

Architecture

extensions/exa/
├── index.ts                    # Plugin entry (definePluginEntry + registerWebSearchProvider)
├── index.test.ts               # Plugin + unit tests (4 tests)
├── openclaw.plugin.json         # Plugin manifest
├── package.json
└── src/
    └── exa-search-provider.ts  # WebSearchProviderPlugin implementation

Registers via api.registerWebSearchProvider() — zero modifications to the provider dispatch logic in web-search-core.ts beyond adding exa to the provider list.

Files Changed (18)

Area Files What
Extension 5 new Plugin entry, provider, manifest, tests
Core web-search-core.ts Provider list + Exa search/fetch dispatch
Config types.tools.ts, zod-schema, schema.help, schema.labels Exa config schema + help text
Onboard onboard-search.ts Exa onboarding flow
Plugins web-search-providers.ts, registry.ts Register exa extension
Tests 4 test files 103 tests passing
Docs docs/tools/web.md Exa provider documentation

Testing

  • 103 tests passing across 5 test files
  • TypeScript strict — zero compilation errors
  • Covers: plugin registration, result normalization, date parsing, freshness resolution, credential scoping

Configuration

# openclaw.yaml
tools:
  web:
    search:
      provider: exa
      exa:
        apiKey: exa-...

Or via environment: EXA_API_KEY=exa-...

@openclaw-barnacle openclaw-barnacle bot added docs Improvements or additions to documentation commands Command implementations agents Agent runtime and tooling size: XL labels Mar 18, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 18, 2026

Greptile Summary

This PR adds Exa as a native web search provider, correctly following the plugin-based extension architecture used by firecrawl and moonshot. The plugin registers via api.registerWebSearchProvider(), and web-search-core.ts delegates all Exa HTTP logic to the plugin rather than duplicating it inline. Config schema, type definitions, help text, labels, onboarding flow, and documentation are all updated consistently.

Key issues found:

  • Month-boundary overflow in freshness calculation (extensions/exa/src/exa-search-provider.ts:191, src/agents/tools/web-search-core.ts:1287): setUTCMonth(getUTCMonth() - 1) overflows when the current day-of-month exceeds the number of days in the target month (e.g., on March 31, "last month" resolves to March 2/3 instead of February 28/29), returning wrong date-range results for the month freshness filter.
  • Configured timeoutSeconds silently ignored for Exa (src/agents/tools/web-search-core.ts:1999): All other providers in runWebSearch forward params.timeoutSeconds to their HTTP helpers, but the Exa delegation block omits it, causing the plugin to always use its hardcoded 30 s default regardless of tools.web.search.timeoutSeconds configuration.

Confidence Score: 3/5

  • Mostly safe to merge, but the month-boundary bug in freshness date calculation will silently return wrong date-range results on end-of-month dates.
  • The plugin architecture is sound and follows existing patterns well. Two issues prevent a higher score: (1) a real logic bug in setUTCMonth that produces incorrect startPublishedDate for the month freshness option on certain calendar days, and (2) the user-configured timeout being silently ignored for Exa requests. The core functional path is otherwise correct, tests pass, and the integration with the plugin registry is clean.
  • extensions/exa/src/exa-search-provider.ts (month-boundary in resolveFreshnessStartDate) and src/agents/tools/web-search-core.ts (same bug in resolveExaFreshnessStartDate, plus missing timeoutSeconds forwarding in the plugin delegation block).
Prompt To Fix All With AI
This is a comment left during a code review.
Path: extensions/exa/src/exa-search-provider.ts
Line: 191

Comment:
**`setUTCMonth` month-boundary overflow**

`Date.prototype.setUTCMonth(month - 1)` has a well-known edge case: when the current day-of-month is larger than the number of days in the target month, JavaScript silently overflows into the next month. Concretely, on March 31 this becomes:

```
date.setUTCMonth(1)  // Feb has 28/29 days → overflows to March 2 or 3
```

so "last month" from March 31 resolves to `2026-03-02` or `2026-03-03` instead of `2026-02-28`. The date range would be only ~28 days, but offset by a full month, effectively returning wrong results.

The same bug exists in `src/agents/tools/web-search-core.ts` at line 1287 inside `resolveExaFreshnessStartDate`.

A safe fix is to clamp the day before setting the month:

```suggestion
  } else if (freshness === "month") {
    const targetMonth = date.getUTCMonth() - 1;
    date.setUTCMonth(targetMonth);
    // Clamp overflow (e.g. March 31 → Feb 28/29)
    const expectedMonth = ((targetMonth % 12) + 12) % 12;
    if (date.getUTCMonth() !== expectedMonth) {
      date.setUTCDate(0); // last day of the overflowed-back month
    }
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/agents/tools/web-search-core.ts
Line: 1999-2007

Comment:
**Configured `timeoutSeconds` not forwarded to Exa plugin**

All other provider dispatch paths in `runWebSearch` forward `params.timeoutSeconds` to their underlying request functions (e.g. Perplexity at line 1851, Gemini at line 1965, Brave at line 2020). When the Exa path delegates to the plugin here, it omits `timeoutSeconds`, so the plugin always falls back to its internal `DEFAULT_TIMEOUT_SECONDS = 30` regardless of what the user has set in `tools.web.search.timeoutSeconds`.

The plugin's `execute()` signature doesn't expose a `timeoutSeconds` arg, but `runExaSearch` does accept one. Consider surfacing this through the `searchConfig` context or the tool's args so the user-configured value is respected.

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: "fix: move mock-heavy..."

Comment on lines +45 to +79
freshness: Type.Optional(
Type.String({
description: "Time filter: day, week, month, year (also accepts pd/pw/pm/py).",
}),
),
date_after: Type.Optional(
Type.String({
description: "Only include results published after this date (YYYY-MM-DD or ISO datetime).",
}),
),
date_before: Type.Optional(
Type.String({
description:
"Only include results published before this date (YYYY-MM-DD or ISO datetime).",
}),
),
type: Type.Optional(
Type.Union([Type.Literal("neural"), Type.Literal("keyword"), Type.Literal("auto")], {
description: "Exa search mode (neural, keyword, or auto). Default: auto.",
}),
),
contents: Type.Optional(
Type.Object(
{
highlights: Type.Optional(
Type.Boolean({ description: "Include Exa highlights in results." }),
),
text: Type.Optional(Type.Boolean({ description: "Include full text in results." })),
},
{ additionalProperties: false },
),
),
},
{ additionalProperties: false },
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing freshness + date-range conflict validation

The ExaSearchSchema exposes freshness, date_after, and date_before as independent optional fields with no mutual-exclusivity constraint. This means a caller can supply both freshness="week" and date_after="2024-01-01" at the same time — the plugin silently accepts this and simply lets date_after win (line 252–254).

The core dispatch path in web-search-core.ts (lines 2403–2409) explicitly rejects this combination with a "conflicting_time_filters" error:

"freshness and date_after/date_before cannot be used together. Use either freshness …"

Because both paths are active in this PR, the same invalid input now produces different outcomes depending on which code path handles the request — a silent no-op on the plugin path vs. a clear error on the core path. Aligning the plugin's schema or execute handler to detect and reject the conflict would make behavior consistent and help users understand why their freshness filter isn't working.

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/exa/src/exa-search-provider.ts
Line: 45-79

Comment:
**Missing `freshness` + date-range conflict validation**

The `ExaSearchSchema` exposes `freshness`, `date_after`, and `date_before` as independent optional fields with no mutual-exclusivity constraint. This means a caller can supply both `freshness="week"` and `date_after="2024-01-01"` at the same time — the plugin silently accepts this and simply lets `date_after` win (line 252–254).

The core dispatch path in `web-search-core.ts` (lines 2403–2409) explicitly rejects this combination with a `"conflicting_time_filters"` error:

```
"freshness and date_after/date_before cannot be used together. Use either freshness …"
```

Because both paths are active in this PR, the same invalid input now produces different outcomes depending on which code path handles the request — a silent no-op on the plugin path vs. a clear error on the core path. Aligning the plugin's schema or `execute` handler to detect and reject the conflict would make behavior consistent and help users understand why their freshness filter isn't working.

How can I resolve this? If you propose a fix, please make it concise.

} from "./web-shared.js";

const SEARCH_PROVIDERS = ["brave", "gemini", "grok", "kimi", "perplexity"] as const;
const SEARCH_PROVIDERS = ["brave", "gemini", "exa", "grok", "kimi", "perplexity"] as const;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Dual implementation of Exa logic across core and plugin

This PR adds "exa" to SEARCH_PROVIDERS here, and then adds a full inline Exa dispatch path to web-search-core.ts — including runExaSearch, resolveExaConfig, resolveExaApiKey, resolveExaType, resolveExaContents, normalizeExaFreshness, resolveExaFreshnessStartDate, resolveExaDescription, etc. (see grep hits at lines ~923–2588). This is in addition to the full plugin implementation in extensions/exa/src/exa-search-provider.ts.

firecrawl, which the PR cites as the architectural pattern to follow, exists only as a plugin — it has no inline dispatch in web-search-core.ts. Exa now has both.

The PR description states "zero modifications to the provider dispatch logic in web-search-core.ts beyond adding exa to the provider list" — but in practice hundreds of lines of Exa-specific dispatch logic were added. This creates a maintenance burden: any bug fix (e.g. in freshness-start-date calculation, date normalization, or content extraction) would need to be applied in two separate, independently-maintained implementations.

Consider whether the inline web-search-core.ts path is intentional (e.g. as a transition shim for the legacy code path) and, if so, documenting that explicitly, or whether the inline path can be removed in favour of the plugin-only dispatch.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/tools/web-search-core.ts
Line: 25

Comment:
**Dual implementation of Exa logic across core and plugin**

This PR adds `"exa"` to `SEARCH_PROVIDERS` here, and then adds a full inline Exa dispatch path to `web-search-core.ts` — including `runExaSearch`, `resolveExaConfig`, `resolveExaApiKey`, `resolveExaType`, `resolveExaContents`, `normalizeExaFreshness`, `resolveExaFreshnessStartDate`, `resolveExaDescription`, etc. (see grep hits at lines ~923–2588). This is in addition to the full plugin implementation in `extensions/exa/src/exa-search-provider.ts`.

`firecrawl`, which the PR cites as the architectural pattern to follow, exists **only** as a plugin — it has no inline dispatch in `web-search-core.ts`. Exa now has both.

The PR description states "zero modifications to the provider dispatch logic in `web-search-core.ts` beyond adding `exa` to the provider list" — but in practice hundreds of lines of Exa-specific dispatch logic were added. This creates a maintenance burden: any bug fix (e.g. in freshness-start-date calculation, date normalization, or content extraction) would need to be applied in two separate, independently-maintained implementations.

Consider whether the inline `web-search-core.ts` path is intentional (e.g. as a transition shim for the legacy code path) and, if so, documenting that explicitly, or whether the inline path can be removed in favour of the plugin-only dispatch.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +165 to +182
function toIsoDateTime(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
const date = new Date(`${trimmed}T00:00:00.000Z`);
return Number.isNaN(date.valueOf()) ? undefined : date.toISOString();
}
const date = new Date(trimmed);
if (Number.isNaN(date.valueOf())) {
return undefined;
}
return date.toISOString();
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Invalid date strings silently produce no date filter in the plugin

toIsoDateTime returns undefined when it receives an invalid date string (e.g. "not-a-date"). The execute handler at line 315–316 passes the raw args.date_after through toIsoDateTime and then forwards the result (possibly undefined) to runExaSearch. In runExaSearch, if (params.dateAfter) is then false, so the date filter is silently dropped.

The inline dispatch in web-search-core.ts (lines 2434–2440) validates the date string upfront with normalizeToIsoDate and returns a structured "invalid_date" error to the caller before the search ever runs.

This means:

  • Core path: date_after="not-a-date" → explicit "invalid_date" error.
  • Plugin path: date_after="not-a-date" → search runs without the date filter, returning silently broader results.

Adding explicit validation (or at minimum logging a warning) in the plugin's execute block before calling runExaSearch would keep the two paths consistent and make the silent failure visible.

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/exa/src/exa-search-provider.ts
Line: 165-182

Comment:
**Invalid date strings silently produce no date filter in the plugin**

`toIsoDateTime` returns `undefined` when it receives an invalid date string (e.g. `"not-a-date"`). The `execute` handler at line 315–316 passes the raw `args.date_after` through `toIsoDateTime` and then forwards the result (possibly `undefined`) to `runExaSearch`. In `runExaSearch`, `if (params.dateAfter)` is then `false`, so the date filter is silently dropped.

The inline dispatch in `web-search-core.ts` (lines 2434–2440) validates the date string upfront with `normalizeToIsoDate` and returns a structured `"invalid_date"` error to the caller before the search ever runs.

This means:
- **Core path**: `date_after="not-a-date"` → explicit `"invalid_date"` error.
- **Plugin path**: `date_after="not-a-date"` → search runs without the date filter, returning silently broader results.

Adding explicit validation (or at minimum logging a warning) in the plugin's `execute` block before calling `runExaSearch` would keep the two paths consistent and make the silent failure visible.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bb19fdfb3a

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +1296 to +1297
} else if (freshness === "month") {
date.setUTCMonth(date.getUTCMonth() - 1);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clamp Exa month freshness before subtracting a month

When freshness: "month" is used on the 29th–31st of a long month, setUTCMonth(date.getUTCMonth() - 1) can overflow back into the current month (2026-03-31 becomes 2026-03-03 in Node). That makes the Exa request's startPublishedDate only a few days old, so month-filtered searches near month-end return much newer results than requested.

Useful? React with 👍 / 👎.

Comment on lines +934 to +935
function resolveExaApiKey(exa?: ExaConfig): string | undefined {
const fromConfig = normalizeApiKey(exa?.apiKey);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve Exa SecretRefs before treating the API key as empty

Exa is now exposed through onboarding, including --secret-input-mode ref, but this helper only accepts literal strings. In flows that read the raw config without an active runtime snapshot, a configured tools.web.search.exa.apiKey: { source: "env", ... } is normalized to empty here, so web_search reports missing_exa_api_key instead of honoring or surfacing the configured reference.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

const dateAfter = rawDateAfter ? normalizeToIsoDate(rawDateAfter) : undefined;
if (rawDateAfter && !dateAfter) {
return jsonResult({
error: "invalid_date",
message: "date_after must be YYYY-MM-DD format.",

P2 Badge Allow ISO datetimes for Exa date filters

The Exa provider added in this commit explicitly accepts YYYY-MM-DD or ISO datetimes in extensions/exa/src/exa-search-provider.ts, but the main web_search path still validates Exa dates with normalizeToIsoDate(). As a result, calls like web_search({ date_after: "2024-06-01T12:00:00Z" }) are rejected with invalid_date before they ever reach the provider, so the finer-grained publication-date filtering exposed by the plugin is not actually available through the tool.

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +1988 to +1989
const providers = resolvePluginWebSearchProviders({ bundledAllowlistCompat: true });
const exaPlugin = providers.find((p) => p.id === "exa");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Honor plugin disablement when dispatching Exa searches

If an admin disables the bundled Exa plugin via plugins.enabled=false, a restrictive plugins.allow, or plugins.entries.exa.enabled=false, web_search still reaches Exa here because it reloads bundled providers without passing the current config. I checked src/plugins/web-search-providers.ts:94-106, and those gates are only applied when config is provided. In practice, tools.web.search.provider: exa can keep making outbound Exa requests even after the plugin was explicitly disabled.

Useful? React with 👍 / 👎.

Comment on lines +1320 to +1327
if (
("highlights" in raw && typeof raw.highlights !== "boolean") ||
("text" in raw && typeof raw.text !== "boolean")
) {
return "invalid";
}

return Object.keys(next).length > 0 ? next : undefined;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject unknown Exa contents keys

This parser only validates the types of highlights and text, so a payload like contents: { summaries: true } falls through to undefined and the search proceeds. That bypasses the strict validation the new Exa plugin added for unknown contents fields, which makes malformed calls look successful and can silently drop a caller's intended overrides instead of returning invalid_exa_contents.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e617d3e56f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +2401 to +2405
if (provider === "exa" && params.type !== undefined && !rawExaType) {
return jsonResult({
error: "invalid_exa_type",
message: 'Exa type must be "neural", "keyword", or "auto".',
docs: "https://docs.openclaw.ai/tools/web",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept Exa's current search-mode enum

Exa's current /search API accepts fast, deep, deep-reasoning, and instant in addition to neural/auto, while keyword is no longer documented. Restricting type to neural|keyword|auto here means any web_search call (and the matching config values added in this patch) that uses a supported deep/fast mode is rejected with invalid_exa_type before the request is sent, so users cannot access the modes Exa currently exposes.

Useful? React with 👍 / 👎.

Comment on lines +2451 to +2454
exaContents:
exaContentsParam === "invalid"
? undefined
: (exaContentsParam ?? resolveExaContents(exaConfig)),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat empty contents as an explicit Exa override

When tools.web.search.exa.contents is set in config, passing contents: {} on an individual web_search call should disable content enrichment for that call, but readExaContentsParam({}) returns undefined and this fallback reapplies the config defaults. In practice there is no way to opt out per request once text or highlights are configured globally, so callers can unexpectedly pull full-page text into requests that explicitly passed an empty contents object.

Useful? React with 👍 / 👎.

@V-Gutierrez V-Gutierrez reopened this Mar 18, 2026
} else if (freshness === "week") {
date.setUTCDate(date.getUTCDate() - 7);
} else if (freshness === "month") {
date.setUTCMonth(date.getUTCMonth() - 1);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 setUTCMonth month-boundary overflow

Date.prototype.setUTCMonth(month - 1) has a well-known edge case: when the current day-of-month is larger than the number of days in the target month, JavaScript silently overflows into the next month. Concretely, on March 31 this becomes:

date.setUTCMonth(1)  // Feb has 28/29 days → overflows to March 2 or 3

so "last month" from March 31 resolves to 2026-03-02 or 2026-03-03 instead of 2026-02-28. The date range would be only ~28 days, but offset by a full month, effectively returning wrong results.

The same bug exists in src/agents/tools/web-search-core.ts at line 1287 inside resolveExaFreshnessStartDate.

A safe fix is to clamp the day before setting the month:

Suggested change
date.setUTCMonth(date.getUTCMonth() - 1);
} else if (freshness === "month") {
const targetMonth = date.getUTCMonth() - 1;
date.setUTCMonth(targetMonth);
// Clamp overflow (e.g. March 31 → Feb 28/29)
const expectedMonth = ((targetMonth % 12) + 12) % 12;
if (date.getUTCMonth() !== expectedMonth) {
date.setUTCDate(0); // last day of the overflowed-back month
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/exa/src/exa-search-provider.ts
Line: 191

Comment:
**`setUTCMonth` month-boundary overflow**

`Date.prototype.setUTCMonth(month - 1)` has a well-known edge case: when the current day-of-month is larger than the number of days in the target month, JavaScript silently overflows into the next month. Concretely, on March 31 this becomes:

```
date.setUTCMonth(1)  // Feb has 28/29 days → overflows to March 2 or 3
```

so "last month" from March 31 resolves to `2026-03-02` or `2026-03-03` instead of `2026-02-28`. The date range would be only ~28 days, but offset by a full month, effectively returning wrong results.

The same bug exists in `src/agents/tools/web-search-core.ts` at line 1287 inside `resolveExaFreshnessStartDate`.

A safe fix is to clamp the day before setting the month:

```suggestion
  } else if (freshness === "month") {
    const targetMonth = date.getUTCMonth() - 1;
    date.setUTCMonth(targetMonth);
    // Clamp overflow (e.g. March 31 → Feb 28/29)
    const expectedMonth = ((targetMonth % 12) + 12) % 12;
    if (date.getUTCMonth() !== expectedMonth) {
      date.setUTCDate(0); // last day of the overflowed-back month
    }
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +1999 to +2007
const result = await tool.execute({
query: params.query,
count: params.count,
freshness: params.freshness,
date_after: params.dateAfter,
date_before: params.dateBefore,
type: params.exaType,
contents: params.exaContents,
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Configured timeoutSeconds not forwarded to Exa plugin

All other provider dispatch paths in runWebSearch forward params.timeoutSeconds to their underlying request functions (e.g. Perplexity at line 1851, Gemini at line 1965, Brave at line 2020). When the Exa path delegates to the plugin here, it omits timeoutSeconds, so the plugin always falls back to its internal DEFAULT_TIMEOUT_SECONDS = 30 regardless of what the user has set in tools.web.search.timeoutSeconds.

The plugin's execute() signature doesn't expose a timeoutSeconds arg, but runExaSearch does accept one. Consider surfacing this through the searchConfig context or the tool's args so the user-configured value is respected.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/tools/web-search-core.ts
Line: 1999-2007

Comment:
**Configured `timeoutSeconds` not forwarded to Exa plugin**

All other provider dispatch paths in `runWebSearch` forward `params.timeoutSeconds` to their underlying request functions (e.g. Perplexity at line 1851, Gemini at line 1965, Brave at line 2020). When the Exa path delegates to the plugin here, it omits `timeoutSeconds`, so the plugin always falls back to its internal `DEFAULT_TIMEOUT_SECONDS = 30` regardless of what the user has set in `tools.web.search.timeoutSeconds`.

The plugin's `execute()` signature doesn't expose a `timeoutSeconds` arg, but `runExaSearch` does accept one. Consider surfacing this through the `searchConfig` context or the tool's args so the user-configured value is respected.

How can I resolve this? If you propose a fix, please make it concise.

Implements Exa (exa.ai) as a bundled plugin extension following the same
architecture used by brave, firecrawl, google, moonshot, perplexity, and xai.

- New extension at extensions/exa/ with full provider implementation
- Neural, keyword, and auto search types
- Content extraction via highlights and text fields
- Freshness filters with month-boundary overflow protection
- Uses plugin-sdk helpers (resolveSearchTimeoutSeconds, buildSearchCacheKey,
  readCachedSearchPayload, normalizeFreshness, etc.)
- Auto-detect order 25 (between gemini:20 and grok:30)
- Config: EXA_API_KEY env or plugins.entries.exa.config.webSearch.apiKey
- 16 pure tests (no vi.mock — compatible with extension CI runner)
- Documentation updated in docs/tools/web.md

Supersedes openclaw#29322 which used the legacy inline provider pattern.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 820280057f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +280 to +283
exa: z
.object({
apiKey: SecretInputSchema.optional().register(sensitive),
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Map legacy Exa config through normalization

Adding tools.web.search.exa.apiKey here makes the legacy compatibility path look supported, but validateConfigObjectRaw() first runs normalizeLegacyWebSearchConfig(), and that helper only migrates brave/firecrawl/gemini/grok/kimi/perplexity. In configs like tools.web.search.provider: "exa" with tools.web.search.exa.apiKey, the new exa object is dropped during validation, so users following the advertised one-release shim end up with missing_exa_api_key at runtime even though their config parsed successfully.

Useful? React with 👍 / 👎.

Comment on lines +71 to +75
readConfiguredSecretString(exaConfig.apiKey, "plugins.entries.exa.config.webSearch.apiKey") ??
readConfiguredSecretString(
(searchConfig as Record<string, unknown> | undefined)?.apiKey,
"tools.web.search.apiKey",
) ??
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stop reusing Brave's legacy API key for Exa

The fallback to tools.web.search.apiKey is Brave-specific (src/config/types.tools.ts marks it as the legacy Brave credential path), so reading it here makes Exa treat an old Brave key as a valid Exa credential. If someone switches tools.web.search.provider from Brave to Exa without adding plugins.entries.exa.config.webSearch.apiKey or EXA_API_KEY, web_search now sends the Brave token to Exa and fails with a provider 401 instead of returning the normal missing-key guidance.

Useful? React with 👍 / 👎.

Comment on lines +438 to +440
autoDetectOrder: 25,
credentialPath: "plugins.entries.exa.config.webSearch.apiKey",
inactiveSecretPaths: ["plugins.entries.exa.config.webSearch.apiKey"],
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Register Exa API-key paths with the secret target registry

This new credential path is not added to src/secrets/target-registry-data.ts, which is what discoverConfigSecretTargets, secret audits/plans, and the secrets.resolve gateway API use to enumerate valid secret-bearing config fields. As shipped, runtime can consume plugins.entries.exa.config.webSearch.apiKey, but secret tooling cannot discover or target Exa web-search keys, so those flows silently miss the new provider.

Useful? React with 👍 / 👎.

@openclaw-barnacle openclaw-barnacle bot added size: L and removed commands Command implementations size: XL labels Mar 18, 2026
…registry, legacy config normalization

- Remove tools.web.search.apiKey fallback from Exa (Brave-specific path)
- Register plugins.entries.exa.config.webSearch.apiKey in secret target registry
- Add exa to legacy web search config normalization map
@V-Gutierrez V-Gutierrez requested a review from a team as a code owner March 18, 2026 15:18
@V-Gutierrez
Copy link
Copy Markdown
Author

Review Feedback Addressed 🔧

Pushed a follow-up commit (533933e) addressing review comments from Greptile and Codex:

  1. Removed Brave legacy apiKey fallbackresolveExaApiKey() no longer falls back to tools.web.search.apiKey (which is Brave-specific). Now only checks plugins.entries.exa.config.webSearch.apiKey and EXA_API_KEY env.
  2. Secret target registry — Registered plugins.entries.exa.config.webSearch.apiKey in target-registry-data.ts so secret audits/plans/configure discover Exa credentials.
  3. Legacy config normalization — Added exa to LEGACY_PROVIDER_MAP and the normalization loop in legacy-web-search.ts, so tools.web.search.exa.apiKey configs are correctly migrated through the one-release shim.

Note on older review comments

Most Greptile/Codex comments reference web-search-core.ts (dual implementation, timeout forwarding, plugin disablement). These are no longer applicable — the reimplementation is plugin-only (extensions/exa/), and web-search-core.ts was removed in the upstream refactor (3de973f). There is no inline dispatch path.

Validation

  • tsc --noEmit
  • 16/16 extension tests pass (both standard and vitest.extensions.config.ts runners)
  • Existing tests unaffected

cc @Takhoffman (authored the web-search provider extraction refactor this PR builds on) @steipete — would appreciate a review when you get a chance 🙏

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 533933e4bc

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +131 to +135
if (params.dateAfter) {
body.startPublishedDate = params.dateAfter;
}
if (params.dateBefore) {
body.endPublishedDate = params.dateBefore;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Serialize Exa date filters as ISO datetimes

When callers use date_after or date_before, normalizeToIsoDate() only produces YYYY-MM-DD, and runExaSearch() forwards that string unchanged to Exa's startPublishedDate/endPublishedDate. I checked Exa's current Search reference, and those fields are documented as ISO 8601 date-time values, so this new date-filter feature now depends on undocumented coercion and can fail or silently skip the filter for any Exa search that uses explicit dates.

Useful? React with 👍 / 👎.

Comment on lines +433 to +435
docsUrl: "https://docs.openclaw.ai/tools/web",
autoDetectOrder: 25,
credentialPath: "plugins.entries.exa.config.webSearch.apiKey",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Move Exa ahead of Gemini in auto-detect precedence

If both EXA_API_KEY and GEMINI_API_KEY are present and no explicit provider is set, sortWebSearchProviders() will still pick Gemini first because Exa's new autoDetectOrder is 25 while Gemini remains 20. That makes the runtime disagree with the new precedence described in this patch, so users who add Exa alongside an existing Gemini key will continue to get Gemini unexpectedly until they set tools.web.search.provider manually.

Useful? React with 👍 / 👎.

Comment on lines +748 to +752
id: "plugins.entries.exa.config.webSearch.apiKey",
targetType: "plugins.entries.exa.config.webSearch.apiKey",
configFile: "openclaw.json",
pathPattern: "plugins.entries.exa.config.webSearch.apiKey",
secretShape: SECRET_INPUT_SHAPE,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Register the legacy Exa shim in secret tooling

This patch adds a one-release compatibility shim for tools.web.search.exa.*, but the registry addition here only covers plugins.entries.exa.config.webSearch.apiKey. As a result, secret discovery/audit/plan flows still cannot see tools.web.search.exa.apiKey, so users who keep the advertised legacy Exa path lose the secret-tooling support that the other web-search shims already provide.

Useful? React with 👍 / 👎.

@vincentkoc
Copy link
Copy Markdown
Member

I appreciate the earlier pass here.

I'm closing this as a duplicate of #52617. #49319 built the strongest implementation groundwork, but it was no longer merge-safe after the rebase and plugin-boundary changes. The landed PR carried that path forward on top of current main while keeping the Exa-specific filters and plugin ownership model.

The credit is preserved in the changelog trail and landing note. If I drew that line wrong, tell me and I can reopen review right away.

@vincentkoc vincentkoc closed this Mar 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling docs Improvements or additions to documentation size: L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants