From 397b0d85f571459c6d56d1a4eba290b23662807a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 19 Mar 2026 23:59:45 -0700 Subject: [PATCH] fix(tui): split assistant error formatting seam --- src/agents/pi-embedded-helpers/errors.ts | 149 ++---------------- src/shared/assistant-error-format.ts | 188 +++++++++++++++++++++++ src/tui/tui-formatters.ts | 2 +- 3 files changed, 204 insertions(+), 135 deletions(-) create mode 100644 src/shared/assistant-error-format.ts diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 605cdd22118..2fec27a45e2 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -1,6 +1,17 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import type { OpenClawConfig } from "../../config/config.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { + extractLeadingHttpStatus, + formatRawAssistantErrorForUi, + isCloudflareOrHtmlErrorPage, +} from "../../shared/assistant-error-format.js"; +export { + extractLeadingHttpStatus, + formatRawAssistantErrorForUi, + isCloudflareOrHtmlErrorPage, + parseApiErrorInfo, +} from "../../shared/assistant-error-format.js"; import { formatSandboxToolPolicyBlockedMessage } from "../sandbox.js"; import { stableStringify } from "../stable-stringify.js"; import { @@ -220,10 +231,6 @@ const ERROR_PREFIX_RE = /^(?:error|(?:[a-z][\w-]*\s+)?api\s*error|openai\s*error|anthropic\s*error|gateway\s*error|request failed|failed|exception)(?:\s+\d{3})?[:\s-]+/i; const CONTEXT_OVERFLOW_ERROR_HEAD_RE = /^(?:context overflow:|request_too_large\b|request size exceeds\b|request exceeds the maximum size\b|context length exceeded\b|maximum context length\b|prompt is too long\b|exceeds model context window\b)/i; -const HTTP_STATUS_PREFIX_RE = /^(?:http\s*)?(\d{3})\s+(.+)$/i; -const HTTP_STATUS_CODE_PREFIX_RE = /^(?:http\s*)?(\d{3})(?:\s+([\s\S]+))?$/i; -const HTML_ERROR_PREFIX_RE = /^\s*(?:/i.test(status.rest) - ); -} - export function isTransientHttpError(raw: string): boolean { const trimmed = raw.trim(); if (!trimmed) { @@ -484,15 +459,14 @@ function isLikelyHttpErrorText(raw: string): boolean { if (isCloudflareOrHtmlErrorPage(raw)) { return true; } - const match = raw.match(HTTP_STATUS_PREFIX_RE); - if (!match) { + const status = extractLeadingHttpStatus(raw); + if (!status) { return false; } - const code = Number(match[1]); - if (!Number.isFinite(code) || code < 400) { + if (status.code < 400) { return false; } - const message = match[2].toLowerCase(); + const message = status.rest.toLowerCase(); return HTTP_ERROR_HINTS.some((hint) => message.includes(hint)); } @@ -580,99 +554,6 @@ export function isRawApiErrorPayload(raw?: string): boolean { return getApiErrorPayloadFingerprint(raw) !== null; } -export type ApiErrorInfo = { - httpCode?: string; - type?: string; - message?: string; - requestId?: string; -}; - -export function parseApiErrorInfo(raw?: string): ApiErrorInfo | null { - if (!raw) { - return null; - } - const trimmed = raw.trim(); - if (!trimmed) { - return null; - } - - let httpCode: string | undefined; - let candidate = trimmed; - - const httpPrefixMatch = candidate.match(/^(\d{3})\s+(.+)$/s); - if (httpPrefixMatch) { - httpCode = httpPrefixMatch[1]; - candidate = httpPrefixMatch[2].trim(); - } - - const payload = parseApiErrorPayload(candidate); - if (!payload) { - return null; - } - - const requestId = - typeof payload.request_id === "string" - ? payload.request_id - : typeof payload.requestId === "string" - ? payload.requestId - : undefined; - - const topType = typeof payload.type === "string" ? payload.type : undefined; - const topMessage = typeof payload.message === "string" ? payload.message : undefined; - - let errType: string | undefined; - let errMessage: string | undefined; - if (payload.error && typeof payload.error === "object" && !Array.isArray(payload.error)) { - const err = payload.error as Record; - if (typeof err.type === "string") { - errType = err.type; - } - if (typeof err.code === "string" && !errType) { - errType = err.code; - } - if (typeof err.message === "string") { - errMessage = err.message; - } - } - - return { - httpCode, - type: errType ?? topType, - message: errMessage ?? topMessage, - requestId, - }; -} - -export function formatRawAssistantErrorForUi(raw?: string): string { - const trimmed = (raw ?? "").trim(); - if (!trimmed) { - return "LLM request failed with an unknown error."; - } - - const leadingStatus = extractLeadingHttpStatus(trimmed); - if (leadingStatus && isCloudflareOrHtmlErrorPage(trimmed)) { - return `The AI service is temporarily unavailable (HTTP ${leadingStatus.code}). Please try again in a moment.`; - } - - const httpMatch = trimmed.match(HTTP_STATUS_PREFIX_RE); - if (httpMatch) { - const rest = httpMatch[2].trim(); - if (!rest.startsWith("{")) { - return `HTTP ${httpMatch[1]}: ${rest}`; - } - } - - const info = parseApiErrorInfo(trimmed); - if (info?.message) { - const prefix = info.httpCode ? `HTTP ${info.httpCode}` : "LLM error"; - const type = info.type ? ` ${info.type}` : ""; - const requestId = info.requestId ? ` (request_id: ${info.requestId})` : ""; - return `${prefix}${type}: ${info.message}${requestId}`; - } - - return trimmed.length > 600 ? `${trimmed.slice(0, 600)}…` : trimmed; -} - export function formatAssistantErrorText( msg: AssistantMessage, opts?: { cfg?: OpenClawConfig; sessionKey?: string; provider?: string; model?: string }, diff --git a/src/shared/assistant-error-format.ts b/src/shared/assistant-error-format.ts new file mode 100644 index 00000000000..6564cf5c641 --- /dev/null +++ b/src/shared/assistant-error-format.ts @@ -0,0 +1,188 @@ +const ERROR_PAYLOAD_PREFIX_RE = + /^(?:error|(?:[a-z][\w-]*\s+)?api\s*error|apierror|openai\s*error|anthropic\s*error|gateway\s*error)(?:\s+\d{3})?[:\s-]+/i; +const HTTP_STATUS_PREFIX_RE = /^(?:http\s*)?(\d{3})\s+(.+)$/i; +const HTTP_STATUS_CODE_PREFIX_RE = /^(?:http\s*)?(\d{3})(?:\s+([\s\S]+))?$/i; +const HTML_ERROR_PREFIX_RE = /^\s*(?:; + +export type ApiErrorInfo = { + httpCode?: string; + type?: string; + message?: string; + requestId?: string; +}; + +function isErrorPayloadObject(payload: unknown): payload is ErrorPayload { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return false; + } + const record = payload as ErrorPayload; + if (record.type === "error") { + return true; + } + if (typeof record.request_id === "string" || typeof record.requestId === "string") { + return true; + } + if ("error" in record) { + const err = record.error; + if (err && typeof err === "object" && !Array.isArray(err)) { + const errRecord = err as ErrorPayload; + if ( + typeof errRecord.message === "string" || + typeof errRecord.type === "string" || + typeof errRecord.code === "string" + ) { + return true; + } + } + } + return false; +} + +function parseApiErrorPayload(raw: string): ErrorPayload | null { + if (!raw) { + return null; + } + const trimmed = raw.trim(); + if (!trimmed) { + return null; + } + const candidates = [trimmed]; + if (ERROR_PAYLOAD_PREFIX_RE.test(trimmed)) { + candidates.push(trimmed.replace(ERROR_PAYLOAD_PREFIX_RE, "").trim()); + } + for (const candidate of candidates) { + if (!candidate.startsWith("{") || !candidate.endsWith("}")) { + continue; + } + try { + const parsed = JSON.parse(candidate) as unknown; + if (isErrorPayloadObject(parsed)) { + return parsed; + } + } catch { + // ignore parse errors + } + } + return null; +} + +export function extractLeadingHttpStatus(raw: string): { code: number; rest: string } | null { + const match = raw.match(HTTP_STATUS_CODE_PREFIX_RE); + if (!match) { + return null; + } + const code = Number(match[1]); + if (!Number.isFinite(code)) { + return null; + } + return { code, rest: (match[2] ?? "").trim() }; +} + +export function isCloudflareOrHtmlErrorPage(raw: string): boolean { + const trimmed = raw.trim(); + if (!trimmed) { + return false; + } + + const status = extractLeadingHttpStatus(trimmed); + if (!status || status.code < 500) { + return false; + } + + if (CLOUDFLARE_HTML_ERROR_CODES.has(status.code)) { + return true; + } + + return ( + status.code < 600 && HTML_ERROR_PREFIX_RE.test(status.rest) && /<\/html>/i.test(status.rest) + ); +} + +export function parseApiErrorInfo(raw?: string): ApiErrorInfo | null { + if (!raw) { + return null; + } + const trimmed = raw.trim(); + if (!trimmed) { + return null; + } + + let httpCode: string | undefined; + let candidate = trimmed; + + const httpPrefixMatch = candidate.match(/^(\d{3})\s+(.+)$/s); + if (httpPrefixMatch) { + httpCode = httpPrefixMatch[1]; + candidate = httpPrefixMatch[2].trim(); + } + + const payload = parseApiErrorPayload(candidate); + if (!payload) { + return null; + } + + const requestId = + typeof payload.request_id === "string" + ? payload.request_id + : typeof payload.requestId === "string" + ? payload.requestId + : undefined; + + const topType = typeof payload.type === "string" ? payload.type : undefined; + const topMessage = typeof payload.message === "string" ? payload.message : undefined; + + let errType: string | undefined; + let errMessage: string | undefined; + if (payload.error && typeof payload.error === "object" && !Array.isArray(payload.error)) { + const err = payload.error as Record; + if (typeof err.type === "string") { + errType = err.type; + } + if (typeof err.code === "string" && !errType) { + errType = err.code; + } + if (typeof err.message === "string") { + errMessage = err.message; + } + } + + return { + httpCode, + type: errType ?? topType, + message: errMessage ?? topMessage, + requestId, + }; +} + +export function formatRawAssistantErrorForUi(raw?: string): string { + const trimmed = (raw ?? "").trim(); + if (!trimmed) { + return "LLM request failed with an unknown error."; + } + + const leadingStatus = extractLeadingHttpStatus(trimmed); + if (leadingStatus && isCloudflareOrHtmlErrorPage(trimmed)) { + return `The AI service is temporarily unavailable (HTTP ${leadingStatus.code}). Please try again in a moment.`; + } + + const httpMatch = trimmed.match(HTTP_STATUS_PREFIX_RE); + if (httpMatch) { + const rest = httpMatch[2].trim(); + if (!rest.startsWith("{")) { + return `HTTP ${httpMatch[1]}: ${rest}`; + } + } + + const info = parseApiErrorInfo(trimmed); + if (info?.message) { + const prefix = info.httpCode ? `HTTP ${info.httpCode}` : "LLM error"; + const type = info.type ? ` ${info.type}` : ""; + const requestId = info.requestId ? ` (request_id: ${info.requestId})` : ""; + return `${prefix}${type}: ${info.message}${requestId}`; + } + + return trimmed.length > 600 ? `${trimmed.slice(0, 600)}…` : trimmed; +} diff --git a/src/tui/tui-formatters.ts b/src/tui/tui-formatters.ts index 566839c7cf1..a47e4177215 100644 --- a/src/tui/tui-formatters.ts +++ b/src/tui/tui-formatters.ts @@ -1,5 +1,5 @@ -import { formatRawAssistantErrorForUi } from "../agents/pi-embedded-helpers.js"; import { stripLeadingInboundMetadata } from "../auto-reply/reply/strip-inbound-meta.js"; +import { formatRawAssistantErrorForUi } from "../shared/assistant-error-format.js"; import { stripAnsi } from "../terminal/ansi.js"; import { formatTokenCount } from "../utils/usage-format.js";