fix(xai): harden Grok web search timeouts

This commit is contained in:
Peter Steinberger
2026-05-02 02:55:11 +01:00
parent ecef57831c
commit 01aea41c2b
8 changed files with 119 additions and 19 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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({

View File

@@ -1,5 +1,23 @@
import type { XaiWebSearchResponse } from "./web-search-response.types.js";
function isRecord(value: unknown): value is Record<string, unknown> {
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)] };
}
}

View File

@@ -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<string, unknown>; 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<string, unknown>):
});
}
function resolveXaiWebSearchTimeoutSeconds(searchConfig?: Record<string, unknown>): number {
return resolveTimeoutSeconds(
searchConfig?.timeoutSeconds,
XAI_WEB_SEARCH_DEFAULT_TIMEOUT_SECONDS,
);
}
export async function executeXaiWebSearchProviderTool(
ctx: { config?: Record<string, unknown>; searchConfig?: Record<string, unknown> },
args: Record<string, unknown>,
@@ -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,
};

View File

@@ -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<{

View File

@@ -68,6 +68,23 @@ export function resolveXaiInlineCitations(searchConfig?: Record<string, unknown>
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));
}

View File

@@ -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", () => {