Skip to content

Commit 52cabf8

Browse files
committed
fix(agents): recover cli context overflow sessions
Fixes #98897
1 parent b60803d commit 52cabf8

17 files changed

Lines changed: 221 additions & 16 deletions

packages/gateway-protocol/src/schema/cron.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ const CronFailoverReasonSchema = Type.Union([
125125
Type.Literal("billing"),
126126
Type.Literal("server_error"),
127127
Type.Literal("timeout"),
128+
Type.Literal("context_overflow"),
128129
Type.Literal("model_not_found"),
129130
Type.Literal("session_expired"),
130131
Type.Literal("empty_response"),

src/agents/cli-runner.reliability.test.ts

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -661,6 +661,67 @@ describe("runCliAgent reliability", () => {
661661
expect(supervisorSpawnMock).toHaveBeenCalledTimes(1);
662662
});
663663

664+
it("does not retry context overflow after a confirmed message send", async () => {
665+
supervisorSpawnMock.mockClear();
666+
supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => {
667+
const input = args[0] as Parameters<ReturnType<typeof getProcessSupervisor>["spawn"]>[0];
668+
const captureHandle = markMcpLoopbackToolCallStarted({
669+
captureKey: input.env?.OPENCLAW_MCP_CLI_CAPTURE_KEY ?? "",
670+
toolName: "message",
671+
args: {
672+
action: "send",
673+
channel: "telegram",
674+
target: "chat123",
675+
message: "sent before overflow",
676+
},
677+
});
678+
if (!captureHandle) {
679+
throw new Error("Expected message delivery capture");
680+
}
681+
recordMcpLoopbackToolCallResult({
682+
captureHandle,
683+
toolName: "message",
684+
args: {
685+
action: "send",
686+
channel: "telegram",
687+
target: "chat123",
688+
message: "sent before overflow",
689+
},
690+
result: { status: "sent" },
691+
isError: false,
692+
});
693+
markMcpLoopbackToolCallFinished(captureHandle);
694+
return createManagedRun({
695+
reason: "exit",
696+
exitCode: 1,
697+
exitSignal: null,
698+
durationMs: 150,
699+
stdout: "",
700+
stderr: "Prompt is too long",
701+
timedOut: false,
702+
noOutputTimedOut: false,
703+
});
704+
});
705+
const context = buildPreparedContext({
706+
sessionKey: "agent:main:delivered-overflow",
707+
runId: "run-delivered-overflow",
708+
cliSessionId: "stale-cli-session",
709+
provider: "claude-cli",
710+
model: "opus",
711+
openClawHistoryPrompt: CLI_RESEED_PROMPT,
712+
});
713+
context.mcpDeliveryCapture = true;
714+
715+
const result = await runPreparedCliAgent(context);
716+
717+
expect(result.payloads).toBeUndefined();
718+
expect(result.didSendViaMessagingTool).toBe(true);
719+
expect(result.messagingToolSentTexts).toEqual(["sent before overflow"]);
720+
expect(result.meta.executionTrace?.attempts?.[0]?.result).toBe("error");
721+
expect(result.meta.agentMeta?.clearCliSessionBinding).toBe(true);
722+
expect(supervisorSpawnMock).toHaveBeenCalledTimes(1);
723+
});
724+
664725
it("preserves first-turn delivery through cleanup without binding the OpenClaw session id", async () => {
665726
supervisorSpawnMock.mockClear();
666727
supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => {
@@ -1390,6 +1451,48 @@ describe("runCliAgent reliability", () => {
13901451
expect(clearBeforeRetry).not.toHaveBeenCalled();
13911452
});
13921453

1454+
it("does not fresh retry context overflow when the run timeout budget is exhausted", async () => {
1455+
supervisorSpawnMock.mockClear();
1456+
const clearBeforeRetry = vi.fn(async () => true);
1457+
supervisorSpawnMock.mockResolvedValueOnce(
1458+
createManagedRun({
1459+
reason: "exit",
1460+
exitCode: 1,
1461+
exitSignal: null,
1462+
durationMs: 150,
1463+
stdout: "",
1464+
stderr: "Prompt is too long",
1465+
timedOut: false,
1466+
noOutputTimedOut: false,
1467+
}),
1468+
);
1469+
const context = buildPreparedContext({
1470+
sessionKey: "agent:main:expired-overflow-budget",
1471+
runId: "run-expired-overflow-budget",
1472+
cliSessionId: "stale-cli-session",
1473+
provider: "claude-cli",
1474+
model: "opus",
1475+
openClawHistoryPrompt: CLI_RESEED_PROMPT,
1476+
});
1477+
const expiredBudgetContext = {
1478+
...context,
1479+
started: Date.now() - context.params.timeoutMs - 1,
1480+
};
1481+
1482+
await expect(
1483+
runPreparedCliAgent({
1484+
...expiredBudgetContext,
1485+
params: {
1486+
...expiredBudgetContext.params,
1487+
onBeforeFreshCliSessionRetry: clearBeforeRetry,
1488+
},
1489+
}),
1490+
).rejects.toThrow("Prompt is too long");
1491+
1492+
expect(supervisorSpawnMock).toHaveBeenCalledTimes(1);
1493+
expect(clearBeforeRetry).not.toHaveBeenCalled();
1494+
});
1495+
13931496
it("does not fresh retry a no-output timeout after CLI diagnostic output", async () => {
13941497
supervisorSpawnMock.mockClear();
13951498
enqueueSystemEventMock.mockClear();
@@ -1468,7 +1571,7 @@ describe("runCliAgent reliability", () => {
14681571
expect(clearBeforeRetry).not.toHaveBeenCalled();
14691572
});
14701573

1471-
it.each(["timeout", "unknown"] as const)(
1574+
it.each(["timeout", "unknown", "context_overflow"] as const)(
14721575
"retries a fresh CLI session after recoverable %s failover without a failed agent_end",
14731576
async (reason) => {
14741577
const hookRunner = {
@@ -1500,6 +1603,18 @@ describe("runCliAgent reliability", () => {
15001603
noOutputTimedOut: true,
15011604
});
15021605
}
1606+
if (spawnCount === 1 && reason === "context_overflow") {
1607+
return createManagedRun({
1608+
reason: "exit",
1609+
exitCode: 1,
1610+
exitSignal: null,
1611+
durationMs: 150,
1612+
stdout: "",
1613+
stderr: "Prompt is too long",
1614+
timedOut: false,
1615+
noOutputTimedOut: false,
1616+
});
1617+
}
15031618
if (spawnCount === 1) {
15041619
return createManagedRun({
15051620
reason: "exit",
@@ -1552,6 +1667,8 @@ describe("runCliAgent reliability", () => {
15521667
});
15531668

15541669
expect(result.payloads).toEqual([{ text: "hello from fresh cli" }]);
1670+
expect(result.meta.finalPromptText).toContain("User: earlier context");
1671+
expect(result.meta.finalPromptText).toContain("<next_user_message>");
15551672
expect(supervisorSpawnMock).toHaveBeenCalledTimes(2);
15561673
expect(events).toEqual(["spawn-1", `clear-${reason}`, "spawn-2"]);
15571674
if (reason === "timeout") {

src/agents/cli-runner.spawn.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3003,6 +3003,64 @@ ${JSON.stringify({
30033003
);
30043004
});
30053005

3006+
it("marks Claude live stderr context overflows as retryable", async () => {
3007+
let stdoutListener: ((chunk: string) => void) | undefined;
3008+
let resolveExit: ((exit: RunExit) => void) | undefined;
3009+
const exited = new Promise<RunExit>((resolve) => {
3010+
resolveExit = resolve;
3011+
});
3012+
const stdin = {
3013+
write: vi.fn((dataValue: string, cb?: (err?: Error | null) => void) => {
3014+
stdoutListener?.(
3015+
JSON.stringify({ type: "system", subtype: "init", session_id: "live-overflow" }) + "\n",
3016+
);
3017+
cb?.();
3018+
resolveExit?.({
3019+
reason: "exit",
3020+
exitCode: 1,
3021+
exitSignal: null,
3022+
durationMs: 1,
3023+
stdout: "",
3024+
stderr: "Prompt is too long",
3025+
timedOut: false,
3026+
noOutputTimedOut: false,
3027+
});
3028+
}),
3029+
end: vi.fn(),
3030+
};
3031+
supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => {
3032+
const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void };
3033+
stdoutListener = input.onStdout;
3034+
return {
3035+
runId: "live-overflow-run",
3036+
pid: 2345,
3037+
startedAtMs: Date.now(),
3038+
stdin,
3039+
wait: vi.fn(() => exited),
3040+
cancel: vi.fn(),
3041+
};
3042+
});
3043+
3044+
await expectRejectsWithFields(
3045+
executePreparedCliRun(
3046+
buildPreparedCliRunContext({
3047+
provider: "claude-cli",
3048+
model: "sonnet",
3049+
runId: "run-live-overflow",
3050+
backend: {
3051+
liveSession: "claude-stdio",
3052+
},
3053+
}),
3054+
),
3055+
{
3056+
name: "FailoverError",
3057+
reason: "context_overflow",
3058+
code: "cli_context_overflow",
3059+
status: 413,
3060+
},
3061+
);
3062+
});
3063+
30063064
it("fails when Claude exits before a live turn starts", async () => {
30073065
supervisorSpawnMock.mockImplementationOnce(async () => ({
30083066
runId: "live-run",

src/agents/cli-runner.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ function shouldRetryFreshCliSessionAfterFailover(params: {
100100
return params.error.code === "cli_unknown_empty_failure";
101101
case "timeout":
102102
return params.error.code === "cli_no_output_timeout";
103+
case "context_overflow":
104+
return params.error.code === "cli_context_overflow";
103105
default:
104106
return false;
105107
}

src/agents/cli-runner/claude-live-session.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -833,11 +833,13 @@ function createResultError(
833833
const result = typeof parsed.result === "string" ? parsed.result.trim() : "";
834834
const message = extractCliErrorMessage(raw) ?? (result || "Claude CLI failed.");
835835
const reason = classifyFailoverReason(message, { provider: session.providerId }) ?? "unknown";
836+
const code = reason === "context_overflow" ? "cli_context_overflow" : undefined;
836837
return new FailoverError(message, {
837838
reason,
838839
provider: session.providerId,
839840
model: session.modelId,
840841
status: resolveFailoverStatus(reason),
842+
code,
841843
});
842844
}
843845

@@ -1012,13 +1014,15 @@ function handleClaudeExit(session: ClaudeLiveSession, exitCode: number | null):
10121014
return;
10131015
}
10141016
const reason = classifyFailoverReason(message, { provider: session.providerId }) ?? "unknown";
1017+
const code = reason === "context_overflow" ? "cli_context_overflow" : undefined;
10151018
failTurn(
10161019
session,
10171020
new FailoverError(message, {
10181021
reason,
10191022
provider: session.providerId,
10201023
model: session.modelId,
10211024
status: resolveFailoverStatus(reason),
1025+
code,
10221026
}),
10231027
);
10241028
}

src/agents/cli-runner/execute.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1300,12 +1300,14 @@ export async function executePreparedCliRun(
13001300
reason = reason ?? "unknown";
13011301
const status = resolveFailoverStatus(reason);
13021302
const retryCode =
1303-
reason === "unknown" &&
1304-
result.reason === "exit" &&
1305-
errorCandidates.length === 0 &&
1306-
!observedCliActivity
1307-
? "cli_unknown_empty_failure"
1308-
: undefined;
1303+
reason === "context_overflow"
1304+
? "cli_context_overflow"
1305+
: reason === "unknown" &&
1306+
result.reason === "exit" &&
1307+
errorCandidates.length === 0 &&
1308+
!observedCliActivity
1309+
? "cli_unknown_empty_failure"
1310+
: undefined;
13091311
throw new FailoverError(err, {
13101312
reason,
13111313
provider: params.provider,

src/agents/embedded-agent-helpers.isbillingerrormessage.test.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,12 @@ describe("extractObservedOverflowTokenCount", () => {
611611
});
612612
});
613613

614+
describe("classifyFailoverReason context overflow", () => {
615+
it("maps prompt overflow to the closed failover reason", () => {
616+
expect(classifyFailoverReason("Prompt is too long")).toBe("context_overflow");
617+
});
618+
});
619+
614620
describe("isTransientHttpError", () => {
615621
it("returns true for retryable 5xx status codes", () => {
616622
expect(isTransientHttpError("499 Client Closed Request")).toBe(true);
@@ -671,13 +677,13 @@ describe("classifyFailoverReasonFromHttpStatus", () => {
671677
).toBe("rate_limit");
672678
});
673679

674-
it("does not force HTTP 400 context-overflow payloads into format", () => {
680+
it("classifies HTTP 400 context-overflow payloads without using format", () => {
675681
expect(
676682
classifyFailoverReasonFromHttpStatus(
677683
400,
678684
"INVALID_ARGUMENT: input exceeds the maximum number of tokens",
679685
),
680-
).toBeNull();
686+
).toBe("context_overflow");
681687
});
682688

683689
it("lets OpenRouter billing-classified HTTP 401 responses bypass generic auth", () => {
@@ -803,12 +809,12 @@ describe("classifyFailoverReason HTTP 410 handling", () => {
803809
expect(classifyFailoverReason("HTTP 404: insufficient credits")).toBe("billing");
804810
});
805811

806-
it("does not map HTTP 404 plus context-overflow text to model_not_found", () => {
812+
it("maps HTTP 404 plus context-overflow text to context_overflow", () => {
807813
expect(
808814
classifyFailoverReason(
809815
"HTTP 404: INVALID_ARGUMENT: input exceeds the maximum number of tokens",
810816
),
811-
).toBeNull();
817+
).toBe("context_overflow");
812818
});
813819

814820
it("keeps raw HTTP 400 wrappers aligned with structured provider classification", () => {
@@ -819,7 +825,7 @@ describe("classifyFailoverReason HTTP 410 handling", () => {
819825
classifyFailoverReason(
820826
"HTTP 400: INVALID_ARGUMENT: input exceeds the maximum number of tokens",
821827
),
822-
).toBeNull();
828+
).toBe("context_overflow");
823829
});
824830

825831
it("classifies OpenAI Responses unknown-no-details message distinctly", () => {

src/agents/embedded-agent-helpers/errors.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -731,7 +731,10 @@ function toReasonClassification(reason: FailoverReason): FailoverClassification
731731
function failoverReasonFromClassification(
732732
classification: FailoverClassification | null,
733733
): FailoverReason | null {
734-
return classification?.kind === "reason" ? classification.reason : null;
734+
if (!classification) {
735+
return null;
736+
}
737+
return classification.kind === "reason" ? classification.reason : "context_overflow";
735738
}
736739

737740
export function isTransientHttpError(raw: string): boolean {

src/agents/embedded-agent-helpers/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export type FailoverReason =
1111
| "billing"
1212
| "server_error"
1313
| "timeout"
14+
| "context_overflow"
1415
| "model_not_found"
1516
| "session_expired"
1617
| "empty_response"

src/agents/embedded-agent-runner/run/auth-profile-failure-policy.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export function resolveAuthProfileFailureReason(params: {
3434
(params.failoverReason === "rate_limit" && params.transientRateLimit === true))) ||
3535
params.failoverReason === "server_error" ||
3636
params.failoverReason === "empty_response" ||
37+
params.failoverReason === "context_overflow" ||
3738
params.failoverReason === "format"
3839
) {
3940
return null;

0 commit comments

Comments
 (0)