mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 23:41:24 +00: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).toContain("API provider");
|
||||||
expect(result).toBe(BILLING_ERROR_USER_MESSAGE);
|
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", () => {
|
it("collapses consecutive duplicate paragraphs", () => {
|
||||||
const text = "Hello there!\n\nHello there!";
|
const text = "Hello there!\n\nHello there!";
|
||||||
expect(sanitizeUserFacingText(text)).toBe("Hello there!");
|
expect(sanitizeUserFacingText(text)).toBe("Hello there!");
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export function formatBillingErrorMessage(provider?: string): string {
|
|||||||
|
|
||||||
export const BILLING_ERROR_USER_MESSAGE = formatBillingErrorMessage();
|
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 {
|
export function isContextOverflowError(errorMessage?: string): boolean {
|
||||||
if (!errorMessage) {
|
if (!errorMessage) {
|
||||||
return false;
|
return false;
|
||||||
@@ -461,6 +463,10 @@ export function formatAssistantErrorText(
|
|||||||
return `LLM request rejected: ${invalidRequest[1]}`;
|
return `LLM request rejected: ${invalidRequest[1]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isRateLimitErrorMessage(raw)) {
|
||||||
|
return RATE_LIMIT_ERROR_USER_MESSAGE;
|
||||||
|
}
|
||||||
|
|
||||||
if (isOverloadedErrorMessage(raw)) {
|
if (isOverloadedErrorMessage(raw)) {
|
||||||
return "The AI service is temporarily overloaded. Please try again in a moment.";
|
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 (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.";
|
return "The AI service is temporarily overloaded. Please try again in a moment.";
|
||||||
}
|
}
|
||||||
if (isTimeoutErrorMessage(trimmed)) {
|
if (isTimeoutErrorMessage(trimmed)) {
|
||||||
|
|||||||
@@ -733,6 +733,8 @@ export async function runEmbeddedAttempt(
|
|||||||
onAssistantMessageStart: params.onAssistantMessageStart,
|
onAssistantMessageStart: params.onAssistantMessageStart,
|
||||||
onAgentEvent: params.onAgentEvent,
|
onAgentEvent: params.onAgentEvent,
|
||||||
enforceFinalTag: params.enforceFinalTag,
|
enforceFinalTag: params.enforceFinalTag,
|
||||||
|
config: params.config,
|
||||||
|
sessionKey: params.sessionKey ?? params.sessionId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type { EmbeddedPiSubscribeContext } from "./pi-embedded-subscribe.handler
|
|||||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||||
import { createInlineCodeState } from "../markdown/code-spans.js";
|
import { createInlineCodeState } from "../markdown/code-spans.js";
|
||||||
import { getGlobalHookRunner } from "../plugins/hook-runner-global.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) {
|
export function handleAgentStart(ctx: EmbeddedPiSubscribeContext) {
|
||||||
ctx.log.debug(`embedded run agent start: runId=${ctx.params.runId}`);
|
ctx.log.debug(`embedded run agent start: runId=${ctx.params.runId}`);
|
||||||
@@ -94,19 +96,46 @@ export function handleAutoCompactionEnd(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function handleAgentEnd(ctx: EmbeddedPiSubscribeContext) {
|
export function handleAgentEnd(ctx: EmbeddedPiSubscribeContext) {
|
||||||
ctx.log.debug(`embedded run agent end: runId=${ctx.params.runId}`);
|
const lastAssistant = ctx.state.lastAssistant;
|
||||||
emitAgentEvent({
|
const isError = isAssistantMessage(lastAssistant) && lastAssistant.stopReason === "error";
|
||||||
runId: ctx.params.runId,
|
|
||||||
stream: "lifecycle",
|
ctx.log.debug(`embedded run agent end: runId=${ctx.params.runId} isError=${isError}`);
|
||||||
data: {
|
|
||||||
phase: "end",
|
if (isError && lastAssistant) {
|
||||||
endedAt: Date.now(),
|
const friendlyError = formatAssistantErrorText(lastAssistant, {
|
||||||
},
|
cfg: ctx.params.config,
|
||||||
});
|
sessionKey: ctx.params.sessionKey,
|
||||||
void ctx.params.onAgentEvent?.({
|
});
|
||||||
stream: "lifecycle",
|
emitAgentEvent({
|
||||||
data: { phase: "end" },
|
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.params.onBlockReply) {
|
||||||
if (ctx.blockChunker?.hasBuffered()) {
|
if (ctx.blockChunker?.hasBuffered()) {
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ export function handleMessageUpdate(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx.state.lastAssistant = msg;
|
||||||
|
|
||||||
const assistantEvent = evt.assistantMessageEvent;
|
const assistantEvent = evt.assistantMessageEvent;
|
||||||
const assistantRecord =
|
const assistantRecord =
|
||||||
assistantEvent && typeof assistantEvent === "object"
|
assistantEvent && typeof assistantEvent === "object"
|
||||||
@@ -198,6 +200,7 @@ export function handleMessageEnd(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const assistantMessage = msg;
|
const assistantMessage = msg;
|
||||||
|
ctx.state.lastAssistant = assistantMessage;
|
||||||
ctx.recordAssistantUsage((assistantMessage as { usage?: unknown }).usage);
|
ctx.recordAssistantUsage((assistantMessage as { usage?: unknown }).usage);
|
||||||
promoteThinkingTagsToBlocks(assistantMessage);
|
promoteThinkingTagsToBlocks(assistantMessage);
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export type EmbeddedPiSubscribeState = {
|
|||||||
messagingToolSentTargets: MessagingToolSend[];
|
messagingToolSentTargets: MessagingToolSend[];
|
||||||
pendingMessagingTexts: Map<string, string>;
|
pendingMessagingTexts: Map<string, string>;
|
||||||
pendingMessagingTargets: Map<string, MessagingToolSend>;
|
pendingMessagingTargets: Map<string, MessagingToolSend>;
|
||||||
|
lastAssistant?: AgentMessage;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EmbeddedPiSubscribeContext = {
|
export type EmbeddedPiSubscribeContext = {
|
||||||
|
|||||||
@@ -317,4 +317,43 @@ describe("subscribeEmbeddedPiSession", () => {
|
|||||||
expect(payloads[0]?.text).toBe("");
|
expect(payloads[0]?.text).toBe("");
|
||||||
expect(payloads[0]?.mediaUrls).toEqual(["https://example.com/a.png"]);
|
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 { AgentSession } from "@mariozechner/pi-coding-agent";
|
||||||
import type { ReasoningLevel, VerboseLevel } from "../auto-reply/thinking.js";
|
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 { HookRunner } from "../plugins/hooks.js";
|
||||||
import type { BlockReplyChunking } from "./pi-embedded-block-chunker.js";
|
import type { BlockReplyChunking } from "./pi-embedded-block-chunker.js";
|
||||||
|
|
||||||
@@ -32,6 +33,8 @@ export type SubscribeEmbeddedPiSessionParams = {
|
|||||||
onAssistantMessageStart?: () => void | Promise<void>;
|
onAssistantMessageStart?: () => void | Promise<void>;
|
||||||
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void | Promise<void>;
|
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void | Promise<void>;
|
||||||
enforceFinalTag?: boolean;
|
enforceFinalTag?: boolean;
|
||||||
|
config?: OpenClawConfig;
|
||||||
|
sessionKey?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type { BlockReplyChunking } from "./pi-embedded-block-chunker.js";
|
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 type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||||
import { stripReasoningTagsFromText } from "../shared/text/reasoning-tags.js";
|
import { stripReasoningTagsFromText } from "../shared/text/reasoning-tags.js";
|
||||||
import { sanitizeUserFacingText } from "./pi-embedded-helpers.js";
|
import { sanitizeUserFacingText } from "./pi-embedded-helpers.js";
|
||||||
import { formatToolDetail, resolveToolDisplay } from "./tool-display.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.
|
* Strip malformed Minimax tool invocations that leak into text content.
|
||||||
* Minimax sometimes embeds tool calls as XML in text blocks instead of
|
* Minimax sometimes embeds tool calls as XML in text blocks instead of
|
||||||
|
|||||||
Reference in New Issue
Block a user