fix(combo): retain context overflow in remaining budget exits (#15289) (#15934)

Batch-merged with the owner's 2026-10-09 PR wave (/merge-batch). Validated in one combined tree of 57 boarded PRs on top of release/v3.8.52 (35ef635c): typecheck:core, check:open-sse-typecheck, check-deps, changelog-integrity, complexity + cognitive ratchets, check:docs-all, i18n UI coverage, ESLint on all 247 changed source files — all green for this wave; 89 focused unit test files (739/744 pass under node:test, the 4 UI .tsx files re-run under vitest 13/13, check-deps fixed in #15912). Remaining reds in that tree belong only to held/draft PRs outside this wave (#15790 file-size, #15780 open-sse types).
This commit is contained in:
Diego Rodrigues de Sa e Souza 2026-10-09 12:49:16 -03:00 • committed by GitHub
parent 9453e01ae0
commit f04df4e077
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 137 additions and 16 deletions

View file

@ -0,0 +1 @@
- Return an actionable context-length error when round-robin or nested combo attempts are exhausted predominantly by context overflow, preserving diagnostics and the attempt limit (#15289).

View file

@ -8,6 +8,7 @@
* and the client only saw the opaque "Maximum combo retry limit reached".
*/
import { errorResponseWithComboDiagnostics } from "../../utils/error.ts";
import type { ComboDiagnostics } from "../../utils/error.ts";
import { isContextOverflow400 } from "./comboPredicates.ts";
import { buildComboDiag } from "./executeTargetClassify.ts";
import type { AttemptLoopState } from "./attemptLoopTypes.ts";
@ -20,6 +21,33 @@ interface BudgetErrorEntry {
error?: string;
}
/** Runtime-unit loops do not otherwise retain error text for terminal diagnostics. */
export async function readBudgetFailure(response: Response): Promise<BudgetErrorEntry> {
let error = "";
if (response.status === 400 || response.status === 413) {
try {
error = await response.clone().text();
} catch {
// An unreadable body is not proof of context overflow.
}
}
return { status: response.status, error };
}
export function buildContextBudgetResponse(
lastError: string | null | undefined,
errors: ReadonlyArray<BudgetErrorEntry>,
diagnostics: ComboDiagnostics
): Response {
if (isContextOverflowDominant(lastError, errors)) {
return errorResponseWithComboDiagnostics(400, CONTEXT_OVERFLOW_BUDGET_MESSAGE, diagnostics, {
code: "context_length_exceeded",
type: "invalid_request_error",
});
}
return errorResponseWithComboDiagnostics(503, "Maximum combo retry limit reached", diagnostics);
}
/** True when the last failure and at least half of the recorded failures were context overflows. */
export function isContextOverflowDominant(
lastError: string | null | undefined,
@ -49,15 +77,8 @@ export function buildBudgetExhaustedResponse(
const reasoningExhausted = /reasoning consumed \d+\/\d+ tokens/.test(state.lastError || "");
const failureReason = reasoningExhausted ? "reasoning_budget_exhausted" : "max_attempts_exceeded";
const diag = buildComboDiag(state, traceInvocationId, failureReason);
if (!reasoningExhausted && isContextOverflowDominant(state.lastError, state.comboErrors)) {
return errorResponseWithComboDiagnostics(400, CONTEXT_OVERFLOW_BUDGET_MESSAGE, diag, {
code: "context_length_exceeded",
type: "invalid_request_error",
});
if (!reasoningExhausted) {
return buildContextBudgetResponse(state.lastError, state.comboErrors, diag);
}
return errorResponseWithComboDiagnostics(
503,
reasoningExhausted ? REASONING_EXHAUSTED_MESSAGE : "Maximum combo retry limit reached",
diag
);
return errorResponseWithComboDiagnostics(503, REASONING_EXHAUSTED_MESSAGE, diag);
}

View file

@ -16,6 +16,7 @@ import {
readProseRetryAfter,
} from "../../utils/error.ts";
import { buildRecoveryHint } from "./pinRecovery.ts";
import { buildContextBudgetResponse } from "./budgetExhaustion.ts";
import { formatExhaustedConnectionKey } from "./comboDiagFormat.ts";
import { collectQuotaWindowExclusions, formatQuotaSkipMessage } from "./quotaSkipDiagnostics.ts";
import { recordComboRequest, recordPersistedSkipBypass } from "../comboMetrics.ts";
@ -619,7 +620,6 @@ export async function handleRoundRobinCombo({
throw err;
}
// Retry loop within this model
try {
for (let retry = 0; retry <= maxRetries; retry++) {
globalAttempts++;
@ -628,7 +628,7 @@ export async function handleRoundRobinCombo({
"COMBO-RR",
`Maximum combo attempts (${maxGlobalAttempts}) exceeded. Terminating loop to prevent runaway requests.`
);
return errorResponseWithComboDiagnostics(503, "Maximum combo retry limit reached", {
return buildContextBudgetResponse(lastError, rrOutcomes, {
poolSize: modelCount,
attempted: globalAttempts,
excluded: [

View file

@ -5,7 +5,8 @@
* @changes
* - [2026-07-24] [Composer] - Skip execute-mode units at concurrency cap before dispatch
*/
import { errorResponse, errorResponseWithComboDiagnostics } from "../../utils/error.ts";
import { errorResponse } from "../../utils/error.ts";
import { buildContextBudgetResponse, readBudgetFailure } from "./budgetExhaustion.ts";
import type { ComboDiagnostics } from "../../utils/error.ts";
import { recordComboRequest } from "../comboMetrics.ts";
import { resolveDelayMs, requestScopedReplayKey } from "./comboPredicates.ts";
@ -214,11 +215,13 @@ export async function executeRuntimeUnitCombo(args: {
let fallbackCount = 0;
let observedFailure = false;
let allObservedFailuresQuota = true;
const budgetFailures: Array<Awaited<ReturnType<typeof readBudgetFailure>>> = [];
const targetFailureTrust = new Map<
string,
{ observedFailure: boolean; allObservedFailuresQuota: boolean }
>();
const observeFailure = async (response: Response, unit: ResolvedComboUnit): Promise<boolean> => {
budgetFailures.push(await readBudgetFailure(response));
const quotaExhausted = await isQuotaExhaustionResponse(
response,
unit.kind === "model" ? unit.provider : null,
@ -280,9 +283,9 @@ export async function executeRuntimeUnitCombo(args: {
}
args.nesting.attemptBudget.count += 1;
if (args.nesting.attemptBudget.count > args.nesting.attemptBudget.limit) {
lastResponse = errorResponseWithComboDiagnostics(
503,
"Maximum combo retry limit reached",
lastResponse = buildContextBudgetResponse(
budgetFailures.at(-1)?.error,
budgetFailures,
buildAttemptBudgetDiag()
);
await observeFailure(lastResponse, unit);

View file

@ -0,0 +1,96 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { ResolvedComboUnit } from "../../open-sse/services/combo/types.ts";
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "combo-budget-15289-"));
process.env.DATA_DIR = dataDir;
const { handleComboChat } = await import("../../open-sse/services/combo.ts");
const { executeRuntimeUnitCombo } = await import("../../open-sse/services/combo/runtimeUnits.ts");
const { resetDbInstance } = await import("../../src/lib/db/core.ts");
const log = { info() {}, warn() {}, error() {}, debug() {} };
test.after(() => {
resetDbInstance();
fs.rmSync(dataDir, { recursive: true, force: true });
});
function failure(overflow: boolean): Response {
return new Response(
JSON.stringify({
error: {
message: overflow ? "Input exceeds the context window" : "upstream unavailable",
code: overflow ? "context_length_exceeded" : "service_unavailable",
},
}),
{ status: overflow ? 400 : 503, headers: { "content-type": "application/json" } }
);
}
for (const strategy of ["round-robin", "nested"] as const) {
for (const failures of [
[true, true, true],
[false, false, true],
[true, true, false],
]) {
test(`#15289 ${strategy} budget preserves dominant context cause: ${failures}`, async () => {
const models = ["openai/budget-a", "anthropic/budget-b", "gemini/budget-c", "codex/budget-d"];
let called = 0;
const handleSingleModel = async () => failure(failures[called++]);
const config = { maxRetries: 0, maxGlobalAttempts: 3, retryDelayMs: 0, fallbackDelayMs: 0 };
const response =
strategy === "round-robin"
? await handleComboChat({
body: { messages: [{ role: "user", content: "hi" }] },
combo: { name: `rr-15289-${failures}`, strategy, models, config },
handleSingleModel,
isModelAvailable: async () => true,
log,
settings: {},
allCombos: [],
})
: (
await executeRuntimeUnitCombo({
body: { messages: [{ role: "user", content: "hi" }] },
combo: { name: "nested-15289", strategy: "pipeline" },
strategy: "pipeline",
units: models.map((modelStr, i): ResolvedComboUnit => ({
kind: "model",
modelStr,
provider: modelStr.split("/")[0],
stepId: `s${i}`,
executionKey: `s${i}`,
providerId: null,
connectionId: null,
weight: 1,
label: null,
})),
handleSingleModel,
log,
config,
allCombos: [],
baseOptions: {},
nesting: {
depth: 0,
maxDepth: 5,
visitedComboNames: [],
rootComboName: "nested-15289",
attemptBudget: { count: 0, limit: 3 },
},
runCombo: async () => failure(true),
})
).response;
const body = await response.json();
assert.equal(called, 3, "the global dispatch budget is unchanged");
const dominant = failures[2] && failures.filter(Boolean).length >= 2;
assert.equal(response.status, dominant ? 400 : 503);
assert.equal(body.error.code, dominant ? "context_length_exceeded" : "service_unavailable");
assert.match(body.error.message, dominant ? /context window/ : /Maximum combo retry limit/);
assert.equal(body.diagnostics.terminalReason, "max_attempts_exceeded");
assert.equal(body.diagnostics.attemptOrder.length, 3);
assert.equal(body.diagnostics.poolSize, 4);
});
}
}