mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
fix(web-search): honor requested result limits across providers
This commit is contained in:
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user