fix(agents): check billing errors before context overflow heuristics

Billing/quota errors from providers like OpenRouter can contain token-limit
wording (e.g. "request token limit exceeded", "request size exceeds") that
matches the context overflow and compaction-failure heuristics. Add an early
isBillingErrorMessage guard so those errors show the billing-specific message
and dashboard link instead of "Context overflow: prompt too large".

- Add isBillingErrorMessage early-exit guard to isLikelyContextOverflowError
- Guard isCompactionFailure behind !isBilling in runAgentTurnWithFallback
- Return BILLING_ERROR_USER_MESSAGE when catch block classifies as billing
- Add helper-level tests covering mixed-signal billing+overflow patterns
- Add runner-level test through runAgentTurnWithFallback to lock in the
  user-visible message classification in the catch block

Co-Authored-By: sparkyrider <Will@willthings.com>
This commit is contained in:
ademczuk
2026-03-10 07:43:56 +01:00
committed by Altay
parent f417d78eef
commit df4aa0cf1d
5 changed files with 101 additions and 7 deletions

View File

@@ -1045,6 +1045,7 @@ Docs: https://docs.openclaw.ai
- Browser/Navigate: resolve the correct `targetId` in navigate responses after renderer swaps. (#25326) Thanks @stone-jin and @vincentkoc.
- FS/Sandbox workspace boundaries: add a dedicated `outside-workspace` safe-open error code for root-escape checks, and propagate specific outside-workspace messages across edit/browser/media consumers instead of generic not-found/invalid-path fallbacks. (#29715) Thanks @YuzuruS.
- Diagnostics/Stuck session signal: add configurable stuck-session warning threshold via `diagnostics.stuckSessionWarnMs` (default 120000ms) to reduce false-positive warnings on long multi-tool turns. (#31032)
- Agents/error classification: check billing errors before context overflow heuristics in the agent runner catch block so spend-limit and quota errors show the billing-specific message instead of being misclassified as "Context overflow: prompt too large". (#40378)
## 2026.2.26

View File

@@ -439,6 +439,18 @@ describe("isLikelyContextOverflowError", () => {
expect(isLikelyContextOverflowError(sample)).toBe(false);
}
});
it("excludes billing errors even when text matches context overflow patterns", () => {
const samples = [
"402 Payment Required: request token limit exceeded for this billing plan",
"insufficient credits: request size exceeds your current plan limits",
"Your credit balance is too low. Maximum request token limit exceeded.",
];
for (const sample of samples) {
expect(isBillingErrorMessage(sample)).toBe(true);
expect(isLikelyContextOverflowError(sample)).toBe(false);
}
});
});
describe("isTransientHttpError", () => {

View File

@@ -138,6 +138,13 @@ export function isLikelyContextOverflowError(errorMessage?: string): boolean {
return false;
}
// Billing/quota errors can contain patterns like "request size exceeds" or
// "maximum token limit exceeded" that match the context overflow heuristic.
// Billing is a more specific error class — exclude it early.
if (isBillingErrorMessage(errorMessage)) {
return false;
}
if (CONTEXT_WINDOW_TOO_SMALL_RE.test(errorMessage)) {
return false;
}

View File

@@ -6,8 +6,10 @@ import { getCliSessionId } from "../../agents/cli-session.js";
import { runWithModelFallback } from "../../agents/model-fallback.js";
import { isCliProvider } from "../../agents/model-selection.js";
import {
BILLING_ERROR_USER_MESSAGE,
isCompactionFailureError,
isContextOverflowError,
isBillingErrorMessage,
isLikelyContextOverflowError,
isTransientHttpError,
sanitizeUserFacingText,
@@ -514,8 +516,9 @@ export async function runAgentTurnWithFallback(params: {
break;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const isContextOverflow = isLikelyContextOverflowError(message);
const isCompactionFailure = isCompactionFailureError(message);
const isBilling = isBillingErrorMessage(message);
const isContextOverflow = !isBilling && isLikelyContextOverflowError(message);
const isCompactionFailure = !isBilling && isCompactionFailureError(message);
const isSessionCorruption = /function call turn comes immediately after/i.test(message);
const isRoleOrderingError = /incorrect role information|roles must alternate/i.test(message);
const isTransientHttp = isTransientHttpError(message);
@@ -610,11 +613,13 @@ export async function runAgentTurnWithFallback(params: {
? sanitizeUserFacingText(message, { errorContext: true })
: message;
const trimmedMessage = safeMessage.replace(/\.\s*$/, "");
const fallbackText = isContextOverflow
? "⚠️ Context overflow — prompt too large for this model. Try a shorter message or a larger-context model."
: isRoleOrderingError
? "⚠️ Message ordering conflict - please try again. If this persists, use /new to start a fresh session."
: `⚠️ Agent failed before reply: ${trimmedMessage}.\nLogs: openclaw logs --follow`;
const fallbackText = isBilling
? BILLING_ERROR_USER_MESSAGE
: isContextOverflow
? "⚠️ Context overflow — prompt too large for this model. Try a shorter message or a larger-context model."
: isRoleOrderingError
? "⚠️ Message ordering conflict - please try again. If this persists, use /new to start a fresh session."
: `⚠️ Agent failed before reply: ${trimmedMessage}.\nLogs: openclaw logs --follow`;
return {
kind: "final",

View File

@@ -1628,3 +1628,72 @@ describe("runReplyAgent transient HTTP retry", () => {
expect(payload?.text).toContain("Recovered response");
});
});
describe("runReplyAgent billing error classification", () => {
// Regression guard for the runner-level catch block in runAgentTurnWithFallback.
// Billing errors from providers like OpenRouter can contain token/size wording that
// matches context overflow heuristics. This test verifies the final user-visible
// message is the billing-specific one, not the "Context overflow" fallback.
it("returns billing message for mixed-signal error (billing text + overflow patterns)", async () => {
runEmbeddedPiAgentMock.mockRejectedValueOnce(
new Error("402 Payment Required: request token limit exceeded for this billing plan"),
);
const typing = createMockTypingController();
const sessionCtx = {
Provider: "telegram",
MessageSid: "msg",
} as unknown as TemplateContext;
const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings;
const followupRun = {
prompt: "hello",
summaryLine: "hello",
enqueuedAt: Date.now(),
run: {
sessionId: "session",
sessionKey: "main",
messageProvider: "telegram",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
config: {},
skillsSnapshot: {},
provider: "anthropic",
model: "claude",
thinkLevel: "low",
verboseLevel: "off",
elevatedLevel: "off",
bashElevated: {
enabled: false,
allowed: false,
defaultLevel: "off",
},
timeoutMs: 1_000,
blockReplyBreak: "message_end",
},
} as unknown as FollowupRun;
const result = await runReplyAgent({
commandBody: "hello",
followupRun,
queueKey: "main",
resolvedQueue,
shouldSteer: false,
shouldFollowup: false,
isActive: false,
isStreaming: false,
typing,
sessionCtx,
defaultModel: "anthropic/claude",
resolvedVerboseLevel: "off",
isNewSession: false,
blockStreamingEnabled: false,
resolvedBlockStreamingBreak: "message_end",
shouldInjectGroupIntro: false,
typingMode: "instant",
});
const payload = Array.isArray(result) ? result[0] : result;
expect(payload?.text).toContain("billing error");
expect(payload?.text).not.toContain("Context overflow");
});
});