From 478af8170689a350154bed7837659d0557c8fcbe Mon Sep 17 00:00:00 2001 From: Vincent Date: Sun, 15 Feb 2026 00:40:02 +0800 Subject: [PATCH] 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 --- ...lpers.formatassistanterrortext.e2e.test.ts | 4 ++ ...helpers.sanitizeuserfacingtext.e2e.test.ts | 6 ++ src/agents/pi-embedded-helpers/errors.ts | 11 +++- src/agents/pi-embedded-runner/run/attempt.ts | 2 + ...i-embedded-subscribe.handlers.lifecycle.ts | 55 ++++++++++++++----- ...pi-embedded-subscribe.handlers.messages.ts | 3 + .../pi-embedded-subscribe.handlers.types.ts | 1 + ...ion.subscribeembeddedpisession.e2e.test.ts | 39 +++++++++++++ src/agents/pi-embedded-subscribe.types.ts | 3 + src/agents/pi-embedded-utils.ts | 5 ++ 10 files changed, 115 insertions(+), 14 deletions(-) diff --git a/src/agents/pi-embedded-helpers.formatassistanterrortext.e2e.test.ts b/src/agents/pi-embedded-helpers.formatassistanterrortext.e2e.test.ts index 7d4f3538c8..9d0179e42e 100644 --- a/src/agents/pi-embedded-helpers.formatassistanterrortext.e2e.test.ts +++ b/src/agents/pi-embedded-helpers.formatassistanterrortext.e2e.test.ts @@ -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"); + }); }); diff --git a/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.e2e.test.ts b/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.e2e.test.ts index e666556ee1..5f222b4552 100644 --- a/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.e2e.test.ts +++ b/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.e2e.test.ts @@ -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!"); diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 1d3c984631..2979cb67a5 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -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)) { diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 7b91249a4b..98c67404eb 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -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 { diff --git a/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts b/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts index 0c8dce9cdd..ffa1eeee98 100644 --- a/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts +++ b/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts @@ -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()) { diff --git a/src/agents/pi-embedded-subscribe.handlers.messages.ts b/src/agents/pi-embedded-subscribe.handlers.messages.ts index 3f1b0e70e4..eb18187193 100644 --- a/src/agents/pi-embedded-subscribe.handlers.messages.ts +++ b/src/agents/pi-embedded-subscribe.handlers.messages.ts @@ -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); diff --git a/src/agents/pi-embedded-subscribe.handlers.types.ts b/src/agents/pi-embedded-subscribe.handlers.types.ts index 6cda543ca7..2a626ad86a 100644 --- a/src/agents/pi-embedded-subscribe.handlers.types.ts +++ b/src/agents/pi-embedded-subscribe.handlers.types.ts @@ -62,6 +62,7 @@ export type EmbeddedPiSubscribeState = { messagingToolSentTargets: MessagingToolSend[]; pendingMessagingTexts: Map; pendingMessagingTargets: Map; + lastAssistant?: AgentMessage; }; export type EmbeddedPiSubscribeContext = { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.e2e.test.ts index 7b52dfe74d..42c0158af4 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.e2e.test.ts @@ -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[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"); + }); }); diff --git a/src/agents/pi-embedded-subscribe.types.ts b/src/agents/pi-embedded-subscribe.types.ts index e94d9acda2..8c9fe02de3 100644 --- a/src/agents/pi-embedded-subscribe.types.ts +++ b/src/agents/pi-embedded-subscribe.types.ts @@ -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; onAgentEvent?: (evt: { stream: string; data: Record }) => void | Promise; enforceFinalTag?: boolean; + config?: OpenClawConfig; + sessionKey?: string; }; export type { BlockReplyChunking } from "./pi-embedded-block-chunker.js"; diff --git a/src/agents/pi-embedded-utils.ts b/src/agents/pi-embedded-utils.ts index edef43ec8c..801e5c9faa 100644 --- a/src/agents/pi-embedded-utils.ts +++ b/src/agents/pi-embedded-utils.ts @@ -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