From f65668cb5fe6d4920803d74faa894a735d21d912 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 15 Jan 2026 01:34:19 +0000 Subject: [PATCH] fix: suppress raw API error payloads (#924) (thanks @grp06) Co-authored-by: George Pickett --- CHANGELOG.md | 1 + ...d-helpers.formatassistanterrortext.test.ts | 6 ++ src/agents/pi-embedded-helpers.ts | 2 + src/agents/pi-embedded-helpers/errors.ts | 74 +++++++++++++++ .../pi-embedded-runner/run/payloads.test.ts | 90 ++++++++++++++++++- src/agents/pi-embedded-runner/run/payloads.ts | 36 ++++++-- 6 files changed, 199 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a3b7cf57..8a74cd731 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixes - Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06. + ## 2026.1.14 ### Changes diff --git a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts b/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts index 2553724a4..f2edda1f6 100644 --- a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts +++ b/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts @@ -33,4 +33,10 @@ describe("formatAssistantErrorText", () => { "The AI service is temporarily overloaded. Please try again in a moment.", ); }); + it("suppresses raw error JSON payloads that are not otherwise classified", () => { + const msg = makeAssistantError( + '{"type":"error","error":{"message":"Something exploded","type":"server_error"}}', + ); + expect(formatAssistantErrorText(msg)).toBe("The AI service returned an error. Please try again."); + }); }); diff --git a/src/agents/pi-embedded-helpers.ts b/src/agents/pi-embedded-helpers.ts index 4dc87369a..88fe701a9 100644 --- a/src/agents/pi-embedded-helpers.ts +++ b/src/agents/pi-embedded-helpers.ts @@ -8,6 +8,7 @@ export { export { classifyFailoverReason, formatAssistantErrorText, + getApiErrorPayloadFingerprint, isAuthAssistantError, isAuthErrorMessage, isBillingAssistantError, @@ -18,6 +19,7 @@ export { isFailoverAssistantError, isFailoverErrorMessage, isOverloadedErrorMessage, + isRawApiErrorPayload, isRateLimitAssistantError, isRateLimitErrorMessage, isTimeoutErrorMessage, diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 248df875e..2b76592ee 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -30,6 +30,76 @@ export function isCompactionFailureError(errorMessage?: string): boolean { ); } +const ERROR_PAYLOAD_PREFIX_RE = + /^(?:error|api\s*error|apierror|openai\s*error|anthropic\s*error|gateway\s*error)[:\s-]+/i; + +type ErrorPayload = Record; + +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; +} + +function stableStringify(value: unknown): string { + if (!value || typeof value !== "object") { + return JSON.stringify(value) ?? "null"; + } + if (Array.isArray(value)) { + return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; + } + const record = value as Record; + const keys = Object.keys(record).sort(); + const entries = keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`); + return `{${entries.join(",")}}`; +} + +export function getApiErrorPayloadFingerprint(raw?: string): string | null { + if (!raw) return null; + const payload = parseApiErrorPayload(raw); + if (!payload) return null; + return stableStringify(payload); +} + +export function isRawApiErrorPayload(raw?: string): boolean { + return getApiErrorPayloadFingerprint(raw) !== null; +} + export function formatAssistantErrorText( msg: AssistantMessage, opts?: { cfg?: ClawdbotConfig; sessionKey?: string }, @@ -73,6 +143,10 @@ export function formatAssistantErrorText( return "The AI service is temporarily overloaded. Please try again in a moment."; } + if (isRawApiErrorPayload(raw)) { + return "The AI service returned an error. Please try again."; + } + return raw.length > 600 ? `${raw.slice(0, 600)}…` : raw; } diff --git a/src/agents/pi-embedded-runner/run/payloads.test.ts b/src/agents/pi-embedded-runner/run/payloads.test.ts index 4680303f4..5b9e406f9 100644 --- a/src/agents/pi-embedded-runner/run/payloads.test.ts +++ b/src/agents/pi-embedded-runner/run/payloads.test.ts @@ -3,15 +3,27 @@ import { describe, expect, it } from "vitest"; import { buildEmbeddedRunPayloads } from "./payloads.js"; describe("buildEmbeddedRunPayloads", () => { - it("suppresses raw API error JSON when the assistant errored", () => { - const errorJson = + const errorJson = '{"type":"error","error":{"details":null,"type":"overloaded_error","message":"Overloaded"},"request_id":"req_011CX7DwS7tSvggaNHmefwWg"}'; - const lastAssistant = { + const errorJsonPretty = `{ + "type": "error", + "error": { + "details": null, + "type": "overloaded_error", + "message": "Overloaded" + }, + "request_id": "req_011CX7DwS7tSvggaNHmefwWg" +}`; + const makeAssistant = (overrides: Partial): AssistantMessage => + ({ stopReason: "error", errorMessage: errorJson, content: [{ type: "text", text: errorJson }], - } as AssistantMessage; + ...overrides, + }) as AssistantMessage; + it("suppresses raw API error JSON when the assistant errored", () => { + const lastAssistant = makeAssistant({}); const payloads = buildEmbeddedRunPayloads({ assistantTexts: [errorJson], toolMetas: [], @@ -29,4 +41,74 @@ describe("buildEmbeddedRunPayloads", () => { expect(payloads[0]?.isError).toBe(true); expect(payloads.some((payload) => payload.text === errorJson)).toBe(false); }); + + it("suppresses pretty-printed error JSON that differs from the errorMessage", () => { + const lastAssistant = makeAssistant({ errorMessage: errorJson }); + const payloads = buildEmbeddedRunPayloads({ + assistantTexts: [errorJsonPretty], + toolMetas: [], + lastAssistant, + sessionKey: "session:telegram", + inlineToolResultsAllowed: true, + verboseLevel: "on", + reasoningLevel: "off", + }); + + expect(payloads).toHaveLength(1); + expect(payloads[0]?.text).toBe( + "The AI service is temporarily overloaded. Please try again in a moment.", + ); + expect(payloads.some((payload) => payload.text === errorJsonPretty)).toBe(false); + }); + + it("suppresses raw error JSON from fallback assistant text", () => { + const lastAssistant = makeAssistant({ content: [{ type: "text", text: errorJsonPretty }] }); + const payloads = buildEmbeddedRunPayloads({ + assistantTexts: [], + toolMetas: [], + lastAssistant, + sessionKey: "session:telegram", + inlineToolResultsAllowed: false, + verboseLevel: "off", + reasoningLevel: "off", + }); + + expect(payloads).toHaveLength(1); + expect(payloads[0]?.text).toBe( + "The AI service is temporarily overloaded. Please try again in a moment.", + ); + expect(payloads.some((payload) => payload.text?.includes("request_id"))).toBe(false); + }); + + it("suppresses raw error JSON even when errorMessage is missing", () => { + const lastAssistant = makeAssistant({ errorMessage: undefined }); + const payloads = buildEmbeddedRunPayloads({ + assistantTexts: [errorJsonPretty], + toolMetas: [], + lastAssistant, + sessionKey: "session:telegram", + inlineToolResultsAllowed: false, + verboseLevel: "off", + reasoningLevel: "off", + }); + + expect(payloads).toHaveLength(1); + expect(payloads[0]?.isError).toBe(true); + expect(payloads.some((payload) => payload.text?.includes("request_id"))).toBe(false); + }); + + it("does not suppress error-shaped JSON when the assistant did not error", () => { + const payloads = buildEmbeddedRunPayloads({ + assistantTexts: [errorJsonPretty], + toolMetas: [], + lastAssistant: { stopReason: "end_turn" } as AssistantMessage, + sessionKey: "session:telegram", + inlineToolResultsAllowed: false, + verboseLevel: "off", + reasoningLevel: "off", + }); + + expect(payloads).toHaveLength(1); + expect(payloads[0]?.text).toBe(errorJsonPretty.trim()); + }); }); diff --git a/src/agents/pi-embedded-runner/run/payloads.ts b/src/agents/pi-embedded-runner/run/payloads.ts index 9eea8ec01..9b0b8e9d2 100644 --- a/src/agents/pi-embedded-runner/run/payloads.ts +++ b/src/agents/pi-embedded-runner/run/payloads.ts @@ -4,7 +4,12 @@ import type { ReasoningLevel, VerboseLevel } from "../../../auto-reply/thinking. import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../../auto-reply/tokens.js"; import { formatToolAggregate } from "../../../auto-reply/tool-meta.js"; import type { ClawdbotConfig } from "../../../config/config.js"; -import { formatAssistantErrorText } from "../../pi-embedded-helpers.js"; +import { + formatAssistantErrorText, + getApiErrorPayloadFingerprint, + isRawApiErrorPayload, + normalizeTextForComparison, +} from "../../pi-embedded-helpers.js"; import { extractAssistantText, extractAssistantThinking, @@ -42,16 +47,20 @@ export function buildEmbeddedRunPayloads(params: { replyToCurrent?: boolean; }> = []; + const lastAssistantErrored = params.lastAssistant?.stopReason === "error"; const errorText = params.lastAssistant ? formatAssistantErrorText(params.lastAssistant, { cfg: params.config, sessionKey: params.sessionKey, }) : undefined; - const rawErrorMessage = - params.lastAssistant?.stopReason === "error" - ? params.lastAssistant.errorMessage?.trim() || undefined - : undefined; + const rawErrorMessage = lastAssistantErrored + ? params.lastAssistant?.errorMessage?.trim() || undefined + : undefined; + const rawErrorFingerprint = rawErrorMessage + ? getApiErrorPayloadFingerprint(rawErrorMessage) + : null; + const normalizedRawErrorText = rawErrorMessage ? normalizeTextForComparison(rawErrorMessage) : null; if (errorText) replyItems.push({ text: errorText, isError: true }); const inlineToolResults = @@ -87,13 +96,28 @@ export function buildEmbeddedRunPayloads(params: { if (reasoningText) replyItems.push({ text: reasoningText }); const fallbackAnswerText = params.lastAssistant ? extractAssistantText(params.lastAssistant) : ""; + const shouldSuppressRawErrorText = (text: string) => { + if (!lastAssistantErrored) return false; + const trimmed = text.trim(); + if (!trimmed) return false; + if (rawErrorMessage && trimmed === rawErrorMessage) return true; + if (normalizedRawErrorText) { + const normalized = normalizeTextForComparison(trimmed); + if (normalized && normalized === normalizedRawErrorText) return true; + } + if (rawErrorFingerprint) { + const fingerprint = getApiErrorPayloadFingerprint(trimmed); + if (fingerprint && fingerprint === rawErrorFingerprint) return true; + } + return isRawApiErrorPayload(trimmed); + }; const answerTexts = ( params.assistantTexts.length ? params.assistantTexts : fallbackAnswerText ? [fallbackAnswerText] : [] - ).filter((text) => (rawErrorMessage ? text.trim() !== rawErrorMessage : true)); + ).filter((text) => !shouldSuppressRawErrorText(text)); for (const text of answerTexts) { const {