mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
* 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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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!");
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ export type EmbeddedPiSubscribeState = {
|
||||
messagingToolSentTargets: MessagingToolSend[];
|
||||
pendingMessagingTexts: Map<string, string>;
|
||||
pendingMessagingTargets: Map<string, MessagingToolSend>;
|
||||
lastAssistant?: AgentMessage;
|
||||
};
|
||||
|
||||
export type EmbeddedPiSubscribeContext = {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user