From aacf29e1ded57617524c4962a4172be24d7b0158 Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 4 Jun 2026 13:28:14 +0000 Subject: [PATCH] fix(coding-agent): kept exa mcp fallback out of auto chain Reviewer noted that an unconditional ExaProvider.isAvailable steered the auto chain into the public MCP fallback before any later-configured provider could run. Restored the credential-gated isAvailable, then split out isExplicitlyAvailable so resolveProviderChain still routes an explicit Exa selection through MCP without affecting other providers.\n\nRefs #1860 --- docs/tools/web_search.md | 2 +- packages/coding-agent/src/web/search/index.ts | 4 +++- .../coding-agent/src/web/search/provider.ts | 2 +- .../src/web/search/providers/base.ts | 17 ++++++++++++++++ .../src/web/search/providers/exa.ts | 20 +++++++++++++++++-- .../test/tools/web-search-exa.test.ts | 12 +++++++++-- .../test/web/search/abort-and-timeout.test.ts | 1 + 7 files changed, 51 insertions(+), 7 deletions(-) diff --git a/docs/tools/web_search.md b/docs/tools/web_search.md index b599e4ac2..5b3aa380b 100644 --- a/docs/tools/web_search.md +++ b/docs/tools/web_search.md @@ -149,7 +149,7 @@ Streaming: none. `WebSearchTool.execute()` forwards its `AbortSignal` into `exec - `limit` and `num_search_results` are collapsed together before dispatch. - Output may include parsed free-text `answer`, `sources`, `requestId`. - **Exa** — `packages/coding-agent/src/web/search/providers/exa.ts` - - Availability: settings must not explicitly disable `exa.enabled` or `exa.enableSearch`; Exa can use public MCP when no credential exists. + - Availability: env or `agent.db` credential for `exa` admits Exa to the auto chain; settings must not explicitly disable `exa.enabled` or `exa.enableSearch`. Explicit selection (`providers.webSearch: exa`) reaches Exa even without a credential and falls back to public MCP. - Querying: POST `https://api.exa.ai/search` with the resolved Exa API key, otherwise JSON-RPC `tools/call` against `https://mcp.exa.ai/mcp` for remote MCP tool `web_search_exa`. - `limit` and `num_search_results` are collapsed together before dispatch. - Output: synthesized `answer` from up to 3 result summaries, `sources`, `requestId`. diff --git a/packages/coding-agent/src/web/search/index.ts b/packages/coding-agent/src/web/search/index.ts index f6fd599ce..065133636 100644 --- a/packages/coding-agent/src/web/search/index.ts +++ b/packages/coding-agent/src/web/search/index.ts @@ -131,7 +131,9 @@ async function executeSearch( const providers = params.provider && params.provider !== "auto" ? await getSearchProvider(params.provider).then(async provider => - (await provider.isAvailable(authStorage)) ? [provider] : resolveProviderChain(authStorage, "auto"), + (await provider.isExplicitlyAvailable(authStorage)) + ? [provider] + : resolveProviderChain(authStorage, "auto"), ) : await resolveProviderChain(authStorage); if (providers.length === 0) { diff --git a/packages/coding-agent/src/web/search/provider.ts b/packages/coding-agent/src/web/search/provider.ts index 3218a4206..0fe94b533 100644 --- a/packages/coding-agent/src/web/search/provider.ts +++ b/packages/coding-agent/src/web/search/provider.ts @@ -140,7 +140,7 @@ export async function resolveProviderChain( if (preferredProvider !== "auto") { const provider = await getSearchProvider(preferredProvider); - if (await provider.isAvailable(authStorage)) { + if (await provider.isExplicitlyAvailable(authStorage)) { providers.push(provider); } } diff --git a/packages/coding-agent/src/web/search/providers/base.ts b/packages/coding-agent/src/web/search/providers/base.ts index c3821e71a..3cf075a90 100644 --- a/packages/coding-agent/src/web/search/providers/base.ts +++ b/packages/coding-agent/src/web/search/providers/base.ts @@ -61,9 +61,26 @@ export abstract class SearchProvider { * Indicates whether this provider has the credentials/config it needs to * service a request right now. Implementations consult the passed * {@link AuthStorage} — never a sibling store. + * + * Drives auto-chain admission: providers that return `false` are skipped + * when {@link resolveProviderChain} walks the order. Explicit selection + * uses {@link isExplicitlyAvailable} instead. */ abstract isAvailable(authStorage: AuthStorage): Promise | boolean; + /** + * Returns `true` when this provider should run when the user explicitly + * selects it, even if {@link isAvailable} would reject it for the auto + * chain. Providers that ship an unauthenticated fallback (e.g. Exa's + * public MCP) override this so explicit selection still routes through + * the fallback rather than silently falling back to another provider. + * + * Defaults to mirroring {@link isAvailable}. + */ + isExplicitlyAvailable(authStorage: AuthStorage): Promise | boolean { + return this.isAvailable(authStorage); + } + /** * Execute a search. Credentials MUST be resolved through `params.authStorage`. */ diff --git a/packages/coding-agent/src/web/search/providers/exa.ts b/packages/coding-agent/src/web/search/providers/exa.ts index 8f60ad6b2..bbca2a5b7 100644 --- a/packages/coding-agent/src/web/search/providers/exa.ts +++ b/packages/coding-agent/src/web/search/providers/exa.ts @@ -270,13 +270,29 @@ export class ExaProvider extends SearchProvider { readonly id = "exa"; readonly label = "Exa"; - isAvailable(_authStorage: AuthStorage): boolean { + isAvailable(authStorage: AuthStorage): boolean { + if (!this.#settingsAllowSearch()) return false; + return !!getEnvApiKey("exa") || authStorage.hasAuth("exa"); + } + + /** + * Exa ships an unauthenticated public MCP fallback, so an explicit + * selection (programmatic or via `providers.webSearch: exa`) routes + * through MCP even when no credential is configured. The auto chain + * still uses {@link isAvailable} so an unrelated configured provider + * keeps priority over the public fallback. + */ + isExplicitlyAvailable(_authStorage: AuthStorage): boolean { + return this.#settingsAllowSearch(); + } + + #settingsAllowSearch(): boolean { try { if (settings.get("exa.enabled") === false || settings.get("exa.enableSearch") === false) { return false; } } catch { - // Settings may be unavailable before CLI initialization; public MCP fallback remains available. + // Settings may be unavailable before CLI initialization; assume not disabled. } return true; } diff --git a/packages/coding-agent/test/tools/web-search-exa.test.ts b/packages/coding-agent/test/tools/web-search-exa.test.ts index c3d9f38a9..b551ca318 100644 --- a/packages/coding-agent/test/tools/web-search-exa.test.ts +++ b/packages/coding-agent/test/tools/web-search-exa.test.ts @@ -449,12 +449,20 @@ describe("searchExa", () => { expect(receivedKey).toBe("stored-key-xyz"); }); - it("reports available without EXA_API_KEY or stored credentials", async () => { + it("reports unavailable for the auto chain without EXA_API_KEY or stored credentials", async () => { delete process.env.EXA_API_KEY; const available = await withLocalAuthStorage(authStorage => Promise.resolve(new ExaProvider().isAvailable(authStorage)), ); - expect(available).toBe(true); + expect(available).toBe(false); + }); + + it("reports explicitly available without credentials so the MCP fallback runs", async () => { + delete process.env.EXA_API_KEY; + const explicit = await withLocalAuthStorage(authStorage => + Promise.resolve(new ExaProvider().isExplicitlyAvailable(authStorage)), + ); + expect(explicit).toBe(true); }); it("reports available with EXA_API_KEY", async () => { diff --git a/packages/coding-agent/test/web/search/abort-and-timeout.test.ts b/packages/coding-agent/test/web/search/abort-and-timeout.test.ts index 5d9dc0e03..9a347d196 100644 --- a/packages/coding-agent/test/web/search/abort-and-timeout.test.ts +++ b/packages/coding-agent/test/web/search/abort-and-timeout.test.ts @@ -164,6 +164,7 @@ describe("executeSearch abort propagation", () => { id, label: "Anthropic", isAvailable: () => true, + isExplicitlyAvailable: () => true, search: behaviour, }; }