Return user-facing message if API reuturn 429 API rate limit reached #2202 (#10415)

* Return user-facing message if API reuturn 429 API rate limit reached

* clarify the error message

* fix(agents): improve 429 user messaging (#10415) (thanks @vincenthsin)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Vincent
2026-02-15 00:40:02 +08:00
committed by GitHub
parent ff32f43459
commit 478af81706
10 changed files with 115 additions and 14 deletions

View File

@@ -104,4 +104,8 @@ describe("formatAssistantErrorText", () => {
expect(result).toContain("API provider");
expect(result).toBe(BILLING_ERROR_USER_MESSAGE);
});
it("returns a friendly message for rate limit errors", () => {
const msg = makeAssistantError("429 rate limit reached");
expect(formatAssistantErrorText(msg)).toContain("rate limit reached");
});
});

View File

@@ -60,6 +60,12 @@ describe("sanitizeUserFacingText", () => {
);
});
it("returns a friendly message for rate limit errors in Error: prefixed payloads", () => {
expect(sanitizeUserFacingText("Error: 429 Rate limit exceeded", { errorContext: true })).toBe(
"⚠️ API rate limit reached. Please try again later.",
);
});
it("collapses consecutive duplicate paragraphs", () => {
const text = "Hello there!\n\nHello there!";
expect(sanitizeUserFacingText(text)).toBe("Hello there!");

View File

@@ -13,6 +13,8 @@ export function formatBillingErrorMessage(provider?: string): string {
export const BILLING_ERROR_USER_MESSAGE = formatBillingErrorMessage();
const RATE_LIMIT_ERROR_USER_MESSAGE = "⚠️ API rate limit reached. Please try again later.";
export function isContextOverflowError(errorMessage?: string): boolean {
if (!errorMessage) {
return false;
@@ -461,6 +463,10 @@ export function formatAssistantErrorText(
return `LLM request rejected: ${invalidRequest[1]}`;
}
if (isRateLimitErrorMessage(raw)) {
return RATE_LIMIT_ERROR_USER_MESSAGE;
}
if (isOverloadedErrorMessage(raw)) {
return "The AI service is temporarily overloaded. Please try again in a moment.";
}
@@ -517,7 +523,10 @@ export function sanitizeUserFacingText(text: string, opts?: { errorContext?: boo
}
if (ERROR_PREFIX_RE.test(trimmed)) {
if (isOverloadedErrorMessage(trimmed) || isRateLimitErrorMessage(trimmed)) {
if (isRateLimitErrorMessage(trimmed)) {
return RATE_LIMIT_ERROR_USER_MESSAGE;
}
if (isOverloadedErrorMessage(trimmed)) {
return "The AI service is temporarily overloaded. Please try again in a moment.";
}
if (isTimeoutErrorMessage(trimmed)) {

View File

@@ -733,6 +733,8 @@ export async function runEmbeddedAttempt(
onAssistantMessageStart: params.onAssistantMessageStart,
onAgentEvent: params.onAgentEvent,
enforceFinalTag: params.enforceFinalTag,
config: params.config,
sessionKey: params.sessionKey ?? params.sessionId,
});
const {

View File

@@ -3,6 +3,8 @@ import type { EmbeddedPiSubscribeContext } from "./pi-embedded-subscribe.handler
import { emitAgentEvent } from "../infra/agent-events.js";
import { createInlineCodeState } from "../markdown/code-spans.js";
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
import { formatAssistantErrorText } from "./pi-embedded-helpers.js";
import { isAssistantMessage } from "./pi-embedded-utils.js";
export function handleAgentStart(ctx: EmbeddedPiSubscribeContext) {
ctx.log.debug(`embedded run agent start: runId=${ctx.params.runId}`);
@@ -94,19 +96,46 @@ export function handleAutoCompactionEnd(
}
export function handleAgentEnd(ctx: EmbeddedPiSubscribeContext) {
ctx.log.debug(`embedded run agent end: runId=${ctx.params.runId}`);
emitAgentEvent({
runId: ctx.params.runId,
stream: "lifecycle",
data: {
phase: "end",
endedAt: Date.now(),
},
});
void ctx.params.onAgentEvent?.({
stream: "lifecycle",
data: { phase: "end" },
});
const lastAssistant = ctx.state.lastAssistant;
const isError = isAssistantMessage(lastAssistant) && lastAssistant.stopReason === "error";
ctx.log.debug(`embedded run agent end: runId=${ctx.params.runId} isError=${isError}`);
if (isError && lastAssistant) {
const friendlyError = formatAssistantErrorText(lastAssistant, {
cfg: ctx.params.config,
sessionKey: ctx.params.sessionKey,
});
emitAgentEvent({
runId: ctx.params.runId,
stream: "lifecycle",
data: {
phase: "error",
error: friendlyError || lastAssistant.errorMessage || "LLM request failed.",
endedAt: Date.now(),
},
});
void ctx.params.onAgentEvent?.({
stream: "lifecycle",
data: {
phase: "error",
error: friendlyError || lastAssistant.errorMessage || "LLM request failed.",
},
});
} else {
emitAgentEvent({
runId: ctx.params.runId,
stream: "lifecycle",
data: {
phase: "end",
endedAt: Date.now(),
},
});
void ctx.params.onAgentEvent?.({
stream: "lifecycle",
data: { phase: "end" },
});
}
if (ctx.params.onBlockReply) {
if (ctx.blockChunker?.hasBuffered()) {

View File

@@ -57,6 +57,8 @@ export function handleMessageUpdate(
return;
}
ctx.state.lastAssistant = msg;
const assistantEvent = evt.assistantMessageEvent;
const assistantRecord =
assistantEvent && typeof assistantEvent === "object"
@@ -198,6 +200,7 @@ export function handleMessageEnd(
}
const assistantMessage = msg;
ctx.state.lastAssistant = assistantMessage;
ctx.recordAssistantUsage((assistantMessage as { usage?: unknown }).usage);
promoteThinkingTagsToBlocks(assistantMessage);

View File

@@ -62,6 +62,7 @@ export type EmbeddedPiSubscribeState = {
messagingToolSentTargets: MessagingToolSend[];
pendingMessagingTexts: Map<string, string>;
pendingMessagingTargets: Map<string, MessagingToolSend>;
lastAssistant?: AgentMessage;
};
export type EmbeddedPiSubscribeContext = {

View File

@@ -317,4 +317,43 @@ describe("subscribeEmbeddedPiSession", () => {
expect(payloads[0]?.text).toBe("");
expect(payloads[0]?.mediaUrls).toEqual(["https://example.com/a.png"]);
});
it("emits lifecycle:error event on agent_end when last assistant message was an error", async () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onAgentEvent = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
runId: "run-error",
onAgentEvent,
sessionKey: "test-session",
});
const assistantMessage = {
role: "assistant",
stopReason: "error",
errorMessage: "429 Rate limit exceeded",
} as AssistantMessage;
// Simulate message update to set lastAssistant
handler?.({ type: "message_update", message: assistantMessage });
// Trigger agent_end
handler?.({ type: "agent_end" });
// Look for lifecycle:error event
const lifecycleError = onAgentEvent.mock.calls.find(
(call) => call[0]?.stream === "lifecycle" && call[0]?.data?.phase === "error",
);
expect(lifecycleError).toBeDefined();
expect(lifecycleError[0].data.error).toContain("API rate limit reached");
});
});

View File

@@ -1,5 +1,6 @@
import type { AgentSession } from "@mariozechner/pi-coding-agent";
import type { ReasoningLevel, VerboseLevel } from "../auto-reply/thinking.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { HookRunner } from "../plugins/hooks.js";
import type { BlockReplyChunking } from "./pi-embedded-block-chunker.js";
@@ -32,6 +33,8 @@ export type SubscribeEmbeddedPiSessionParams = {
onAssistantMessageStart?: () => void | Promise<void>;
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void | Promise<void>;
enforceFinalTag?: boolean;
config?: OpenClawConfig;
sessionKey?: string;
};
export type { BlockReplyChunking } from "./pi-embedded-block-chunker.js";

View File

@@ -1,8 +1,13 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage } from "@mariozechner/pi-ai";
import { stripReasoningTagsFromText } from "../shared/text/reasoning-tags.js";
import { sanitizeUserFacingText } from "./pi-embedded-helpers.js";
import { formatToolDetail, resolveToolDisplay } from "./tool-display.js";
export function isAssistantMessage(msg: AgentMessage | undefined): msg is AssistantMessage {
return msg?.role === "assistant";
}
/**
* Strip malformed Minimax tool invocations that leak into text content.
* Minimax sometimes embeds tool calls as XML in text blocks instead of