Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 4a16670

Browse files
authored
fix(agent): retry oversized resume on a fresh session with summarized history (#3334)
1 parent 829faf4 commit 4a16670

3 files changed

Lines changed: 179 additions & 3 deletions

File tree

packages/agent/src/adapters/error-classification.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { getErrorMessage } from "@posthog/shared";
2+
13
export type AgentErrorClassification =
24
| "upstream_stream_terminated"
35
| "upstream_connection_error"
@@ -32,3 +34,8 @@ export function classifyAgentError(
3234
}
3335
return "agent_error";
3436
}
37+
38+
/** Hard API rejection: the assembled prompt exceeds the model's context window. */
39+
export function isPromptTooLongError(error: unknown): boolean {
40+
return /prompt is too long/i.test(getErrorMessage(error));
41+
}

packages/agent/src/server/agent-server.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1808,6 +1808,98 @@ describe("AgentServer HTTP Mode", () => {
18081808
});
18091809

18101810
describe("native resume", () => {
1811+
it.each([
1812+
{ retryOutcome: "succeeds", retryFails: false },
1813+
{ retryOutcome: "fails", retryFails: true },
1814+
])(
1815+
"clears resume state when the fresh-session retry $retryOutcome",
1816+
async ({ retryFails }) => {
1817+
const s = createServer();
1818+
await s.start();
1819+
1820+
const prompts: ContentBlock[][] = [];
1821+
const prompt = vi.fn(async (params: { prompt: ContentBlock[] }) => {
1822+
prompts.push(params.prompt);
1823+
if (prompts.length === 1) {
1824+
throw new Error("Internal error: Prompt is too long");
1825+
}
1826+
if (retryFails) {
1827+
throw new Error("Fresh-session retry failed");
1828+
}
1829+
return { stopReason: "end_turn" };
1830+
});
1831+
const newSession = vi.fn(async () => ({ sessionId: "fresh-session" }));
1832+
1833+
const internals = s as unknown as {
1834+
session: {
1835+
acpSessionId: string;
1836+
clientConnection: {
1837+
prompt: typeof prompt;
1838+
newSession: typeof newSession;
1839+
};
1840+
};
1841+
resumeState: ResumeState | null;
1842+
nativeResume: { sessionId: string; warm: boolean } | null;
1843+
loadResumeState(
1844+
taskId: string,
1845+
resumeRunId: string,
1846+
runId: string,
1847+
): Promise<void>;
1848+
sendResumeContinuation(
1849+
payload: JwtPayload,
1850+
taskRun: TaskRun | null,
1851+
): Promise<void>;
1852+
};
1853+
internals.session.clientConnection.prompt = prompt;
1854+
internals.session.clientConnection.newSession = newSession;
1855+
internals.nativeResume = { sessionId: "prior-session", warm: true };
1856+
internals.loadResumeState = vi.fn(async () => {
1857+
internals.resumeState = {
1858+
conversation: [
1859+
{
1860+
role: "user",
1861+
content: [{ type: "text", text: "original task" }],
1862+
},
1863+
{
1864+
role: "assistant",
1865+
content: [{ type: "text", text: "progress so far" }],
1866+
},
1867+
],
1868+
latestGitCheckpoint: null,
1869+
interrupted: false,
1870+
logEntryCount: 2,
1871+
sessionId: "prior-session",
1872+
};
1873+
});
1874+
1875+
await internals.sendResumeContinuation(
1876+
{
1877+
task_id: "test-task-id",
1878+
run_id: "test-run-id",
1879+
team_id: 1,
1880+
user_id: 1,
1881+
distinct_id: "test-distinct-id",
1882+
mode: "interactive",
1883+
},
1884+
createTaskRun({
1885+
id: "test-run-id",
1886+
state: { resume_from_run_id: "previous-run" },
1887+
}),
1888+
);
1889+
1890+
expect(newSession).toHaveBeenCalledOnce();
1891+
expect(internals.session.acpSessionId).toBe("fresh-session");
1892+
expect(internals.resumeState).toBeNull();
1893+
expect(internals.nativeResume).toBeNull();
1894+
expect(prompts).toHaveLength(2);
1895+
const retryText = prompts[1]
1896+
.map((block) => ("text" in block ? block.text : ""))
1897+
.join("\n");
1898+
expect(retryText).toContain("progress so far");
1899+
},
1900+
20000,
1901+
);
1902+
18111903
it("hydrates cold sessions from S3 logs instead of cached resume conversation", async () => {
18121904
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
18131905
process.env.CLAUDE_CONFIG_DIR = join(repo.path, ".claude-test");

packages/agent/src/server/agent-server.ts

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { getCurrentBranch } from "@posthog/git/queries";
1919
import {
2020
type Adapter,
2121
buildPrOutput,
22+
getErrorMessage,
2223
mergePrUrls,
2324
readPrUrls,
2425
} from "@posthog/shared";
@@ -40,6 +41,7 @@ import { hasCodexThreadState } from "../adapters/codex-app-server/thread-state";
4041
import {
4142
type AgentErrorClassification,
4243
classifyAgentError,
44+
isPromptTooLongError,
4345
} from "../adapters/error-classification";
4446
import {
4547
SIGNED_COMMIT_QUALIFIED_TOOL_NAME,
@@ -249,6 +251,8 @@ interface ActiveSession {
249251
/** Whether a desktop client has ever connected via SSE during this session */
250252
hasDesktopConnected: boolean;
251253
pendingHandoffGitState?: HandoffLocalGitState;
254+
/** Meta the session was created with, reused when a retry needs a fresh session */
255+
sessionMeta: Record<string, unknown>;
252256
}
253257

254258
interface InstalledSkillBundle {
@@ -328,6 +332,7 @@ export class AgentServer {
328332
private lastReportedBranch: string | null = null;
329333
private resumeState: ResumeState | null = null;
330334
private nativeResume: { sessionId: string; warm: boolean } | null = null;
335+
private oversizedResumeRetried = false;
331336
// Prewarmed runs boot before the user's first message exists, so the boot-time
332337
// --autoPublish flag can't carry the user's choice; it is resolved from run
333338
// state when the first message arrives (see resolveWarmAutoPublishUpgrade).
@@ -1409,6 +1414,7 @@ export class AgentServer {
14091414
permissionMode: initialPermissionMode,
14101415
hasDesktopConnected: sseController !== null,
14111416
pendingHandoffGitState: undefined,
1417+
sessionMeta,
14121418
};
14131419

14141420
this.logger = new Logger({
@@ -1745,7 +1751,6 @@ export class AgentServer {
17451751
gitCheckpointBranch: resumeState.latestGitCheckpoint?.branch ?? null,
17461752
});
17471753

1748-
this.resumeState = null;
17491754
return {
17501755
prompt: resumePromptBlocks,
17511756
...(resumePromptMeta ? { meta: resumePromptMeta } : {}),
@@ -1786,21 +1791,82 @@ export class AgentServer {
17861791
hasPendingUserMessage: !!pendingUserPrompt?.prompt.length,
17871792
});
17881793

1789-
this.resumeState = null;
1790-
this.nativeResume = null;
17911794
return {
17921795
prompt,
17931796
...(pendingUserPrompt?.meta ? { meta: pendingUserPrompt.meta } : {}),
17941797
};
17951798
},
1799+
{ retryOnOversizedPrompt: true },
17961800
);
17971801
}
17981802

1803+
/**
1804+
* A native resume replays the prior transcript verbatim; when that
1805+
* transcript no longer fits the context window, every request (including
1806+
* auto-compaction) is rejected, so the only way forward is a fresh session
1807+
* seeded with the summarized history the non-native resume path uses.
1808+
*/
1809+
private async retryOversizedResumeOnFreshSession(
1810+
payload: JwtPayload,
1811+
taskRun: TaskRun | null,
1812+
): Promise<boolean> {
1813+
if (this.oversizedResumeRetried || !this.session) {
1814+
return false;
1815+
}
1816+
this.oversizedResumeRetried = true;
1817+
1818+
const resumeRunId = this.getResumeRunId(taskRun);
1819+
if (!resumeRunId) return false;
1820+
if (!this.resumeState) {
1821+
try {
1822+
await this.loadResumeState(
1823+
payload.task_id,
1824+
resumeRunId,
1825+
payload.run_id,
1826+
);
1827+
} catch (error) {
1828+
this.logger.warn("Failed to reload resume state for retry", {
1829+
error: getErrorMessage(error),
1830+
});
1831+
return false;
1832+
}
1833+
}
1834+
if (!this.resumeState?.conversation.length) return false;
1835+
1836+
this.logger.warn(
1837+
"Resume prompt exceeded the context window; retrying on a fresh session with summarized history",
1838+
{ taskId: payload.task_id, runId: payload.run_id },
1839+
);
1840+
1841+
try {
1842+
const response = await this.session.clientConnection.newSession({
1843+
cwd: this.config.repositoryPath ?? "/tmp/workspace",
1844+
mcpServers: this.config.mcpServers ?? [],
1845+
_meta: this.session.sessionMeta,
1846+
});
1847+
this.session.acpSessionId = response.sessionId;
1848+
} catch (error) {
1849+
this.logger.warn("Failed to start fresh session for oversized resume", {
1850+
error: getErrorMessage(error),
1851+
});
1852+
return false;
1853+
}
1854+
1855+
try {
1856+
await this.sendResumeMessage(payload, taskRun);
1857+
return true;
1858+
} finally {
1859+
this.resumeState = null;
1860+
this.nativeResume = null;
1861+
}
1862+
}
1863+
17991864
private async runResumeTurn(
18001865
payload: JwtPayload,
18011866
taskRun: TaskRun | null,
18021867
logLabel: string,
18031868
buildPrompt: () => Promise<BuiltPrompt>,
1869+
opts: { retryOnOversizedPrompt?: boolean } = {},
18041870
): Promise<void> {
18051871
if (!this.session) return;
18061872

@@ -1819,6 +1885,10 @@ export class AgentServer {
18191885
stopReason: result.stopReason,
18201886
});
18211887

1888+
// Kept until the turn succeeds so a prompt-too-long retry can reuse it.
1889+
this.resumeState = null;
1890+
this.nativeResume = null;
1891+
18221892
await this.clearPendingInitialPromptState(payload, taskRun);
18231893

18241894
if (result.stopReason === "end_turn") {
@@ -1836,6 +1906,13 @@ export class AgentServer {
18361906
if (this.session) {
18371907
await this.session.logWriter.flushAll();
18381908
}
1909+
if (
1910+
opts.retryOnOversizedPrompt &&
1911+
isPromptTooLongError(error) &&
1912+
(await this.retryOversizedResumeOnFreshSession(payload, taskRun))
1913+
) {
1914+
return;
1915+
}
18391916
await this.handleTurnFailure(payload, "resume", error);
18401917
}
18411918
}

0 commit comments

Comments
 (0)