diff --git a/CHANGELOG.md b/CHANGELOG.md index 06362fc1a..698220d11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi. - Telegram: skip `message_thread_id=1` for General topic sends while keeping typing indicators. (#848) — thanks @azade-c. - Discord: allow allowlisted guilds without channel lists to receive messages when `groupPolicy="allowlist"`. — thanks @thewilloftheshadow. +- Fix: sanitize user-facing error text + strip `` tags across reply pipelines. (#975) — thanks @ThomsenDrake. ## 2026.1.14-1 diff --git a/README.md b/README.md index 410efca01..4a37dc1f4 100644 --- a/README.md +++ b/README.md @@ -475,22 +475,22 @@ Thanks to all clawtributors:

steipete bohdanpodvirnyi joaohlisboa mneves75 rahthakor vrknetha joshp123 mukhtharcm maxsumrall xadenryan - Tobias Bischoff hsrvc magimetal meaningfool NicholasSpisak abhisekbasu1 claude jamesgroat Hyaxia dantelex - daveonkels radek-paclt mteam88 Eng. Juan Combetto dbhurley Mariano Belinky julianengel benithors sreekaransrinath gupsammy - cristip73 nachoiacovino Vasanth Rao Naik Sabavat lc0rp scald andranik-sahakyan nachx639 davidguttman sleontenko sircrumpet - peschee rafaelreis-r ratulsarna lutr0 thewilloftheshadow emanuelst KristijanJovanovski CashWilliams rdev osolmaz - kiranjd sebslight sheeek onutc manuelhettich minghinmatthewlam myfunc buddyh mcinteerj timkrase - obviyus azade-c bjesuiter danielz1z Josh Phillips roshanasingh4 superman32432432 Yurii Chukhlib antons austinm911 - blacksmith-sh[bot] grp06 HeimdallStrategy imfing jarvis-medmatic mahmoudashraf93 petter-b pkrmf RandyVentures dan-dr - erikpr1994 jalehman jonasjancarik Keith the Silly Goose L36 Server mitschabaude-bot neist chrisrodz Friederike Seiler gabriel-trigo - iamadig Kit koala73 manmal ngutman ogulcancelik pasogott petradonka VACInc zats - Chris Taylor Django Navarro pcty-nextgen-service-account rubyrunsstuff Syhids Aaron Konyer erik-agens evalexpr fcatuhe gumadeiras - henrino3 jayhickey jeffersonwarrior jeffersonwarrior Jonathan D. Rhyne (DJ-D) juanpablodlc jverdi mickahouan mjrussell oswalpalash - p6l-richard philipp-spiess robaxelsen Sash Catanzarite VAC zknicker adam91holt alejandro maza andrewting19 Asleep123 - bolismauro cash-echo-bot Clawd conhecendocontato Drake Thomsen gtsifrikas HazAT hrdwdmrbl hugobarauna Jarvis - Jefferson Nunn kitze kkarimi levifig Lloyd loukotal Marc martinpucik Miles mrdbstn - MSch Mustafa Tag Eldeen ndraiman nexty5870 prathamdby reeltimeapps RLTCmpe Rolf Fredheim Rony Kelner Samrat Jha - siraht snopoke The Admiral wes-davis wstock YuriNachos Zach Knickerbocker Azade carlulsoe cpojer + Tobias Bischoff juanpablodlc hsrvc magimetal meaningfool NicholasSpisak abhisekbasu1 claude jamesgroat Hyaxia + dantelex daveonkels radek-paclt mteam88 Eng. Juan Combetto dbhurley Mariano Belinky julianengel benithors nachx639 + sreekaransrinath gupsammy cristip73 nachoiacovino Vasanth Rao Naik Sabavat lc0rp scald andranik-sahakyan davidguttman sleontenko + sircrumpet peschee rafaelreis-r ratulsarna lutr0 thewilloftheshadow gumadeiras emanuelst KristijanJovanovski CashWilliams + rdev osolmaz kiranjd sebslight sheeek onutc manuelhettich minghinmatthewlam myfunc buddyh + mcinteerj timkrase obviyus azade-c bjesuiter danielz1z Josh Phillips roshanasingh4 superman32432432 Yurii Chukhlib + antons austinm911 blacksmith-sh[bot] grp06 HeimdallStrategy imfing jalehman jarvis-medmatic mahmoudashraf93 petter-b + pkrmf RandyVentures dan-dr erikpr1994 jonasjancarik Keith the Silly Goose kkarimi L36 Server mitschabaude-bot neist + chrisrodz Friederike Seiler gabriel-trigo iamadig Kit koala73 manmal ngutman ogulcancelik pasogott + petradonka VACInc zats Chris Taylor Django Navarro pcty-nextgen-service-account rubyrunsstuff Syhids Aaron Konyer erik-agens + evalexpr fcatuhe henrino3 jayhickey jeffersonwarrior jeffersonwarrior Jonathan D. Rhyne (DJ-D) jverdi mickahouan mjrussell + oswalpalash p6l-richard philipp-spiess robaxelsen Sash Catanzarite VAC zknicker alejandro maza andrewting19 Asleep123 + bolismauro cash-echo-bot Clawd conhecendocontato ThomsenDrake gtsifrikas HazAT hrdwdmrbl hugobarauna Jarvis + Jefferson Nunn kitze levifig Lloyd loukotal Marc martinpucik Miles mrdbstn MSch + Mustafa Tag Eldeen ndraiman nexty5870 prathamdby reeltimeapps RLTCmpe Rolf Fredheim Rony Kelner Samrat Jha siraht + snopoke The Admiral voidserf wes-davis wstock YuriNachos Zach Knickerbocker Azade carlulsoe cpojer ddyo Erik latitudeki5223 longmaba Manuel Maly Mourad Boustani pcty-nextgen-ios-builder Quentin Randy Torres ronak-guliani - thesash William Stock tyler6204 + thesash William Stock

diff --git a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts b/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts index 5c874aff8..e0a4c897c 100644 --- a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts +++ b/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts @@ -33,6 +33,12 @@ describe("formatAssistantErrorText", () => { "The AI service is temporarily overloaded. Please try again in a moment.", ); }); + it("handles JSON-wrapped role errors", () => { + const msg = makeAssistantError('{"error":{"message":"400 Incorrect role information"}}'); + const result = formatAssistantErrorText(msg); + expect(result).toContain("Message ordering conflict"); + expect(result).not.toContain("400"); + }); it("suppresses raw error JSON payloads that are not otherwise classified", () => { const msg = makeAssistantError( '{"type":"error","error":{"message":"Something exploded","type":"server_error"}}', diff --git a/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts b/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts new file mode 100644 index 000000000..553e62efc --- /dev/null +++ b/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { sanitizeUserFacingText } from "./pi-embedded-helpers.js"; + +describe("sanitizeUserFacingText", () => { + it("strips final tags", () => { + expect(sanitizeUserFacingText("Hello")).toBe("Hello"); + expect(sanitizeUserFacingText("Hi there!")).toBe("Hi there!"); + }); + + it("does not clobber normal numeric prefixes", () => { + expect(sanitizeUserFacingText("202 results found")).toBe("202 results found"); + expect(sanitizeUserFacingText("400 days left")).toBe("400 days left"); + }); + + it("sanitizes role ordering errors", () => { + const result = sanitizeUserFacingText("400 Incorrect role information"); + expect(result).toContain("Message ordering conflict"); + }); + + it("sanitizes HTTP status errors with error hints", () => { + expect(sanitizeUserFacingText("500 Internal Server Error")).toBe( + "The AI service returned an error. Please try again.", + ); + }); + + it("sanitizes raw API error payloads", () => { + const raw = '{"type":"error","error":{"message":"Something exploded","type":"server_error"}}'; + expect(sanitizeUserFacingText(raw)).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 f92e02992..48beb95a2 100644 --- a/src/agents/pi-embedded-helpers.ts +++ b/src/agents/pi-embedded-helpers.ts @@ -14,6 +14,7 @@ export { isAuthErrorMessage, isBillingAssistantError, parseApiErrorInfo, + sanitizeUserFacingText, isBillingErrorMessage, isCloudCodeAssistFormatError, isCompactionFailureError, diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 6eb6383cf..307d0c9f8 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -32,6 +32,41 @@ 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; +const FINAL_TAG_RE = /<\s*\/?\s*final\s*>/gi; +const ERROR_PREFIX_RE = + /^(?:error|api\s*error|openai\s*error|anthropic\s*error|gateway\s*error|request failed|failed|exception)[:\s-]+/i; +const HTTP_STATUS_PREFIX_RE = /^(?:http\s*)?(\d{3})\s+(.+)$/i; +const HTTP_ERROR_HINTS = [ + "error", + "bad request", + "not found", + "unauthorized", + "forbidden", + "internal server", + "service unavailable", + "gateway", + "rate limit", + "overloaded", + "timeout", + "timed out", + "invalid", + "too many requests", + "permission", +]; + +function stripFinalTagsFromText(text: string): string { + if (!text) return text; + return text.replace(FINAL_TAG_RE, ""); +} + +function isLikelyHttpErrorText(raw: string): boolean { + const match = raw.match(HTTP_STATUS_PREFIX_RE); + if (!match) return false; + const code = Number(match[1]); + if (!Number.isFinite(code) || code < 400) return false; + const message = match[2].toLowerCase(); + return HTTP_ERROR_HINTS.some((hint) => message.includes(hint)); +} type ErrorPayload = Record; @@ -170,8 +205,9 @@ export function formatAssistantErrorText( msg: AssistantMessage, opts?: { cfg?: ClawdbotConfig; sessionKey?: string }, ): string | undefined { - if (msg.stopReason !== "error") return undefined; + // Also format errors if errorMessage is present, even if stopReason isn't "error" const raw = (msg.errorMessage ?? "").trim(); + if (msg.stopReason !== "error" && !raw) return undefined; if (!raw) return "LLM request failed with an unknown error."; const unknownTool = @@ -193,7 +229,8 @@ export function formatAssistantErrorText( ); } - if (/incorrect role information|roles must alternate/i.test(raw)) { + // Catch role ordering errors - including JSON-wrapped and "400" prefix variants + if (/incorrect role information|roles must alternate|400.*role|"message".*role.*information/i.test(raw)) { return ( "Message ordering conflict - please try again. " + "If this persists, use /new to start a fresh session." @@ -213,9 +250,50 @@ export function formatAssistantErrorText( return "The AI service returned an error. Please try again."; } + // Never return raw unhandled errors - log for debugging but return safe message + if (raw.length > 600) { + console.warn("[formatAssistantErrorText] Long error truncated:", raw.slice(0, 200)); + } return raw.length > 600 ? `${raw.slice(0, 600)}…` : raw; } +export function sanitizeUserFacingText(text: string): string { + if (!text) return text; + const stripped = stripFinalTagsFromText(text); + const trimmed = stripped.trim(); + if (!trimmed) return stripped; + + if (/incorrect role information|roles must alternate/i.test(trimmed)) { + return ( + "Message ordering conflict - please try again. " + + "If this persists, use /new to start a fresh session." + ); + } + + if (isContextOverflowError(trimmed)) { + return ( + "Context overflow: prompt too large for the model. " + + "Try again with less input or a larger-context model." + ); + } + + if (isRawApiErrorPayload(trimmed) || isLikelyHttpErrorText(trimmed)) { + return "The AI service returned an error. Please try again."; + } + + if (ERROR_PREFIX_RE.test(trimmed)) { + if (isOverloadedErrorMessage(trimmed) || isRateLimitErrorMessage(trimmed)) { + return "The AI service is temporarily overloaded. Please try again in a moment."; + } + if (isTimeoutErrorMessage(trimmed)) { + return "LLM request timed out."; + } + return "The AI service returned an error. Please try again."; + } + + return stripped; +} + export function isRateLimitAssistantError(msg: AssistantMessage | undefined): boolean { if (!msg || msg.stopReason !== "error") return false; return isRateLimitErrorMessage(msg.errorMessage ?? ""); diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 8918a790c..f05703c95 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -256,6 +256,27 @@ export async function runEmbeddedPiAgent( }, }; } + // Handle role ordering errors with a user-friendly message + if (/incorrect role information|roles must alternate/i.test(errorText)) { + return { + payloads: [ + { + text: + "Message ordering conflict - please try again. " + + "If this persists, use /new to start a fresh session.", + isError: true, + }, + ], + meta: { + durationMs: Date.now() - started, + agentMeta: { + sessionId: sessionIdUsed, + provider, + model: model.id, + }, + }, + }; + } const promptFailoverReason = classifyFailoverReason(errorText); if (promptFailoverReason && promptFailoverReason !== "timeout" && lastProfileId) { await markAuthProfileFailure({ @@ -339,14 +360,15 @@ export async function runEmbeddedPiAgent( if (rotated) continue; if (fallbackConfigured) { + // Prefer formatted error message (user-friendly) over raw errorMessage const message = - lastAssistant?.errorMessage?.trim() || (lastAssistant ? formatAssistantErrorText(lastAssistant, { cfg: params.config, sessionKey: params.sessionKey ?? params.sessionId, }) - : "") || + : undefined) || + lastAssistant?.errorMessage?.trim() || (timedOut ? "LLM request timed out." : rateLimitFailure diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index f0205507b..4ba7a5016 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -419,14 +419,33 @@ export async function runEmbeddedAttempt( try { const promptStartedAt = Date.now(); log.debug(`embedded run prompt start: runId=${params.runId} sessionId=${params.sessionId}`); - try { - await activeSession.prompt(params.prompt, { images: params.images }); - } catch (err) { - promptError = err; - } finally { - log.debug( - `embedded run prompt end: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - promptStartedAt}`, + + // Check if last message is a user message to prevent consecutive user turns + const lastMsg = activeSession.messages[activeSession.messages.length - 1]; + const lastMsgRole = lastMsg && typeof lastMsg === "object" ? (lastMsg as { role?: unknown }).role : undefined; + + if (lastMsgRole === "user") { + // Last message was a user message. Adding another user message would create + // consecutive user turns, violating Anthropic's role ordering requirement. + // This can happen when: + // 1. A previous heartbeat didn't get a response + // 2. A user message errored before getting an assistant response + // Skip this prompt to prevent "400 Incorrect role information" error. + log.warn( + `Skipping prompt because last message is a user message (would create consecutive user turns). ` + + `runId=${params.runId} sessionId=${params.sessionId}` ); + promptError = new Error("Incorrect role information: consecutive user messages would violate role ordering"); + } else { + try { + await activeSession.prompt(params.prompt, { images: params.images }); + } catch (err) { + promptError = err; + } finally { + log.debug( + `embedded run prompt end: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - promptStartedAt}`, + ); + } } try { diff --git a/src/agents/pi-embedded-runner/run/payloads.ts b/src/agents/pi-embedded-runner/run/payloads.ts index 1ca91f594..082958df1 100644 --- a/src/agents/pi-embedded-runner/run/payloads.ts +++ b/src/agents/pi-embedded-runner/run/payloads.ts @@ -63,6 +63,8 @@ export function buildEmbeddedRunPayloads(params: { const normalizedRawErrorText = rawErrorMessage ? normalizeTextForComparison(rawErrorMessage) : null; + const normalizedErrorText = errorText ? normalizeTextForComparison(errorText) : null; + const genericErrorText = "The AI service returned an error. Please try again."; if (errorText) replyItems.push({ text: errorText, isError: true }); const inlineToolResults = @@ -102,6 +104,11 @@ export function buildEmbeddedRunPayloads(params: { if (!lastAssistantErrored) return false; const trimmed = text.trim(); if (!trimmed) return false; + if (errorText) { + const normalized = normalizeTextForComparison(trimmed); + if (normalized && normalizedErrorText && normalized === normalizedErrorText) return true; + if (trimmed === genericErrorText) return true; + } if (rawErrorMessage && trimmed === rawErrorMessage) return true; if (normalizedRawErrorText) { const normalized = normalizeTextForComparison(trimmed); diff --git a/src/agents/pi-embedded-utils.ts b/src/agents/pi-embedded-utils.ts index f63d48c8a..82020a149 100644 --- a/src/agents/pi-embedded-utils.ts +++ b/src/agents/pi-embedded-utils.ts @@ -1,4 +1,5 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; +import { sanitizeUserFacingText } from "./pi-embedded-helpers.js"; import { formatToolDetail, resolveToolDisplay } from "./tool-display.js"; /** @@ -214,7 +215,8 @@ export function extractAssistantText(msg: AssistantMessage): string { ) .filter(Boolean) : []; - return blocks.join("\n").trim(); + const extracted = blocks.join("\n").trim(); + return sanitizeUserFacingText(extracted); } export function extractAssistantThinking(msg: AssistantMessage): string { diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index dc46ee7bc..9c9f9c0a0 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -9,6 +9,7 @@ import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js"; import { isCompactionFailureError, isContextOverflowError, + sanitizeUserFacingText, } from "../../agents/pi-embedded-helpers.js"; import { resolveAgentIdFromSessionKey, @@ -106,7 +107,10 @@ export async function runAgentTurnWithFallback(params: { if (isSilentReplyText(text, SILENT_REPLY_TOKEN)) { return { skip: true }; } - return { text, skip: false }; + if (!text) return { skip: true }; + const sanitized = sanitizeUserFacingText(text); + if (!sanitized.trim()) return { skip: true }; + return { text: sanitized, skip: false }; }; const handlePartialForTyping = async (payload: ReplyPayload): Promise => { const { text, skip } = normalizeStreamingText(payload); @@ -366,6 +370,7 @@ export async function runAgentTurnWithFallback(params: { /context.*overflow|too large|context window/i.test(message); const isCompactionFailure = isCompactionFailureError(message); const isSessionCorruption = /function call turn comes immediately after/i.test(message); + const isRoleOrderingError = /incorrect role information|roles must alternate/i.test(message); if ( isCompactionFailure && @@ -427,7 +432,9 @@ export async function runAgentTurnWithFallback(params: { payload: { text: isContextOverflow ? "⚠️ Context overflow — prompt too large for this model. Try a shorter message or a larger-context model." - : `⚠️ Agent failed before reply: ${message}. Check gateway logs for details.`, + : isRoleOrderingError + ? "⚠️ Message ordering conflict - please try again. If this persists, use /new to start a fresh session." + : `⚠️ Agent failed before reply: ${message}. Check gateway logs for details.`, }, }; } diff --git a/src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.resets-corrupted-gemini-sessions-deletes-transcripts.test.ts b/src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.resets-corrupted-gemini-sessions-deletes-transcripts.test.ts index cf9c6a823..1ffca4d95 100644 --- a/src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.resets-corrupted-gemini-sessions-deletes-transcripts.test.ts +++ b/src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.resets-corrupted-gemini-sessions-deletes-transcripts.test.ts @@ -213,4 +213,31 @@ describe("runReplyAgent typing (heartbeat)", () => { } } }); + it("returns friendly message for role ordering errors thrown as exceptions", async () => { + runEmbeddedPiAgentMock.mockImplementationOnce(async () => { + throw new Error("400 Incorrect role information"); + }); + + const { run } = createMinimalRun({}); + const res = await run(); + + expect(res).toMatchObject({ + text: expect.stringContaining("Message ordering conflict"), + }); + expect(res).toMatchObject({ + text: expect.not.stringContaining("400"), + }); + }); + it("returns friendly message for 'roles must alternate' errors thrown as exceptions", async () => { + runEmbeddedPiAgentMock.mockImplementationOnce(async () => { + throw new Error('messages: roles must alternate between "user" and "assistant"'); + }); + + const { run } = createMinimalRun({}); + const res = await run(); + + expect(res).toMatchObject({ + text: expect.stringContaining("Message ordering conflict"), + }); + }); }); diff --git a/src/auto-reply/reply/normalize-reply.ts b/src/auto-reply/reply/normalize-reply.ts index ac8e85f2e..f3060476b 100644 --- a/src/auto-reply/reply/normalize-reply.ts +++ b/src/auto-reply/reply/normalize-reply.ts @@ -1,6 +1,7 @@ import { stripHeartbeatToken } from "../heartbeat.js"; import { HEARTBEAT_TOKEN, isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js"; import type { ReplyPayload } from "../types.js"; +import { sanitizeUserFacingText } from "../../agents/pi-embedded-helpers.js"; import { resolveResponsePrefixTemplate, type ResponsePrefixContext, @@ -42,6 +43,11 @@ export function normalizeReplyPayload( text = stripped.text; } + if (text) { + text = sanitizeUserFacingText(text); + } + if (!text?.trim() && !hasMedia) return null; + // Resolve template variables in responsePrefix if context is provided const effectivePrefix = opts.responsePrefixContext ? resolveResponsePrefixTemplate(opts.responsePrefix, opts.responsePrefixContext)