From 30087124dcfcab3770c4e7a79d4f4d58d884eb74 Mon Sep 17 00:00:00 2001 From: Will Date: Fri, 31 Jul 2026 16:37:22 -0400 Subject: [PATCH] feat(coding-agent): configure web search timeout --- docs/settings.md | 1 + docs/tools/web_search.md | 4 ++ packages/coding-agent/CHANGELOG.md | 4 ++ .../src/config/settings-schema.ts | 25 +++++++++- packages/coding-agent/src/web/kagi.ts | 3 +- packages/coding-agent/src/web/search/index.ts | 19 +++++++- .../src/web/search/providers/anthropic.ts | 5 +- .../src/web/search/providers/base.ts | 2 + .../src/web/search/providers/brave.ts | 4 +- .../src/web/search/providers/browser-page.ts | 10 ++-- .../src/web/search/providers/codex.ts | 5 +- .../src/web/search/providers/duckduckgo.ts | 3 +- .../src/web/search/providers/ecosia.ts | 3 +- .../src/web/search/providers/exa.ts | 6 ++- .../src/web/search/providers/firecrawl.ts | 4 +- .../src/web/search/providers/gemini.ts | 10 +++- .../src/web/search/providers/google.ts | 3 +- .../src/web/search/providers/jina.ts | 14 +++++- .../src/web/search/providers/kagi.ts | 3 ++ .../src/web/search/providers/kimi.ts | 6 ++- .../src/web/search/providers/mojeek.ts | 3 +- .../src/web/search/providers/parallel.ts | 6 ++- .../src/web/search/providers/perplexity.ts | 9 ++-- .../src/web/search/providers/public.ts | 2 +- .../src/web/search/providers/searxng.ts | 16 +++++-- .../src/web/search/providers/startpage.ts | 14 ++++-- .../src/web/search/providers/synthetic.ts | 16 +++++-- .../src/web/search/providers/tavily.ts | 4 +- .../src/web/search/providers/tinyfish.ts | 4 +- .../src/web/search/providers/utils.ts | 9 +++- .../src/web/search/providers/xai.ts | 2 +- .../src/web/search/providers/zai.ts | 21 +++++++-- packages/coding-agent/src/web/search/types.ts | 3 ++ .../test/tools/web-search-codex.test.ts | 15 ++++++ .../test/tools/web-search-kagi.test.ts | 23 +++++++++ .../test/web/search/abort-and-timeout.test.ts | 47 ++++++++++++++++++- 36 files changed, 277 insertions(+), 51 deletions(-) diff --git a/docs/settings.md b/docs/settings.md index 3f1bc3c4d..96fadd413 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -716,6 +716,7 @@ searxng: | Key | Type | Default | Values / notes | |---|---|---|---| | `providers.webSearchOrder` | array | `[]` | Provider IDs in priority order for `web_search` (`perplexity`, `gemini`, `anthropic`, `codex`, `zai`, `exa`, `jina`, `kagi`, `tavily`, `brave`, `kimi`, `parallel`, `synthetic`, `searxng`, …). Duplicates and unknown IDs are ignored; unlisted providers retain their built-in relative order afterward. Empty = built-in order. Replaces the removed `providers.webSearch` enum (a legacy value migrates to the head of this list). | +| `providers.webSearchTimeoutSeconds` | number | `60` | Hard timeout in seconds supplied to each `web_search` provider transport before the automatic chain advances to the next fallback. Use a larger value for slower model-backed providers. This is not a whole-chain deadline, and provider-specific upstream or aggregate limits may still be shorter. | | `providers.webSearchGeminiModel` | string | _(unset)_ | Gemini model ID for Google Search grounding when `web_search` uses Gemini; defaults to `gemini-2.5-flash`, overridden by `GEMINI_SEARCH_MODEL`. | | `providers.imageOrder` | array | `[]` | Image-generation provider IDs in priority order (`openai`, `openai-codex`, `antigravity`, `xai`, `gemini`, `openrouter`). Unlisted providers follow the active session provider and the built-in order. Replaces the removed `providers.image` enum (a legacy value migrates to the head of this list). | | `providers.fetch` | enum | `auto` | `auto`, `native`, `trafilatura`, `lynx`, `parallel`, `jina`. | diff --git a/docs/tools/web_search.md b/docs/tools/web_search.md index f14e74e6e..b24754347 100644 --- a/docs/tools/web_search.md +++ b/docs/tools/web_search.md @@ -80,6 +80,8 @@ Failure output is not thrown at the tool boundary when providers are unavailable Streaming: none. `WebSearchTool.execute()` forwards its `AbortSignal` into `executeSearch()`, and `executeSearch()` passes it to providers. If the signal is aborted during fallback handling, `throwIfAborted(signal)` rethrows the cancellation instead of returning an `"Error: ..."` text result. +Each provider search transport receives a hard timeout from `providers.webSearchTimeoutSeconds` (default `60`). When that transport exceeds the ceiling, the automatic chain records the provider failure and advances to the next candidate. The setting is not a whole-chain deadline, and providers may impose shorter upstream, retry, or aggregate limits. Set a positive number of seconds, for example `omp config set providers.webSearchTimeoutSeconds 180` for slower model-backed search. + ## Flow 1. `WebSearchTool.execute()` in `packages/coding-agent/src/web/search/index.ts` delegates directly to `executeSearch()`. 2. `executeSearch()` computes ordered provider candidates without loading their modules: @@ -90,6 +92,7 @@ Streaming: none. `WebSearchTool.execute()` forwards its `AbortSignal` into `exec 5. For each provider in order, `executeSearch()` calls `provider.search()` with: - `query`, - `limit`, `recency`, `temperature`, `maxOutputTokens`, `numSearchResults`, + - `timeoutMs`, derived from `providers.webSearchTimeoutSeconds`, - `systemPrompt` from `packages/coding-agent/src/prompts/system/web-search.md`. 6. A `SearchResponse` with no renderable content (`hasRenderableSearchContent()` returns false) is rejected as a `SearchProviderError` (status `204`) so the loop advances to the next provider. On the first response that has renderable content, `formatForLLM()` renders answer/sources/citations/related/search-queries into one text block and returns it with `details.response`. 7. If a provider throws, `executeSearch()` records the error and tries the next provider. There is no provider-level parallel fan-out; fallback is sequential. @@ -105,6 +108,7 @@ Streaming: none. `WebSearchTool.execute()` forwards its `AbortSignal` into `exec - **Configured order**: `setSearchProviderOrder()` prioritizes the valid, first-occurrence provider IDs in `providers.webSearchOrder`; providers omitted from the setting follow in their built-in relative order. Listed providers are explicit selections — they resolve through `isExplicitlyAvailable()`, so e.g. a hand-listed Perplexity may fall back to anonymous search. Wired from settings in `packages/coding-agent/src/config/provider-globals.ts` (SDK startup, cwd reloads, live settings changes). - **Excluded providers**: `setExcludedSearchProviders()` records providers `resolveProviderCandidates()` must skip, including as fallbacks. Wired from the `providers.webSearchExclude` setting via the same `provider-globals.ts` paths. - **Default auto chain order** (25 providers): `perplexity`, `gemini`, `anthropic`, `codex`, `xai`, `zai`, `exa`, `tinyfish`, `jina`, `kagi`, `tavily`, `firecrawl`, `brave`, `kimi`, `parallel`, `synthetic`, `searxng`, `duckduckgo`, `bing`, `yahoo`, `startpage`, `google`, `ecosia`, `mojeek`, `public` (`SEARCH_PROVIDER_ORDER` in `packages/coding-agent/src/web/search/types.ts`). `public` is explicit-only: its `isAvailable()` returns `false` so the auto chain never fans out implicitly. +- **Provider timeout**: `providers.webSearchTimeoutSeconds` supplies the hard ceiling for each provider's search transport before the automatic chain advances. It defaults to `60`; invalid non-positive values fall back to that default, while provider-specific upstream or aggregate limits may still be shorter. - **Provider adapters** - **Perplexity** — `packages/coding-agent/src/web/search/providers/perplexity.ts` - Availability: auth precedence is `PERPLEXITY_COOKIES` -> OAuth token in `agent.db` -> `PERPLEXITY_API_KEY` / `PPLX_API_KEY` -> anonymous ask-endpoint fallback. `isAvailable()` gates the auto chain on credentials, but `isExplicitlyAvailable()` is always true, so explicit selection works unauthenticated. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3f76ce653..dfa3845d3 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added a configurable per-request web search timeout via `providers.webSearchTimeoutSeconds`. + ## [17.2.2] - 2026-07-31 ### Added diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 81eb67373..3b59f8696 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -37,7 +37,11 @@ import { TTS_LOCAL_VOICE_VALUES, } from "../tts/models"; import { EDIT_MODES } from "../utils/edit-mode"; -import { SEARCH_PROVIDER_CHOICES, type SearchProviderId } from "../web/search/types"; +import { + DEFAULT_WEB_SEARCH_TIMEOUT_SECONDS, + SEARCH_PROVIDER_CHOICES, + type SearchProviderId, +} from "../web/search/types"; import { SERVICE_TIER_ANTHROPIC_OPTIONS, SERVICE_TIER_ANTHROPIC_VALUES, @@ -4869,6 +4873,25 @@ export const SETTINGS_SCHEMA = { options: SEARCH_PROVIDER_CHOICES, }, }, + "providers.webSearchTimeoutSeconds": { + type: "number", + default: DEFAULT_WEB_SEARCH_TIMEOUT_SECONDS, + ui: { + tab: "providers", + group: "Services", + label: "Web Search Timeout", + description: + "Hard timeout for each provider's search transport before web_search advances to the next fallback, in seconds", + options: [ + { value: "30", label: "30 seconds" }, + { value: "60", label: "1 minute" }, + { value: "120", label: "2 minutes" }, + { value: "180", label: "3 minutes" }, + { value: "300", label: "5 minutes" }, + { value: "600", label: "10 minutes" }, + ], + }, + }, "providers.webSearchGeminiModel": { type: "string", default: undefined, diff --git a/packages/coding-agent/src/web/kagi.ts b/packages/coding-agent/src/web/kagi.ts index bc78651d6..0f2c776dd 100644 --- a/packages/coding-agent/src/web/kagi.ts +++ b/packages/coding-agent/src/web/kagi.ts @@ -156,6 +156,7 @@ export interface KagiSearchOptions { recency?: "day" | "week" | "month" | "year"; sessionId?: string; signal?: AbortSignal; + timeoutMs?: number; fetch?: FetchImpl; } @@ -253,7 +254,7 @@ export async function searchWithKagi( Accept: "application/json", }, body, - signal: withHardTimeout(options.signal), + signal: withHardTimeout(options.signal, options.timeoutMs), }); if (!res.ok) { diff --git a/packages/coding-agent/src/web/search/index.ts b/packages/coding-agent/src/web/search/index.ts index 41c1413a0..93a32fee5 100644 --- a/packages/coding-agent/src/web/search/index.ts +++ b/packages/coding-agent/src/web/search/index.ts @@ -29,8 +29,12 @@ import { } from "./provider"; import { applyQueryConstraints, parseSearchQuery } from "./query"; import { renderSearchCall, renderSearchResult, type SearchRenderDetails } from "./render"; -import type { SearchProviderId, SearchResponse } from "./types"; -import { SearchProviderError } from "./types"; +import { + DEFAULT_WEB_SEARCH_TIMEOUT_SECONDS, + SearchProviderError, + type SearchProviderId, + type SearchResponse, +} from "./types"; /** Web search tool parameters schema */ export const webSearchSchema = type({ @@ -164,6 +168,16 @@ async function executeSearch( geminiModel = undefined; } + let timeoutMs = DEFAULT_WEB_SEARCH_TIMEOUT_SECONDS * 1_000; + try { + const configuredSeconds = settings.get("providers.webSearchTimeoutSeconds"); + if (Number.isFinite(configuredSeconds) && configuredSeconds > 0) { + timeoutMs = Math.ceil(configuredSeconds * 1_000); + } + } catch { + // Preserve the default for one-shot callers that do not initialize Settings. + } + const failures: Array<{ provider: Pick; error: unknown }> = []; let availableProviderCount = 0; let lastProvider: Pick | undefined; @@ -196,6 +210,7 @@ async function executeSearch( numSearchResults: params.num_search_results, temperature: params.temperature, signal, + timeoutMs, authStorage, modelRegistry, sessionId, diff --git a/packages/coding-agent/src/web/search/providers/anthropic.ts b/packages/coding-agent/src/web/search/providers/anthropic.ts index d34661d85..50cedc0a3 100644 --- a/packages/coding-agent/src/web/search/providers/anthropic.ts +++ b/packages/coding-agent/src/web/search/providers/anthropic.ts @@ -97,6 +97,7 @@ export interface AnthropicSearchParams { max_tokens?: number; temperature?: number; signal?: AbortSignal; + timeoutMs?: number; fetch?: FetchImpl; } @@ -152,6 +153,7 @@ async function callSearch( temperature?: number, signal?: AbortSignal, fetchImpl: FetchImpl = fetch, + timeoutMs?: number, ): Promise { const url = buildAnthropicUrl(auth); const headers = buildAnthropicSearchHeaders(auth); @@ -191,7 +193,7 @@ async function callSearch( method: "POST", headers, body: JSON.stringify(body), - signal: withHardTimeout(signal), + signal: withHardTimeout(signal, timeoutMs), }); if (!response.ok) { @@ -367,6 +369,7 @@ export async function searchAnthropic( params.temperature, params.signal, params.fetch, + params.timeoutMs, ); }, { diff --git a/packages/coding-agent/src/web/search/providers/base.ts b/packages/coding-agent/src/web/search/providers/base.ts index a123331b4..03eb96315 100644 --- a/packages/coding-agent/src/web/search/providers/base.ts +++ b/packages/coding-agent/src/web/search/providers/base.ts @@ -47,6 +47,8 @@ export interface SearchParams { recency?: "day" | "week" | "month" | "year"; systemPrompt: string; signal?: AbortSignal; + /** Hard timeout for this provider's search transport, in milliseconds. */ + timeoutMs?: number; fetch?: FetchImpl; maxOutputTokens?: number; numSearchResults?: number; diff --git a/packages/coding-agent/src/web/search/providers/brave.ts b/packages/coding-agent/src/web/search/providers/brave.ts index 57ac1f480..0b5fe65e3 100644 --- a/packages/coding-agent/src/web/search/providers/brave.ts +++ b/packages/coding-agent/src/web/search/providers/brave.ts @@ -52,6 +52,7 @@ export interface BraveSearchParams { recency?: "day" | "week" | "month" | "year"; parsedQuery?: StructuredQuery; signal?: AbortSignal; + timeoutMs?: number; fetch?: FetchImpl; } @@ -113,7 +114,7 @@ async function callBraveSearch( Accept: "application/json", "X-Subscription-Token": apiKey, }, - signal: withHardTimeout(params.signal), + signal: withHardTimeout(params.signal, params.timeoutMs), }); if (!response.ok) { @@ -173,6 +174,7 @@ export class BraveProvider extends SearchProvider { recency: params.recency, parsedQuery: params.parsedQuery, signal: params.signal, + timeoutMs: params.timeoutMs, fetch: params.fetch, }); } diff --git a/packages/coding-agent/src/web/search/providers/browser-page.ts b/packages/coding-agent/src/web/search/providers/browser-page.ts index 2be17fbca..f4b01232d 100644 --- a/packages/coding-agent/src/web/search/providers/browser-page.ts +++ b/packages/coding-agent/src/web/search/providers/browser-page.ts @@ -26,6 +26,7 @@ interface BrowserFallbackOptions { export interface BrowserFetchOptions { fetch?: FetchImpl; signal: AbortSignal; + timeoutMs?: number; randomizeHeaders?: boolean; referer?: string; init?: Omit; @@ -50,6 +51,7 @@ async function browseHtmlPage( url: string, options: BrowserFallbackOptions, signal: AbortSignal, + timeoutMs = SEARCH_HARD_TIMEOUT_MS, ): Promise { const { homeUrl, ready } = options; const attempts = Math.max(1, options.attempts ?? 1); @@ -76,14 +78,14 @@ async function browseHtmlPage( await applyStealthPatches(handle.browser, activePage, handle.stealth); if (homeUrl) { await untilAborted(signal, () => - activePage.goto(homeUrl, { waitUntil: "domcontentloaded", timeout: SEARCH_HARD_TIMEOUT_MS }), + activePage.goto(homeUrl, { waitUntil: "domcontentloaded", timeout: timeoutMs }), ); } for (let attempt = 0; attempt < attempts; attempt++) { if (attempt > 0 && options.retryDelayMs) await Bun.sleep(options.retryDelayMs); const response = await untilAborted(signal, () => - activePage.goto(url, { waitUntil: "domcontentloaded", timeout: SEARCH_HARD_TIMEOUT_MS }), + activePage.goto(url, { waitUntil: "domcontentloaded", timeout: timeoutMs }), ); if (options.afterNavigation) await options.afterNavigation(activePage, signal); if (ready) { @@ -113,11 +115,11 @@ export async function browserFetch(url: string, options: BrowserFetchOptions): P page = await fetchHtmlPage(url, options, fetchImpl); } catch (error) { if (options.fetch || !options.browser) throw error; - return browseHtmlPage(url, options.browser, options.signal); + return browseHtmlPage(url, options.browser, options.signal, options.timeoutMs); } if (!options.browser || options.fetch) return page; const isSuccessful = page.status >= 200 && page.status < 300; if (isSuccessful && !options.browser.shouldFallback(page)) return page; - return browseHtmlPage(url, options.browser, options.signal); + return browseHtmlPage(url, options.browser, options.signal, options.timeoutMs); } diff --git a/packages/coding-agent/src/web/search/providers/codex.ts b/packages/coding-agent/src/web/search/providers/codex.ts index 79b5fb412..aa1a6fa99 100644 --- a/packages/coding-agent/src/web/search/providers/codex.ts +++ b/packages/coding-agent/src/web/search/providers/codex.ts @@ -145,6 +145,7 @@ function shouldRetryWithNextDefaultModel(error: unknown): boolean { export interface CodexSearchParams { signal?: AbortSignal; + timeoutMs?: number; fetch?: FetchImpl; query: string; system_prompt?: string; @@ -418,6 +419,7 @@ async function callCodexSearch( query: string, options: { signal?: AbortSignal; + timeoutMs?: number; systemPrompt?: string; searchContextSize?: "low" | "medium" | "high"; model: CodexModelCandidate; @@ -472,7 +474,7 @@ async function callCodexSearch( method: "POST", headers, body: JSON.stringify(body), - signal: withHardTimeout(options.signal), + signal: withHardTimeout(options.signal, options.timeoutMs), }); if (!response.ok) { @@ -620,6 +622,7 @@ async function runCodexSearchCandidates(options: { try { return await callCodexSearch(options.auth, options.query, { signal: options.params.signal, + timeoutMs: options.params.timeoutMs, systemPrompt: options.params.systemPrompt, searchContextSize: "high", model: candidate, diff --git a/packages/coding-agent/src/web/search/providers/duckduckgo.ts b/packages/coding-agent/src/web/search/providers/duckduckgo.ts index 78f78229f..8116dbc75 100644 --- a/packages/coding-agent/src/web/search/providers/duckduckgo.ts +++ b/packages/coding-agent/src/web/search/providers/duckduckgo.ts @@ -305,6 +305,7 @@ async function callDuckDuckGoHtml(params: SearchParams, form: URLSearchParams, s const page = await browserFetch(DUCKDUCKGO_HTML_URL, { fetch: params.fetch ?? fetch, signal, + timeoutMs: params.timeoutMs, referer: "https://html.duckduckgo.com/", init: { method: "POST", @@ -334,7 +335,7 @@ async function callDuckDuckGoHtml(params: SearchParams, form: URLSearchParams, s /** Execute a DuckDuckGo web search via the no-JS HTML frontend. */ export async function searchDuckDuckGo(params: SearchParams): Promise { const numResults = clampNumResults(params.numSearchResults ?? params.limit, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS); - const signal = withHardTimeout(params.signal); + const signal = withHardTimeout(params.signal, params.timeoutMs); const sources: SearchSource[] = []; const seen = new Set(); let form: URLSearchParams | undefined = createDuckDuckGoForm(params); diff --git a/packages/coding-agent/src/web/search/providers/ecosia.ts b/packages/coding-agent/src/web/search/providers/ecosia.ts index fcbb37163..562effa71 100644 --- a/packages/coding-agent/src/web/search/providers/ecosia.ts +++ b/packages/coding-agent/src/web/search/providers/ecosia.ts @@ -99,7 +99,7 @@ function isBlockedPage(page: LoadedHtmlPage): boolean { } async function callEcosiaHtml(params: SearchParams): Promise { - const signal = withHardTimeout(params.signal); + const signal = withHardTimeout(params.signal, params.timeoutMs); const url = new URL(ECOSIA_SEARCH_URL); // Ecosia serves Google-backed results, so classic operators pass through // inline; canonicalize aliases (domain: -> site:, since: -> after:) and @@ -111,6 +111,7 @@ async function callEcosiaHtml(params: SearchParams): Promise { page = await browserFetch(url.href, { fetch: params.fetch, signal, + timeoutMs: params.timeoutMs, referer: ECOSIA_HOME_URL, browser: { homeUrl: ECOSIA_HOME_URL, diff --git a/packages/coding-agent/src/web/search/providers/exa.ts b/packages/coding-agent/src/web/search/providers/exa.ts index 1e71ee610..e745f8ab9 100644 --- a/packages/coding-agent/src/web/search/providers/exa.ts +++ b/packages/coding-agent/src/web/search/providers/exa.ts @@ -115,6 +115,7 @@ export interface ExaSearchParams { start_published_date?: string; end_published_date?: string; signal?: AbortSignal; + timeoutMs?: number; fetch?: FetchImpl; /** * Credential source. Resolved before falling back to `EXA_API_KEY` so @@ -315,7 +316,7 @@ async function callExaSearch(apiKey: string, params: ExaSearchParams): Promise { let endpoints: string[]; @@ -383,7 +385,7 @@ async function callGeminiSearch( ...headers, }, body: JSON.stringify(requestBody), - signal: withHardTimeout(signal), + signal: withHardTimeout(signal, timeoutMs), }); let response: Response | undefined; @@ -442,6 +444,7 @@ async function callGeminiDeveloperSearch( toolParams: GeminiToolParams, fetchImpl: FetchImpl | undefined, signal: AbortSignal | undefined, + timeoutMs: number | undefined, ): Promise { const normalizedSystemPrompt = systemPrompt?.toWellFormed(); const requestBody: Record = { @@ -480,7 +483,7 @@ async function callGeminiDeveloperSearch( Accept: "text/event-stream", }, body: JSON.stringify(requestBody), - signal: withHardTimeout(signal), + signal: withHardTimeout(signal, timeoutMs), fetch: fetchImpl, maxAttempts: MAX_RETRIES + 1, defaultDelayMs: attempt => BASE_DELAY_MS * 2 ** attempt, @@ -548,6 +551,7 @@ export async function searchGemini(params: GeminiSearchParams): Promise { - const signal = withHardTimeout(params.signal); + const signal = withHardTimeout(params.signal, params.timeoutMs); const url = buildSearchUrl(params, numResults); let page: LoadedHtmlPage; try { page = await browserFetch(url, { fetch: params.fetch, signal, + timeoutMs: params.timeoutMs, referer: GOOGLE_HOME_URL, browser: { homeUrl: GOOGLE_HOME_URL, diff --git a/packages/coding-agent/src/web/search/providers/jina.ts b/packages/coding-agent/src/web/search/providers/jina.ts index a46ea5834..c3467c454 100644 --- a/packages/coding-agent/src/web/search/providers/jina.ts +++ b/packages/coding-agent/src/web/search/providers/jina.ts @@ -22,6 +22,7 @@ export interface JinaSearchParams { /** Single bare host for Jina's `X-Site` in-site search header. */ site?: string; signal?: AbortSignal; + timeoutMs?: number; fetch?: FetchImpl; } @@ -45,6 +46,7 @@ async function callJinaSearch( site?: string, signal?: AbortSignal, fetchImpl: FetchImpl = fetch, + timeoutMs?: number, ): Promise { const requestUrl = `${JINA_SEARCH_URL}/${encodeURIComponent(query)}`; const headers: Record = { @@ -54,7 +56,7 @@ async function callJinaSearch( if (site) headers["X-Site"] = site; const response = await fetchImpl(requestUrl, { headers, - signal: withHardTimeout(signal), + signal: withHardTimeout(signal, timeoutMs), }); if (!response.ok) { @@ -75,7 +77,14 @@ export async function searchJina(params: JinaSearchParams): Promise { @@ -122,7 +124,7 @@ async function callKimiSearch( enable_page_crawling: params.includeContent, timeout_seconds: DEFAULT_TIMEOUT_SECONDS, }), - signal: withHardTimeout(params.signal), + signal: withHardTimeout(params.signal, params.timeoutMs), }); if (!response.ok) { @@ -161,6 +163,7 @@ export async function searchKimi(params: KimiSearchParams): Promise { - const signal = withHardTimeout(params.signal); + const signal = withHardTimeout(params.signal, params.timeoutMs); const url = buildSearchUrl(params, numResults); let page: LoadedHtmlPage; try { page = await browserFetch(url, { fetch: params.fetch, signal, + timeoutMs: params.timeoutMs, randomizeHeaders: false, referer: MOJEEK_HOME_URL, browser: { diff --git a/packages/coding-agent/src/web/search/providers/parallel.ts b/packages/coding-agent/src/web/search/providers/parallel.ts index 957efde5f..c9b02fcee 100644 --- a/packages/coding-agent/src/web/search/providers/parallel.ts +++ b/packages/coding-agent/src/web/search/providers/parallel.ts @@ -59,6 +59,7 @@ async function searchWithAuthStorage( queries: string[], params: { signal?: AbortSignal; + timeoutMs?: number; fetch?: FetchImpl; }, authStorage: AuthStorage, @@ -97,7 +98,7 @@ async function searchWithAuthStorage( }, ...(sourcePolicy && { source_policy: sourcePolicy }), }), - signal: withHardTimeout(params.signal), + signal: withHardTimeout(params.signal, params.timeoutMs), }); if (!response.ok) { @@ -116,6 +117,7 @@ export async function searchParallel( query: string; num_results?: number; signal?: AbortSignal; + timeoutMs?: number; fetch?: FetchImpl; parsedQuery?: StructuredQuery; }, @@ -134,6 +136,7 @@ export async function searchParallel( [query], { signal: params.signal, + timeoutMs: params.timeoutMs, fetch: params.fetch, }, authStorage, @@ -172,6 +175,7 @@ export class ParallelProvider extends SearchProvider { query: params.query, num_results: params.numSearchResults ?? params.limit, signal: params.signal, + timeoutMs: params.timeoutMs, fetch: params.fetch, parsedQuery: params.parsedQuery, }, diff --git a/packages/coding-agent/src/web/search/providers/perplexity.ts b/packages/coding-agent/src/web/search/providers/perplexity.ts index c9c40c0a6..b0742b97d 100644 --- a/packages/coding-agent/src/web/search/providers/perplexity.ts +++ b/packages/coding-agent/src/web/search/providers/perplexity.ts @@ -322,6 +322,7 @@ function sourcesFromTextPayload(text: string | undefined): SearchSource[] { } export interface PerplexitySearchParams { signal?: AbortSignal; + timeoutMs?: number; query: string; system_prompt?: string; /** Pre-parsed view of `query` from the search pipeline; parsed locally when absent. */ @@ -498,10 +499,11 @@ async function callPerplexityApi( request: PerplexityRequest, fetchImpl: FetchImpl | undefined, signal?: AbortSignal, + timeoutMs?: number, ): Promise { const metadata: PerplexityApiStreamMetadata = {}; const context = buildPerplexityContext(request); - const requestSignal = withHardTimeout(signal); + const requestSignal = withHardTimeout(signal, timeoutMs); const onSseEvent = (event: { data: string }): void => { collectPerplexityMetadata(metadata, event.data); }; @@ -690,7 +692,7 @@ async function callPerplexityAsk( query_str: effectiveQuery, params: requestParams, }), - signal: withHardTimeout(params.signal), + signal: withHardTimeout(params.signal, params.timeoutMs), }; // The consumer ask endpoint intermittently drops the socket before sending an @@ -902,7 +904,7 @@ export async function searchPerplexity(params: PerplexitySearchParams): Promise< for (const auth of authMethods) { if (auth.type === "api_key") { try { - const result = await callPerplexityApi(auth, request, params.fetch, params.signal); + const result = await callPerplexityApi(auth, request, params.fetch, params.signal, params.timeoutMs); result.authMode = "api_key"; return applySourceLimit(result, params.num_results); } catch (error) { @@ -978,6 +980,7 @@ export class PerplexityProvider extends SearchProvider { search(params: SearchParams): Promise { return searchPerplexity({ signal: params.signal, + timeoutMs: params.timeoutMs, query: params.query, parsedQuery: params.parsedQuery, temperature: params.temperature, diff --git a/packages/coding-agent/src/web/search/providers/public.ts b/packages/coding-agent/src/web/search/providers/public.ts index 34c5a5617..c68c1cf28 100644 --- a/packages/coding-agent/src/web/search/providers/public.ts +++ b/packages/coding-agent/src/web/search/providers/public.ts @@ -130,7 +130,7 @@ export async function searchPublicWeb( // hard deadline; the straggler controller lets the aggregate cancel // still-running engines once it decides to return. const straggler = new AbortController(); - const signal = AbortSignal.any([withHardTimeout(params.signal), straggler.signal]); + const signal = AbortSignal.any([withHardTimeout(params.signal, params.timeoutMs), straggler.signal]); const responses: (SearchResponse | undefined)[] = new Array(engineIds.length); const failures: { provider: { id: SearchProviderId; label: string }; error: unknown }[] = []; diff --git a/packages/coding-agent/src/web/search/providers/searxng.ts b/packages/coding-agent/src/web/search/providers/searxng.ts index b5e4119e5..9ef4794f3 100644 --- a/packages/coding-agent/src/web/search/providers/searxng.ts +++ b/packages/coding-agent/src/web/search/providers/searxng.ts @@ -197,11 +197,12 @@ async function fetchEngineNameMap( auth: SearXNGAuth | null, fetchImpl: FetchImpl | undefined, signal: AbortSignal | undefined, + timeoutMs?: number, ): Promise | null> { try { const response = await (fetchImpl ?? fetch)(`${base}/config`, { headers: buildHeaders(auth), - signal: withHardTimeout(signal), + signal: withHardTimeout(signal, timeoutMs), }); if (!response.ok) return null; const config = (await response.json()) as SearXNGConfig; @@ -224,11 +225,12 @@ function getEngineNameMap( auth: SearXNGAuth | null, fetchImpl: FetchImpl | undefined, signal: AbortSignal | undefined, + timeoutMs?: number, ): Promise | null> { const base = endpoint.replace(/\/+$/, ""); let cached = engineNameMapCache.get(base); if (!cached) { - cached = fetchEngineNameMap(base, auth, fetchImpl, signal).then(map => { + cached = fetchEngineNameMap(base, auth, fetchImpl, signal, timeoutMs).then(map => { if (!map) engineNameMapCache.delete(base); return map; }); @@ -247,13 +249,14 @@ async function resolveEngineNames( auth: SearXNGAuth | null, fetchImpl: FetchImpl | undefined, signal: AbortSignal | undefined, + timeoutMs?: number, ): Promise { const entries = raw .split(",") .map(entry => entry.trim()) .filter(Boolean); if (!entries.length) return undefined; - const map = await getEngineNameMap(endpoint, auth, fetchImpl, signal); + const map = await getEngineNameMap(endpoint, auth, fetchImpl, signal, timeoutMs); if (!map) return entries.join(","); return entries.map(entry => map.get(entry.toLowerCase()) ?? entry).join(","); } @@ -324,6 +327,7 @@ async function callSearXNGSearch( engines?: string; language?: string; signal?: AbortSignal; + timeoutMs?: number; fetch?: FetchImpl; }, auth: SearXNGAuth | null, @@ -332,7 +336,7 @@ async function callSearXNGSearch( const response = await (params.fetch ?? fetch)(url, { headers, - signal: withHardTimeout(params.signal), + signal: withHardTimeout(params.signal, params.timeoutMs), }); if (!response.ok) { @@ -352,6 +356,7 @@ export async function searchSearXNG(params: { num_results?: number; recency?: "day" | "week" | "month" | "year"; signal?: AbortSignal; + timeoutMs?: number; fetch?: FetchImpl; }): Promise { const numResults = clampNumResults(params.num_results, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS); @@ -386,7 +391,7 @@ export async function searchSearXNG(params: { if (parsed.lang) language = parsed.lang; const engines = configuredEngines - ? await resolveEngineNames(configuredEngines, endpoint, auth, params.fetch, params.signal) + ? await resolveEngineNames(configuredEngines, endpoint, auth, params.fetch, params.signal, params.timeoutMs) : undefined; const response = await callSearXNGSearch( @@ -455,6 +460,7 @@ export class SearXNGProvider extends SearchProvider { num_results: params.numSearchResults ?? params.limit, recency: params.recency, signal: params.signal, + timeoutMs: params.timeoutMs, fetch: params.fetch, }); } diff --git a/packages/coding-agent/src/web/search/providers/startpage.ts b/packages/coding-agent/src/web/search/providers/startpage.ts index 5076f6612..58f192b58 100644 --- a/packages/coding-agent/src/web/search/providers/startpage.ts +++ b/packages/coding-agent/src/web/search/providers/startpage.ts @@ -121,10 +121,14 @@ function parseHtmlResults(html: string): ParsedResult[] { * any failure (network, non-OK status, challenge shell, markup drift) yields * `undefined` and the caller falls back to a direct GET. */ -async function fetchFormInputs(fetchImpl: FetchImpl, signal: AbortSignal): Promise | undefined> { +async function fetchFormInputs( + fetchImpl: FetchImpl, + signal: AbortSignal, + timeoutMs?: number, +): Promise | undefined> { let page: LoadedHtmlPage; try { - page = await browserFetch(STARTPAGE_HOME_URL, { fetch: fetchImpl, signal }); + page = await browserFetch(STARTPAGE_HOME_URL, { fetch: fetchImpl, signal, timeoutMs }); } catch (error) { if (signal.aborted) throw error; return undefined; @@ -135,7 +139,7 @@ async function fetchFormInputs(fetchImpl: FetchImpl, signal: AbortSignal): Promi async function callStartpageHtml(params: SearchParams): Promise { const fetchImpl = params.fetch ?? fetch; - const signal = withHardTimeout(params.signal); + const signal = withHardTimeout(params.signal, params.timeoutMs); const withDate = params.recency ? RECENCY_TO_STARTPAGE_WITH_DATE[params.recency] : undefined; // Startpage proxies Google, so the operator set works inline; rebuild via // the shared scraper formatter to canonicalize aliases (domain: → site:, @@ -143,7 +147,7 @@ async function callStartpageHtml(params: SearchParams): Promise { // queries pass through byte-identical. const query = formatScraperQuery(params.query, params.parsedQuery); - const formInputs = await fetchFormInputs(fetchImpl, signal); + const formInputs = await fetchFormInputs(fetchImpl, signal, params.timeoutMs); let page: LoadedHtmlPage; if (formInputs) { const form = new URLSearchParams(formInputs); @@ -152,6 +156,7 @@ async function callStartpageHtml(params: SearchParams): Promise { page = await browserFetch(STARTPAGE_SEARCH_URL, { fetch: fetchImpl, signal, + timeoutMs: params.timeoutMs, referer: STARTPAGE_HOME_URL, init: { method: "POST", body: form.toString() }, headers: { "Content-Type": "application/x-www-form-urlencoded" }, @@ -163,6 +168,7 @@ async function callStartpageHtml(params: SearchParams): Promise { page = await browserFetch(url.href, { fetch: fetchImpl, signal, + timeoutMs: params.timeoutMs, referer: STARTPAGE_HOME_URL, }); } diff --git a/packages/coding-agent/src/web/search/providers/synthetic.ts b/packages/coding-agent/src/web/search/providers/synthetic.ts index ad4397eb2..37d37005c 100644 --- a/packages/coding-agent/src/web/search/providers/synthetic.ts +++ b/packages/coding-agent/src/web/search/providers/synthetic.ts @@ -43,6 +43,7 @@ async function callSyntheticSearch( query: string, signal?: AbortSignal, fetchImpl: FetchImpl = fetch, + timeoutMs?: number, ): Promise { const response = await fetchImpl(SYNTHETIC_SEARCH_URL, { method: "POST", @@ -51,7 +52,7 @@ async function callSyntheticSearch( Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify({ query }), - signal: withHardTimeout(signal), + signal: withHardTimeout(signal, timeoutMs), }); if (!response.ok) { @@ -80,10 +81,15 @@ export async function searchSynthetic(params: SearchParamsWithFetch): Promise callSyntheticSearch(key, query, params.signal, fetchImpl), { - signal: params.signal, - missingKeyMessage: "Synthetic credentials not found. Set SYNTHETIC_API_KEY or login with 'omp /login synthetic'.", - }); + const data = await withAuth( + keyOrResolver, + key => callSyntheticSearch(key, query, params.signal, fetchImpl, params.timeoutMs), + { + signal: params.signal, + missingKeyMessage: + "Synthetic credentials not found. Set SYNTHETIC_API_KEY or login with 'omp /login synthetic'.", + }, + ); const sources: SearchSource[] = []; for (const result of data.results ?? []) { diff --git a/packages/coding-agent/src/web/search/providers/tavily.ts b/packages/coding-agent/src/web/search/providers/tavily.ts index fd53f9fc9..a31d6ac9c 100644 --- a/packages/coding-agent/src/web/search/providers/tavily.ts +++ b/packages/coding-agent/src/web/search/providers/tavily.ts @@ -30,6 +30,7 @@ export interface TavilySearchParams { /** `before:` upper bound, ISO `YYYY-MM-DD`, mapped to `end_date`. */ end_date?: string; signal?: AbortSignal; + timeoutMs?: number; fetch?: FetchImpl; } @@ -120,7 +121,7 @@ async function callTavilySearch(apiKey: string, params: TavilySearchParams): Pro Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify(buildRequestBody(params)), - signal: withHardTimeout(params.signal), + signal: withHardTimeout(params.signal, params.timeoutMs), }); if (!response.ok) { @@ -189,6 +190,7 @@ export async function searchTavily(params: SearchParams): Promise { const headers: Record = { Authorization: `Bearer ${apiKey}`, @@ -144,7 +146,7 @@ async function postZaiMcp( method: "POST", headers, body: JSON.stringify(body), - signal: withHardTimeout(signal), + signal: withHardTimeout(signal, timeoutMs), }); if (!response.ok) { @@ -211,6 +213,7 @@ async function callZaiTool( args: Record, signal: AbortSignal | undefined, fetchImpl: FetchImpl, + timeoutMs?: number, ): Promise { const initialized = await postZaiMcp( apiKey, @@ -224,12 +227,22 @@ async function callZaiTool( signal, fetchImpl, true, + timeoutMs, ); if (initialized.parsed !== undefined) { readJsonRpcPayload(initialized.parsed); } - await postZaiMcp(apiKey, "notifications/initialized", {}, initialized.sessionId, signal, fetchImpl, false); + await postZaiMcp( + apiKey, + "notifications/initialized", + {}, + initialized.sessionId, + signal, + fetchImpl, + false, + timeoutMs, + ); const toolCall = await postZaiMcp( apiKey, @@ -242,6 +255,7 @@ async function callZaiTool( signal, fetchImpl, true, + timeoutMs, ); const payload = readJsonRpcPayload(toolCall.parsed); const resultRecord = isRecord(payload.result) ? payload.result : null; @@ -279,7 +293,7 @@ async function callZaiSearch(apiKey: string, params: ZaiSearchParams): Promise; diff --git a/packages/coding-agent/test/tools/web-search-codex.test.ts b/packages/coding-agent/test/tools/web-search-codex.test.ts index 4dfb2ac85..e89f768ce 100644 --- a/packages/coding-agent/test/tools/web-search-codex.test.ts +++ b/packages/coding-agent/test/tools/web-search-codex.test.ts @@ -8,6 +8,7 @@ type CapturedRequest = { url: string; headers: RequestInit["headers"]; body: Record | null; + signal?: AbortSignal | null; }; const originalCodexSearchModel = process.env.PI_CODEX_WEB_SEARCH_MODEL; @@ -264,6 +265,7 @@ describe("searchCodex model selection", () => { url: typeof url === "string" ? url : url.toString(), headers: init?.headers, body: init?.body ? (JSON.parse(init.body as string) as Record) : null, + signal: init?.signal, }; return Promise.resolve( new Response(responseBody ?? makeSseResponse(responseModel), { @@ -295,6 +297,19 @@ describe("searchCodex model selection", () => { expect(result.sources).toEqual([{ title: "Example Article", url: "https://example.com/article" }]); }); + it("applies the configured request timeout to Codex search", async () => { + const timeoutSignal = new AbortController().signal; + const timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockReturnValue(timeoutSignal); + + await searchCodex({ + ...makeSearchParams("slow codex search", mockCodexFetch("gpt-5.6-luna")), + timeoutMs: 180_000, + }); + + expect(timeoutSpy).toHaveBeenCalledWith(180_000); + expect(capturedRequest?.signal).toBe(timeoutSignal); + }); + function sentUserText(): string | undefined { const input = capturedRequest?.body?.input as Array> | undefined; const userItem = input?.find(item => item.role === "user"); diff --git a/packages/coding-agent/test/tools/web-search-kagi.test.ts b/packages/coding-agent/test/tools/web-search-kagi.test.ts index a38edc1aa..1a99835ee 100644 --- a/packages/coding-agent/test/tools/web-search-kagi.test.ts +++ b/packages/coding-agent/test/tools/web-search-kagi.test.ts @@ -58,6 +58,29 @@ describe("Kagi web search error handling", () => { "Kagi API error (502)", ); }); + it("applies the configured timeout at the provider fetch boundary", async () => { + const timeoutSignal = new AbortController().signal; + const timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockReturnValue(timeoutSignal); + let fetchSignal: AbortSignal | null | undefined; + const fetchMock: FetchImpl = async (_input, init) => { + fetchSignal = init?.signal; + return new Response(JSON.stringify({ meta: { trace: "req-timeout" }, data: {} }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + await new KagiProvider().search({ + query: "slow kagi search", + systemPrompt: "", + authStorage: fakeAuthStorage, + timeoutMs: 180_000, + fetch: fetchMock, + }); + + expect(timeoutSpy).toHaveBeenCalledWith(180_000); + expect(fetchSignal).toBe(timeoutSignal); + }); }); describe("Kagi search result parsing", () => { 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 641a2592c..7b72aa761 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 @@ -13,6 +13,7 @@ */ import { afterEach, describe, expect, it, vi } from "bun:test"; import type { AuthStorage, FetchImpl } from "@oh-my-pi/pi-ai"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import type { AgentStorage } from "@oh-my-pi/pi-coding-agent/session/agent-storage"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { ToolAbortError } from "@oh-my-pi/pi-coding-agent/tools/tool-errors"; @@ -162,7 +163,10 @@ describe("Brave provider hard-timeout wiring", () => { }); describe("executeSearch abort propagation", () => { - afterEach(() => vi.restoreAllMocks()); + afterEach(() => { + vi.restoreAllMocks(); + resetSettingsForTest(); + }); function fakeProvider( id: SearchProviderId, @@ -188,6 +192,47 @@ describe("executeSearch abort propagation", () => { }); } + it("passes the configured provider-request timeout into the search adapter", async () => { + resetSettingsForTest(); + const config = await Settings.init({ inMemory: true }); + config.set("providers.webSearchTimeoutSeconds", 180); + let timeoutMs: number | undefined; + mockProviderChain([ + fakeProvider("codex", async params => { + timeoutMs = params.timeoutMs; + return { + provider: "codex", + sources: [{ title: "Configured result", url: "https://example.com/configured" }], + }; + }), + ]); + + const result = await new WebSearchTool(FAKE_SESSION).execute("test-id", { query: "anything" }); + + expect(result.details?.response.provider).toBe("codex"); + expect(timeoutMs).toBe(180_000); + }); + + it("uses the default provider timeout for a non-positive setting", async () => { + resetSettingsForTest(); + const config = await Settings.init({ inMemory: true }); + config.set("providers.webSearchTimeoutSeconds", 0); + let timeoutMs: number | undefined; + mockProviderChain([ + fakeProvider("codex", async params => { + timeoutMs = params.timeoutMs; + return { + provider: "codex", + sources: [{ title: "Default result", url: "https://example.com/default" }], + }; + }), + ]); + + await new WebSearchTool(FAKE_SESSION).execute("test-id", { query: "anything" }); + + expect(timeoutMs).toBe(60_000); + }); + it("surfaces caller cancellation as ToolAbortError instead of falling through to the next provider", async () => { // Two providers: the first throws an AbortError after the caller aborted, // the second would happily return a value. Pre-fix, executeSearch would