fix: sanitize user-facing errors and strip final tags

Co-authored-by: Drake Thomsen <drake.thomsen@example.com>
This commit is contained in:
Peter Steinberger
2026-01-16 03:00:40 +00:00
parent d9f9e93dee
commit 23e4ba845c
13 changed files with 239 additions and 31 deletions

View File

@@ -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<string, unknown>;
@@ -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 ?? "");