fix(web-search): honor requested result limits across providers

This commit is contained in:
Peter Steinberger
2026-08-21 18:37:23 -07:00
parent fc5cb9e304
commit 48e2817742
10 changed files with 133 additions and 6 deletions
@@ -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 ?? "";
@@ -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) => {
@@ -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);
},
);
}
@@ -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: [] }), {
@@ -65,6 +65,7 @@ export async function executeParallelFreeWebSearchProviderTool(
provider: "parallel-free",
objective,
searchQueries,
count,
response,
start,
});
@@ -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<string, unknown>[] {
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<string, unknown> {
const results = mapParallelResults(params.response);
const results = mapParallelResults(params.response, params.count);
const payload: Record<string, unknown> = {
...(params.objective ? { objective: params.objective } : {}),
searchQueries: params.searchQueries,
@@ -241,6 +241,7 @@ export async function executeParallelWebSearchProviderTool(
provider: "parallel",
objective,
searchQueries,
count,
response,
start,
});
@@ -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,
@@ -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") : "",
@@ -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 },