fix: sanitize user-facing errors and strip final tags
Co-authored-by: Drake Thomsen <drake.thomsen@example.com>
This commit is contained in:
@@ -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"}}',
|
||||
|
||||
@@ -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("<final>Hello</final>")).toBe("Hello");
|
||||
expect(sanitizeUserFacingText("Hi <final>there</final>!")).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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ export {
|
||||
isAuthErrorMessage,
|
||||
isBillingAssistantError,
|
||||
parseApiErrorInfo,
|
||||
sanitizeUserFacingText,
|
||||
isBillingErrorMessage,
|
||||
isCloudCodeAssistFormatError,
|
||||
isCompactionFailureError,
|
||||
|
||||
@@ -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 ?? "");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user