From 01aea41c2b09950fb8f53cfc7613889d6c257f04 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 02:55:11 +0100 Subject: [PATCH] fix(xai): harden Grok web search timeouts --- CHANGELOG.md | 4 ++ docs/tools/grok-search.md | 4 ++ .../xai/src/responses-tool-shared.test.ts | 29 ++++++++++++++ extensions/xai/src/responses-tool-shared.ts | 40 +++++++++++++------ .../xai/src/web-search-provider.runtime.ts | 12 +++++- .../xai/src/web-search-response.types.ts | 8 ++-- extensions/xai/src/web-search-shared.ts | 19 ++++++++- extensions/xai/web-search.test.ts | 22 ++++++++++ 8 files changed, 119 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd66ab60d4a..19c01e86e5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ Docs: https://docs.openclaw.ai - Heartbeat: strip legacy `[TOOL_CALL]...[/TOOL_CALL]` and `[TOOL_RESULT]...[/TOOL_RESULT]` pseudo-call blocks from heartbeat replies before channel delivery. Fixes #54138. Thanks @Deniable9570. - macOS/Voice Wake: send wake-word and Push-to-Talk transcripts through the selected macOS session target instead of always falling back to main WebChat. Fixes #51040. Thanks @carl-jeffrolc. +- Providers/xAI: give Grok `web_search` a 60s default timeout, harden malformed xAI Responses parsing, and return structured timeout errors instead of aborting the tool call. Fixes #58063 and #58733. Thanks @dnishimura, @marvcasasola-svg, and @Nanako0129. +- Heartbeat: strip legacy `[TOOL_CALL]...[/TOOL_CALL]` and `[TOOL_RESULT]...[/TOOL_RESULT]` pseudo-call blocks from heartbeat replies before channel delivery. Fixes #54138. Thanks @Deniable9570. +- macOS/Voice Wake: send wake-word and Push-to-Talk transcripts through the selected macOS session target instead of always falling back to main WebChat. Fixes #51040. Thanks @carl-jeffrolc. +- Providers/xAI: give Grok `web_search` a 60s default timeout, harden malformed xAI Responses parsing, and return structured timeout errors instead of aborting the tool call. Fixes #58063 and #58733. Thanks @dnishimura, @marvcasasola-svg, and @Nanako0129. - Slack: recover full inbound DM text from top-level rich-text blocks when Slack sends a shortened message preview, so long direct messages still reach the agent intact. Fixes #55358. Thanks @tonyjwinter. - Replies: strip legacy `[TOOL_CALL]{tool => ..., args => ...}[/TOOL_CALL]` pseudo-call text from user-facing replies and flag it in tool-call diagnostics instead of showing raw tool syntax in channels. Fixes #63610. Thanks @canh0chua. - WhatsApp: close long-lived web sockets through Baileys `end(error)` before falling back to raw websocket close, so listener teardown runs Baileys cleanup instead of leaving zombie sockets. Fixes #52442. Thanks @essendigitalgroup-cyber. diff --git a/docs/tools/grok-search.md b/docs/tools/grok-search.md index 5580809623a..190e384835b 100644 --- a/docs/tools/grok-search.md +++ b/docs/tools/grok-search.md @@ -93,6 +93,10 @@ returns one synthesized answer with citations rather than an N-result list. Provider-specific filters are not currently supported. +Grok uses a provider-specific 60 second default timeout because xAI Responses +web-grounded searches can run longer than the shared `web_search` default. Set +`tools.web.search.timeoutSeconds` to override it. + ## Related - [Web Search overview](/tools/web) -- all providers and auto-detection diff --git a/extensions/xai/src/responses-tool-shared.test.ts b/extensions/xai/src/responses-tool-shared.test.ts index 05550f11750..5eff5b54c0a 100644 --- a/extensions/xai/src/responses-tool-shared.test.ts +++ b/extensions/xai/src/responses-tool-shared.test.ts @@ -40,6 +40,35 @@ describe("xai responses tool helpers", () => { }); }); + it("ignores malformed output, content, and annotation entries", () => { + expect( + __testing.extractXaiWebSearchContent({ + output: [ + null, + { + type: "message", + content: [ + null, + { + type: "output_text", + text: "Found it", + annotations: [ + null, + { type: "url_citation", url: "https://example.com/a" }, + { type: "url_citation", url: "https://example.com/a" }, + { type: "url_citation" }, + ], + }, + ], + }, + ], + }), + ).toEqual({ + text: "Found it", + annotationCitations: ["https://example.com/a"], + }); + }); + it("prefers explicit top-level citations when present", () => { expect( __testing.resolveXaiResponseTextAndCitations({ diff --git a/extensions/xai/src/responses-tool-shared.ts b/extensions/xai/src/responses-tool-shared.ts index bf80ff8809a..3d1e1129a05 100644 --- a/extensions/xai/src/responses-tool-shared.ts +++ b/extensions/xai/src/responses-tool-shared.ts @@ -1,5 +1,23 @@ import type { XaiWebSearchResponse } from "./web-search-response.types.js"; +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object"; +} + +function extractUrlCitations(annotations: unknown): string[] { + if (!Array.isArray(annotations)) { + return []; + } + return annotations + .filter( + (annotation) => + isRecord(annotation) && + annotation.type === "url_citation" && + typeof annotation.url === "string", + ) + .map((annotation) => annotation.url as string); +} + export const XAI_RESPONSES_ENDPOINT = "https://api.x.ai/v1/responses"; export function buildXaiResponsesToolBody(params: { @@ -21,26 +39,24 @@ export function extractXaiWebSearchContent(data: XaiWebSearchResponse): { annotationCitations: string[]; } { for (const output of data.output ?? []) { + if (!isRecord(output)) { + continue; + } if (output.type === "message") { - for (const block of output.content ?? []) { + const content = Array.isArray(output.content) ? output.content : []; + for (const block of content) { + if (!isRecord(block)) { + continue; + } if (block.type === "output_text" && typeof block.text === "string" && block.text) { - const urls = (block.annotations ?? []) - .filter( - (annotation) => - annotation.type === "url_citation" && typeof annotation.url === "string", - ) - .map((annotation) => annotation.url as string); + const urls = extractUrlCitations(block.annotations); return { text: block.text, annotationCitations: [...new Set(urls)] }; } } } if (output.type === "output_text" && typeof output.text === "string" && output.text) { - const urls = (output.annotations ?? []) - .filter( - (annotation) => annotation.type === "url_citation" && typeof annotation.url === "string", - ) - .map((annotation) => annotation.url as string); + const urls = extractUrlCitations(output.annotations); return { text: output.text, annotationCitations: [...new Set(urls)] }; } } diff --git a/extensions/xai/src/web-search-provider.runtime.ts b/extensions/xai/src/web-search-provider.runtime.ts index 3be666d2bc8..8ce5829d07b 100644 --- a/extensions/xai/src/web-search-provider.runtime.ts +++ b/extensions/xai/src/web-search-provider.runtime.ts @@ -1,6 +1,5 @@ import { DEFAULT_CACHE_TTL_MINUTES, - DEFAULT_TIMEOUT_SECONDS, formatCliCommand, getScopedCredentialValue, mergeScopedSearchConfig, @@ -29,6 +28,7 @@ const XAI_WEB_SEARCH_CACHE = new Map< string, { value: Record; insertedAt: number; expiresAt: number } >(); +const XAI_WEB_SEARCH_DEFAULT_TIMEOUT_SECONDS = 60; const X_SEARCH_MODEL_OPTIONS = [ { @@ -176,6 +176,13 @@ function resolveXaiWebSearchCredential(searchConfig?: Record): }); } +function resolveXaiWebSearchTimeoutSeconds(searchConfig?: Record): number { + return resolveTimeoutSeconds( + searchConfig?.timeoutSeconds, + XAI_WEB_SEARCH_DEFAULT_TIMEOUT_SECONDS, + ); +} + export async function executeXaiWebSearchProviderTool( ctx: { config?: Record; searchConfig?: Record }, args: Record, @@ -199,7 +206,7 @@ export async function executeXaiWebSearchProviderTool( query, model: resolveXaiWebSearchModel(searchConfig), apiKey, - timeoutSeconds: resolveTimeoutSeconds(searchConfig?.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS), + timeoutSeconds: resolveXaiWebSearchTimeoutSeconds(searchConfig), inlineCitations: resolveXaiInlineCitations(searchConfig), cacheTtlMs: resolveCacheTtlMs(searchConfig?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES), }); @@ -212,5 +219,6 @@ export const __testing = { resolveXaiInlineCitations, resolveXaiWebSearchCredential, resolveXaiWebSearchModel, + resolveXaiWebSearchTimeoutSeconds, requestXaiWebSearch, }; diff --git a/extensions/xai/src/web-search-response.types.ts b/extensions/xai/src/web-search-response.types.ts index 5f78b1e7ce3..c9ce32060f0 100644 --- a/extensions/xai/src/web-search-response.types.ts +++ b/extensions/xai/src/web-search-response.types.ts @@ -8,13 +8,13 @@ export type XaiWebSearchResponse = { annotations?: Array<{ type?: string; url?: string; - }>; - }>; + } | null>; + } | null>; annotations?: Array<{ type?: string; url?: string; - }>; - }>; + } | null>; + } | null>; output_text?: string; citations?: string[]; inline_citations?: Array<{ diff --git a/extensions/xai/src/web-search-shared.ts b/extensions/xai/src/web-search-shared.ts index c8b05f9d700..bee2c743042 100644 --- a/extensions/xai/src/web-search-shared.ts +++ b/extensions/xai/src/web-search-shared.ts @@ -68,6 +68,23 @@ export function resolveXaiInlineCitations(searchConfig?: Record return resolveXaiSearchConfig(searchConfig).inlineCitations === true; } +function isAbortError(error: unknown): boolean { + return ( + error instanceof Error && + (error.name === "AbortError" || error.message === "This operation was aborted") + ); +} + +export function wrapXaiWebSearchError(error: unknown, timeoutSeconds: number): never { + if (isAbortError(error)) { + throw new Error( + `xAI web search timed out after ${timeoutSeconds}s. Increase tools.web.search.timeoutSeconds if queries are complex.`, + { cause: error }, + ); + } + throw error; +} + export async function requestXaiWebSearch(params: { query: string; model: string; @@ -91,5 +108,5 @@ export async function requestXaiWebSearch(params: { const data = (await response.json()) as XaiWebSearchResponse; return resolveXaiResponseTextCitationsAndInline(data, params.inlineCitations); }, - ); + ).catch((error: unknown) => wrapXaiWebSearchError(error, params.timeoutSeconds)); } diff --git a/extensions/xai/web-search.test.ts b/extensions/xai/web-search.test.ts index 776be93b0d0..3c6c7a71a73 100644 --- a/extensions/xai/web-search.test.ts +++ b/extensions/xai/web-search.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it, vi } from "vitest"; import { resolveXaiCatalogEntry } from "./model-definitions.js"; import { isModernXaiModel, resolveXaiForwardCompatModel } from "./provider-models.js"; import { resolveFallbackXaiAuth } from "./src/tool-auth-shared.js"; +import { wrapXaiWebSearchError } from "./src/web-search-shared.js"; import { __testing } from "./test-api.js"; import { createXaiWebSearchProvider } from "./web-search.js"; @@ -15,6 +16,7 @@ const { resolveXaiToolSearchConfig, resolveXaiWebSearchCredential, resolveXaiWebSearchModel, + resolveXaiWebSearchTimeoutSeconds, } = __testing; describe("xai web search config resolution", () => { @@ -253,6 +255,12 @@ describe("xai web search config resolution", () => { expect(resolveXaiWebSearchModel(undefined)).toBe("grok-4-1-fast"); }); + it("uses a Grok-specific 60s default timeout while preserving overrides", () => { + expect(resolveXaiWebSearchTimeoutSeconds({})).toBe(60); + expect(resolveXaiWebSearchTimeoutSeconds(undefined)).toBe(60); + expect(resolveXaiWebSearchTimeoutSeconds({ timeoutSeconds: 15 })).toBe(15); + }); + it("uses config model when provided", () => { expect(resolveXaiWebSearchModel({ grok: { model: "grok-4-fast-reasoning" } })).toBe( "grok-4-fast", @@ -301,6 +309,20 @@ describe("xai web search config resolution", () => { externalContent: expect.objectContaining({ wrapped: true }), }); }); + + it("converts internal xAI timeout aborts into structured tool errors", () => { + const abort = new DOMException("This operation was aborted", "AbortError"); + + expect(() => wrapXaiWebSearchError(abort, 60)).toThrow("xAI web search timed out after 60s"); + + try { + wrapXaiWebSearchError(abort, 60); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).name).toBe("Error"); + expect((error as Error).cause).toBe(abort); + } + }); }); describe("xai web search response parsing", () => {