From 48e28177428c0c3952bb50fa9862ab19225bfa8d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 21 Aug 2026 18:37:23 -0700 Subject: [PATCH] fix(web-search): honor requested result limits across providers --- .../src/brave-web-search-provider.runtime.ts | 2 +- .../src/brave-web-search-provider.test.ts | 26 ++++++++++++ .../src/exa-web-search-provider.runtime.ts | 2 +- .../exa/src/exa-web-search-provider.test.ts | 40 +++++++++++++++++++ ...rallel-free-web-search-provider.runtime.ts | 1 + .../parallel/src/parallel-search-normalize.ts | 8 ++-- .../parallel-web-search-provider.runtime.ts | 1 + .../src/parallel-web-search-provider.test.ts | 34 ++++++++++++++++ .../perplexity-web-search-provider.runtime.ts | 2 +- .../perplexity-web-search-provider.test.ts | 23 +++++++++++ 10 files changed, 133 insertions(+), 6 deletions(-) diff --git a/extensions/brave/src/brave-web-search-provider.runtime.ts b/extensions/brave/src/brave-web-search-provider.runtime.ts index bd8abada80b6..3f0e0213ca13 100644 --- a/extensions/brave/src/brave-web-search-provider.runtime.ts +++ b/extensions/brave/src/brave-web-search-provider.runtime.ts @@ -332,7 +332,7 @@ async function runBraveWebSearch(params: { "Brave Search API error", ); const results = Array.isArray(data.web?.results) ? (data.web?.results ?? []) : []; - return results.map((entry) => { + return results.slice(0, params.count).map((entry) => { const description = entry.description ?? ""; const title = entry.title ?? ""; const url = entry.url ?? ""; diff --git a/extensions/brave/src/brave-web-search-provider.test.ts b/extensions/brave/src/brave-web-search-provider.test.ts index 2a80140157bc..81df91ce2a52 100644 --- a/extensions/brave/src/brave-web-search-provider.test.ts +++ b/extensions/brave/src/brave-web-search-provider.test.ts @@ -348,6 +348,32 @@ describe("brave web search provider", () => { expect(requestUrl.pathname).toBe("/proxy/res/v1/llm/context"); }); + it("caps returned and cached web results when Brave exceeds the requested count", async () => { + const mockFetch = vi.fn(async () => + jsonResponse({ + web: { + results: [ + { url: "https://example.com/first", title: "First", description: "first" }, + { url: "https://example.com/second", title: "Second", description: "second" }, + { url: "https://example.com/third", title: "Third", description: "third" }, + ], + }, + }), + ); + global.fetch = mockFetch as typeof global.fetch; + const tool = createBraveTool({ webSearch: { apiKey: "brave-test-key", mode: "web" } }); + const args = { query: "brave result count owner", count: 1 }; + + const first = await tool.execute(args); + const cached = await tool.execute(args); + + expect(mockFetch).toHaveBeenCalledOnce(); + expect(fetchRequestUrl(mockFetch).searchParams.get("count")).toBe("1"); + expect(first).toMatchObject({ provider: "brave", count: 1 }); + expect(first.results).toHaveLength(1); + expect(cached).toEqual({ ...first, cached: true }); + }); + it("reports malformed Brave web search JSON as a provider error", async () => { vi.stubEnv("BRAVE_API_KEY", ""); const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => { diff --git a/extensions/exa/src/exa-web-search-provider.runtime.ts b/extensions/exa/src/exa-web-search-provider.runtime.ts index dc8b76a9ff1a..87d32323b2a7 100644 --- a/extensions/exa/src/exa-web-search-provider.runtime.ts +++ b/extensions/exa/src/exa-web-search-provider.runtime.ts @@ -431,7 +431,7 @@ async function runExaSearch(params: { const detail = await readExaErrorDetail(res); throw new Error(`Exa API error (${res.status}): ${detail || res.statusText}`); } - return readExaSearchResults(res); + return (await readExaSearchResults(res)).slice(0, params.count); }, ); } diff --git a/extensions/exa/src/exa-web-search-provider.test.ts b/extensions/exa/src/exa-web-search-provider.test.ts index a0703ae02c49..786882da87df 100644 --- a/extensions/exa/src/exa-web-search-provider.test.ts +++ b/extensions/exa/src/exa-web-search-provider.test.ts @@ -54,6 +54,46 @@ function streamingJsonResponse(params: { chunkCount: number; chunkSize: number } } describe("exa web search provider", () => { + it("caps returned and cached results when Exa exceeds the requested count", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ + results: [ + { url: "https://example.com/first", title: "First", highlights: ["first"] }, + { url: "https://example.com/second", title: "Second", highlights: ["second"] }, + { url: "https://example.com/third", title: "Third", highlights: ["third"] }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + const tool = createExaWebSearchProvider().createTool({ + config: { + plugins: { entries: { exa: { config: { webSearch: { apiKey: "exa-test-key" } } } } }, + }, + searchConfig: {}, + }); + if (!tool) { + throw new Error("Expected tool definition"); + } + + try { + const args = { query: "exa result count owner", count: 1 }; + const first = await tool.execute(args); + const cached = await tool.execute(args); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toMatchObject({ + numResults: 1, + }); + expect(first).toMatchObject({ provider: "exa", count: 1 }); + expect(first.results).toHaveLength(1); + expect(cached).toEqual({ ...first, cached: true }); + } finally { + fetchMock.mockRestore(); + } + }); + it("does not send or cache an already canceled search", async () => { const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( new Response(JSON.stringify({ results: [] }), { diff --git a/extensions/parallel/src/parallel-free-web-search-provider.runtime.ts b/extensions/parallel/src/parallel-free-web-search-provider.runtime.ts index 9a5b3096f2f8..4b90a015bb1c 100644 --- a/extensions/parallel/src/parallel-free-web-search-provider.runtime.ts +++ b/extensions/parallel/src/parallel-free-web-search-provider.runtime.ts @@ -65,6 +65,7 @@ export async function executeParallelFreeWebSearchProviderTool( provider: "parallel-free", objective, searchQueries, + count, response, start, }); diff --git a/extensions/parallel/src/parallel-search-normalize.ts b/extensions/parallel/src/parallel-search-normalize.ts index 219fb07f67d4..a468129fe67f 100644 --- a/extensions/parallel/src/parallel-search-normalize.ts +++ b/extensions/parallel/src/parallel-search-normalize.ts @@ -176,8 +176,9 @@ export function normalizeParallelResults(payload: unknown): ParallelSearchResult } /** Maps a Parallel v1 response into wrapped `web_search` result entries. */ -function mapParallelResults(response: ParallelSearchResponse): Record[] { - return normalizeParallelResults(response).map((entry) => { +function mapParallelResults(response: ParallelSearchResponse, count: number) { + const results = normalizeParallelResults(response).slice(0, count); + return results.map((entry) => { const title = typeof entry.title === "string" ? entry.title : ""; const url = typeof entry.url === "string" ? entry.url : ""; const published = @@ -205,10 +206,11 @@ export function buildParallelSearchPayload(params: { provider: "parallel" | "parallel-free"; objective?: string; searchQueries: readonly string[]; + count: number; response: ParallelSearchResponse; start: number; }): Record { - const results = mapParallelResults(params.response); + const results = mapParallelResults(params.response, params.count); const payload: Record = { ...(params.objective ? { objective: params.objective } : {}), searchQueries: params.searchQueries, diff --git a/extensions/parallel/src/parallel-web-search-provider.runtime.ts b/extensions/parallel/src/parallel-web-search-provider.runtime.ts index eefd4139063c..a12dbfc258ea 100644 --- a/extensions/parallel/src/parallel-web-search-provider.runtime.ts +++ b/extensions/parallel/src/parallel-web-search-provider.runtime.ts @@ -241,6 +241,7 @@ export async function executeParallelWebSearchProviderTool( provider: "parallel", objective, searchQueries, + count, response, start, }); diff --git a/extensions/parallel/src/parallel-web-search-provider.test.ts b/extensions/parallel/src/parallel-web-search-provider.test.ts index c96f082389d2..0978ec47e07e 100644 --- a/extensions/parallel/src/parallel-web-search-provider.test.ts +++ b/extensions/parallel/src/parallel-web-search-provider.test.ts @@ -445,6 +445,40 @@ describe("parallel web search provider", () => { const body = readBody() as { advanced_settings?: { max_results?: number } }; expect(body.advanced_settings?.max_results).toBe(5); }); + it("caps returned and cached results when paid Parallel exceeds the requested count", async () => { + enqueueJson({ + search_id: "parallel-result-cap", + session_id: "parallel-cap-session", + results: [ + { url: "https://example.com/first", title: "First", excerpts: ["first"] }, + { url: "https://example.com/second", title: "Second", excerpts: ["second"] }, + { url: "https://example.com/third", title: "Third", excerpts: ["third"] }, + ], + warnings: ["provider warning"], + usage: [{ count: 1 }], + }); + const tool = paidTool(); + const args = { + search_queries: ["parallel result count owner"], + session_id: "parallel-cap-session", + count: 1, + }; + + const first = await tool.execute(args); + const cached = await tool.execute(args); + + expect(endpointMockState.calls).toHaveLength(1); + expect(readBody()).toMatchObject({ advanced_settings: { max_results: 1 } }); + expect(first).toMatchObject({ + count: 1, + searchId: "parallel-result-cap", + sessionId: "parallel-cap-session", + warnings: ["provider warning"], + usage: [{ count: 1 }], + }); + expect(first.results).toHaveLength(1); + expect(cached).toEqual({ ...first, cached: true }); + }); it("bounds Parallel API error bodies without using response.text()", async () => { const tracked = cancelTrackedResponse(`${"parallel upstream unavailable ".repeat(1024)}tail`, { status: 503, diff --git a/extensions/perplexity/src/perplexity-web-search-provider.runtime.ts b/extensions/perplexity/src/perplexity-web-search-provider.runtime.ts index d846dbf5252f..207513cca147 100644 --- a/extensions/perplexity/src/perplexity-web-search-provider.runtime.ts +++ b/extensions/perplexity/src/perplexity-web-search-provider.runtime.ts @@ -253,7 +253,7 @@ async function runPerplexitySearchApi(params: { res, "Perplexity Search", ); - return (data.results ?? []).map((entry) => ({ + return (data.results ?? []).slice(0, params.count).map((entry) => ({ title: entry.title ? wrapWebContent(entry.title, "web_search") : "", url: entry.url ?? "", description: entry.snippet ? wrapWebContent(entry.snippet, "web_search") : "", diff --git a/extensions/perplexity/src/perplexity-web-search-provider.test.ts b/extensions/perplexity/src/perplexity-web-search-provider.test.ts index 5745e2486b0a..2de9a11601c8 100644 --- a/extensions/perplexity/src/perplexity-web-search-provider.test.ts +++ b/extensions/perplexity/src/perplexity-web-search-provider.test.ts @@ -117,6 +117,29 @@ describe("perplexity web search provider", () => { expect(withTrustedWebSearchEndpointMock).toHaveBeenCalledTimes(2); }); + it("caps returned and cached results when the Perplexity Search API exceeds the requested count", async () => { + withTrustedWebSearchEndpointMock.mockReset(); + mockPerplexityResponseOnce({ + results: [ + { url: "https://example.com/first", title: "First", snippet: "first" }, + { url: "https://example.com/second", title: "Second", snippet: "second" }, + { url: "https://example.com/third", title: "Third", snippet: "third" }, + ], + }); + const tool = createConfiguredPerplexityTool(true); + const args = { query: "perplexity result count owner", count: 1 }; + + const first = await tool.execute(args); + const cached = await tool.execute(args); + + expect(withTrustedWebSearchEndpointMock).toHaveBeenCalledOnce(); + const [request] = withTrustedWebSearchEndpointMock.mock.calls[0] as [{ init: RequestInit }]; + expect(JSON.parse(request.init.body as string)).toMatchObject({ max_results: 1 }); + expect(first).toMatchObject({ provider: "perplexity", count: 1 }); + expect(first.results).toHaveLength(1); + expect(cached).toEqual({ ...first, cached: true }); + }); + it.each([ { name: "chat completions", structured: false, expectedRequests: 1 }, { name: "native Search API", structured: true, expectedRequests: 2 },