diff --git a/docs/tools/web_search.md b/docs/tools/web_search.md index 22dcb1672..7a33cc757 100644 --- a/docs/tools/web_search.md +++ b/docs/tools/web_search.md @@ -39,10 +39,10 @@ | --- | --- | --- | --- | | `query` | `string` | Yes | Search query, passed to providers unchanged. | | `recency` | `"day" \| "week" \| "month" \| "year"` | No | Time filter. Only providers that implement it use it; code maps it for Brave, Perplexity, Tavily, SearXNG, Kagi, TinyFish, and Firecrawl. xAI ignores it because the documented Agent Tools `web_search` API does not expose date controls. | -| `limit` | `number` | No | Max results to return. Usually becomes the provider request's result-count parameter when `num_search_results` is absent. xAI does not send it upstream; it caps returned sources/citations after parsing unless `num_search_results` is provided. | +| `limit` | `number` | No | Max results to return. Usually becomes the provider request's result-count parameter when `num_search_results` is absent. For xAI, it is not sent upstream; it is a local post-parse cap on returned sources/citations, defaulting to `10` when omitted/invalid/zero and capped at `30`. | | `max_tokens` | `number` | No | Passed through as provider token caps (`maxOutputTokens`, `max_tokens`, or xAI `max_output_tokens`) only by Anthropic, Gemini, xAI, and Perplexity API-key mode. Ignored by the other providers. | | `temperature` | `number` | No | Passed through only by Anthropic, Gemini, xAI, and Perplexity API-key mode. Ignored by the other providers. | -| `num_search_results` | `number` | No | Requested search breadth or local result cap. Most providers send it upstream. xAI does not send it upstream; it caps returned sources/citations after parsing and takes precedence over `limit`. | +| `num_search_results` | `number` | No | Requested search breadth or local result cap. Most providers send it upstream. xAI does not send it upstream; it is a local cap on returned sources/citations after parsing, takes precedence over `limit`, defaults to `10` when omitted/invalid/zero, and is capped at `30`. | ## Outputs The tool returns a single text content block plus structured `details`. @@ -128,7 +128,7 @@ Streaming: none. `WebSearchTool.execute()` forwards its `AbortSignal` into `exec - **xAI** — `packages/coding-agent/src/web/search/providers/xai.ts` - Availability: `XAI_API_KEY` or `agent.db` credential for `xai`. - Querying: POST `https://api.x.ai/v1/responses` with model `grok-4.3` and `tools: [{ type: "web_search" }]` using the `/v1/responses` Agent Tools API. - - `max_tokens` and `temperature` pass through. `recency` is ignored because the documented Agent Tools `web_search` API does not expose date controls. `limit` and `num_search_results` are not sent upstream; because xAI citations may include every encountered URL, the adapter locally caps returned `sources` and `citations` after parsing (`numSearchResults ?? limit`). Missing cap returns all sources/citations. + - `max_tokens` and `temperature` pass through. `recency` is ignored because the documented Agent Tools `web_search` API does not expose date controls. `limit` and `num_search_results` are not sent upstream; because xAI citations may include every encountered URL, the adapter locally caps returned `sources` and `citations` after parsing. The local cap uses `num_search_results` before `limit`, defaults to `10` when omitted/invalid/zero, and is capped at `30`. - Output may include `answer`, `sources`, `citations`, `usage`, `model`, `requestId`, `authMode: "api_key"`. - **Z.AI** — `packages/coding-agent/src/web/search/providers/zai.ts` - Availability: env or `agent.db` credential for `zai`. @@ -226,6 +226,7 @@ Streaming: none. `WebSearchTool.execute()` forwards its `AbortSignal` into `exec - Parallel result count: default `10`, max `40`; per-result excerpt cap `10_000` chars (`packages/coding-agent/src/web/search/providers/parallel.ts`, `packages/coding-agent/src/web/parallel.ts`). - Kagi result count: default `10`, max `40` (`packages/coding-agent/src/web/search/providers/kagi.ts`). - SearXNG result count: default `10`, max `20` (`packages/coding-agent/src/web/search/providers/searxng.ts`). +- xAI local sources/citations cap: `num_search_results` before `limit`, omitted/invalid/zero => default `10`, max `30`; neither value is sent upstream (`packages/coding-agent/src/web/search/providers/xai.ts`). - Perplexity API-key mode defaults: `max_tokens = 8192`, `temperature = 0.2`, `num_search_results = 20` (`packages/coding-agent/src/web/search/providers/perplexity.ts`). - Anthropic defaults: model `claude-haiku-4-5`, `DEFAULT_MAX_TOKENS = 4096` when the provider omits `max_tokens` (`packages/coding-agent/src/web/search/providers/anthropic.ts`). - Gemini retries: up to `3` retries per endpoint, base delay `1000` ms, rate-limit delay budget `5 * 60 * 1000` ms (`packages/coding-agent/src/web/search/providers/gemini.ts`). @@ -246,7 +247,7 @@ Streaming: none. `WebSearchTool.execute()` forwards its `AbortSignal` into `exec ## Notes - The model-facing schema does not expose `provider`, but internal callers can force one through `SearchQueryParams`. - `resolveProviderChain()` lazily imports provider modules and caches singleton instances. Just asking for labels via `getSearchProviderLabel()` does not trigger those imports. -- Most providers treat `limit` and `num_search_results` as the same number because adapters pass `params.numSearchResults ?? params.limit`. Perplexity preserves both concepts; xAI applies that same precedence locally after parsing to cap returned sources/citations without sending a result-count control upstream. +- Most providers treat `limit` and `num_search_results` as the same number because adapters pass `params.numSearchResults ?? params.limit`. Perplexity preserves both concepts; xAI applies that same precedence locally after parsing to cap returned sources/citations without sending a result-count control upstream (`10` default, `30` max). - `recency` is implemented by Brave, Perplexity, Tavily, SearXNG, Kagi, TinyFish, and Firecrawl; xAI ignores it because the documented Agent Tools `web_search` API does not expose date controls. The model-facing prompt does not name specific providers. - `packages/coding-agent/src/config/settings-schema.ts` uses the shared `SEARCH_PROVIDER_PREFERENCES` / `SEARCH_PROVIDER_OPTIONS` metadata, so the settings selector and setup wizard expose `auto` plus every provider in the auto chain. - DuckDuckGo is intentionally last in the auto chain because it is always available without credentials. diff --git a/packages/coding-agent/src/web/search/providers/xai.ts b/packages/coding-agent/src/web/search/providers/xai.ts index 89156c8d0..aafa53c56 100644 --- a/packages/coding-agent/src/web/search/providers/xai.ts +++ b/packages/coding-agent/src/web/search/providers/xai.ts @@ -1,12 +1,15 @@ import { type ApiKey, type AuthStorage, withAuth } from "@oh-my-pi/pi-ai"; import type { SearchCitation, SearchResponse, SearchSource, SearchUsage } from "../../../web/search/types"; import { SearchProviderError } from "../../../web/search/types"; +import { clampNumResults } from "../utils"; import type { SearchParams } from "./base"; import { SearchProvider } from "./base"; import { classifyProviderHttpError, withHardTimeout } from "./utils"; const XAI_RESPONSES_URL = "https://api.x.ai/v1/responses"; const XAI_WEB_SEARCH_MODEL = "grok-4.3"; +const DEFAULT_NUM_RESULTS = 10; +const MAX_NUM_RESULTS = 30; interface XAIUrlCitationAnnotation { type?: string; @@ -181,17 +184,15 @@ function parseUsage(usage: XAIResponsesUsage | null | undefined): SearchUsage | function applyResultCap( sources: SearchSource[], citations: SearchCitation[], - requestedCap: number | undefined, + resultCap: number, ): { sources: SearchSource[]; citations: SearchCitation[] } { - if (requestedCap === undefined) return { sources, citations }; - return { - sources: sources.slice(0, requestedCap), - citations: citations.slice(0, requestedCap), + sources: sources.slice(0, resultCap), + citations: citations.slice(0, resultCap), }; } -function parseResponse(response: XAIResponsesResponse, requestedCap?: number): SearchResponse { +function parseResponse(response: XAIResponsesResponse, resultCap: number): SearchResponse { const sources: SearchSource[] = []; const citations: SearchCitation[] = []; const seenUrls = new Set(); @@ -206,7 +207,7 @@ function parseResponse(response: XAIResponsesResponse, requestedCap?: number): S for (const url of response.citations ?? []) { addCitationSource(sources, citations, seenUrls, url); } - const limited = applyResultCap(sources, citations, requestedCap); + const limited = applyResultCap(sources, citations, resultCap); return { provider: "xai", @@ -226,11 +227,12 @@ export async function searchXAI(params: SearchParams): Promise { sessionId: params.sessionId, }); + const resultCap = clampNumResults(params.numSearchResults ?? params.limit, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS); const response = await withAuth(keyOrResolver, (key: string) => callXAIResponses(key, params), { signal: params.signal, missingKeyMessage: 'xAI credentials not found. Set XAI_API_KEY or configure an API key for provider "xai".', }); - return parseResponse(response, params.numSearchResults ?? params.limit); + return parseResponse(response, resultCap); } /** Search provider for xAI web search. */ diff --git a/packages/coding-agent/test/tools/web-search-xai.test.ts b/packages/coding-agent/test/tools/web-search-xai.test.ts index bbc01c19f..27a6d3578 100644 --- a/packages/coding-agent/test/tools/web-search-xai.test.ts +++ b/packages/coding-agent/test/tools/web-search-xai.test.ts @@ -58,6 +58,10 @@ function captureFetch(responseBody: Record | string, status = 2 }; } +function citationUrls(prefix: string, count: number): string[] { + return Array.from({ length: count }, (_, index) => `https://example.com/${prefix}-${index + 1}`); +} + describe("xAI web search provider", () => { afterEach(() => { vi.restoreAllMocks(); @@ -257,6 +261,55 @@ describe("xAI web search provider", () => { }); }); + it("defaults xAI local cap to 10 sources and citations when no count is requested", async () => { + const urls = citationUrls("default-cap", 12); + const capture = captureFetch({ + id: "resp_default_cap", + model: "grok-4.3", + output_text: "Default capped xAI answer", + citations: urls, + }); + + const response = await searchXAI(makeParams(capture.fetchMock)); + const expectedUrls = urls.slice(0, 10); + + expect(response.sources).toHaveLength(10); + expect(response.citations).toHaveLength(10); + expect(response.sources.map(source => source.url)).toEqual(expectedUrls); + expect(response.citations?.map(citation => citation.url)).toEqual(expectedUrls); + expect(capture.capturedRequest).not.toBeNull(); + const body = capture.capturedRequest?.body; + expect(body?.tools).toEqual([{ type: "web_search" }]); + expect(body).not.toHaveProperty("search_parameters"); + expect(Object.keys(body ?? {}).sort()).toEqual(["input", "model", "tools"]); + }); + + it("clamps oversized xAI local cap requests to 30 sources and citations", async () => { + const urls = citationUrls("max-cap", 35); + const capture = captureFetch({ + id: "resp_max_cap", + model: "grok-4.3", + output_text: "Max capped xAI answer", + citations: urls, + }); + + const response = await searchXAI({ + ...makeParams(capture.fetchMock), + numSearchResults: 99, + }); + const expectedUrls = urls.slice(0, 30); + + expect(response.sources).toHaveLength(30); + expect(response.citations).toHaveLength(30); + expect(response.sources.map(source => source.url)).toEqual(expectedUrls); + expect(response.citations?.map(citation => citation.url)).toEqual(expectedUrls); + expect(capture.capturedRequest).not.toBeNull(); + const body = capture.capturedRequest?.body; + expect(body?.tools).toEqual([{ type: "web_search" }]); + expect(body).not.toHaveProperty("search_parameters"); + expect(Object.keys(body ?? {}).sort()).toEqual(["input", "model", "tools"]); + }); + it("caps parsed sources and citations locally without changing Agent Tools request shape", async () => { const capture = captureFetch({ id: "resp_local_cap",