mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:40:44 +00:00
fix(xai): harden Grok web search timeouts
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)] };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user