Skip to content

Commit 9d54285

Browse files
authored
fix: force preflight compaction before oversized agent turns
Force required preflight context compaction before oversized turns can enter the agent runtime. Treat required preflight compaction as a hard gate: compact, skip only explicit harmless no-op reasons, or surface a visible recovery message when compaction cannot recover. Fixes #87234. Co-authored-by: ArthurNie <[email protected]>
1 parent 3ff86f3 commit 9d54285

10 files changed

Lines changed: 299 additions & 86 deletions

src/agents/embedded-agent-runner/compact-reasons.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export function classifyCompactionReason(reason?: string): string {
2626
if (!text) {
2727
return "unknown";
2828
}
29-
if (text.includes("nothing to compact")) {
29+
if (text.includes("nothing to compact") || text.includes("no real conversation messages")) {
3030
return "no_compactable_entries";
3131
}
3232
// Backends use both phrases for the same harmless state: the transcript is

src/agents/embedded-agent-runner/compact.hooks.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2164,6 +2164,46 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => {
21642164
expect(hookRunner.runAfterCompaction).not.toHaveBeenCalled();
21652165
});
21662166

2167+
it("forces engine-owned compaction for preflight-required budget compaction", async () => {
2168+
const result = await compactEmbeddedAgentSession(
2169+
wrappedCompactionArgs({
2170+
trigger: "budget",
2171+
forcePreflight: true,
2172+
preflightRequired: true,
2173+
preflightCompactionTrigger: "transcript_bytes",
2174+
}),
2175+
);
2176+
2177+
expect(result.ok).toBe(true);
2178+
const compactArg = mockCallArg(contextEngineCompactMock) as {
2179+
runtimeContext?: Record<string, unknown>;
2180+
};
2181+
expectRecordFields(compactArg, {
2182+
compactionTarget: "budget",
2183+
force: true,
2184+
});
2185+
expectRecordFields(compactArg.runtimeContext, {
2186+
forceReason: "preflight_required",
2187+
preflightCompactionTrigger: "transcript_bytes",
2188+
});
2189+
});
2190+
2191+
it("continues forcing engine-owned manual compaction with manual force reason", async () => {
2192+
const result = await compactEmbeddedAgentSession(wrappedCompactionArgs({ trigger: "manual" }));
2193+
2194+
expect(result.ok).toBe(true);
2195+
const compactArg = mockCallArg(contextEngineCompactMock) as {
2196+
runtimeContext?: Record<string, unknown>;
2197+
};
2198+
expectRecordFields(compactArg, {
2199+
compactionTarget: "threshold",
2200+
force: true,
2201+
});
2202+
expectRecordFields(compactArg.runtimeContext, {
2203+
forceReason: "manual",
2204+
});
2205+
});
2206+
21672207
it("threads the caller abort signal into the engine compact() call", async () => {
21682208
const controller = new AbortController();
21692209

src/agents/embedded-agent-runner/compact.queued.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -363,8 +363,21 @@ export async function compactEmbeddedAgentSession(
363363
currentTokenCount: params.currentTokenCount,
364364
compactionTarget: params.trigger === "manual" ? "threshold" : "budget",
365365
customInstructions: params.customInstructions,
366-
force: params.trigger === "manual",
367-
runtimeContext,
366+
force:
367+
params.force === true ||
368+
params.forcePreflight === true ||
369+
params.preflightRequired === true ||
370+
params.trigger === "manual",
371+
runtimeContext: {
372+
...runtimeContext,
373+
forceReason:
374+
params.forcePreflight === true || params.preflightRequired === true
375+
? "preflight_required"
376+
: params.trigger === "manual"
377+
? "manual"
378+
: undefined,
379+
preflightCompactionTrigger: params.preflightCompactionTrigger,
380+
},
368381
},
369382
resolveCompactionTimeoutMs(params.config),
370383
params.abortSignal,

src/agents/embedded-agent-runner/compact.types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@ export type CompactEmbeddedAgentSessionParams = {
6666
customInstructions?: string;
6767
tokenBudget?: number;
6868
force?: boolean;
69+
/** Force compaction because the caller already determined this turn must compact before prompt submission. */
70+
forcePreflight?: boolean;
71+
/** Alias for forcePreflight used by preflight budget gates. */
72+
preflightRequired?: boolean;
73+
/** Diagnostic trigger that made preflight compaction mandatory. */
74+
preflightCompactionTrigger?: "tokens" | "transcript_bytes";
6975
trigger?: "budget" | "overflow" | "manual";
7076
/**
7177
* Preflight callers can allow native/current-session harness compaction but

src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,29 @@ describe("runReplyAgent runtime config", () => {
306306
expect(metadata?.deliverDespiteSourceReplySuppression).toBe(true);
307307
});
308308

309+
it("surfaces preflight compaction failures before the agent starts", async () => {
310+
const { replyParams } = createDirectRuntimeReplyParams({
311+
shouldFollowup: false,
312+
isActive: false,
313+
});
314+
runPreflightCompactionIfNeededMock.mockRejectedValue(
315+
new Error("Preflight compaction required but failed: auth profile mismatch"),
316+
);
317+
runMemoryFlushIfNeededMock.mockResolvedValue(undefined);
318+
319+
const result = await runReplyAgent(replyParams);
320+
321+
if (!result || Array.isArray(result)) {
322+
throw new Error("expected a single preflight compaction failure reply payload");
323+
}
324+
expect(result.text).toContain("Context is too large");
325+
expect(result.text).toContain("auto-compaction could not recover");
326+
expect(result.text).toContain("/compact");
327+
expect(result.text).toContain("/new");
328+
const metadata = getReplyPayloadMetadata(result);
329+
expect(metadata?.deliverDespiteSourceReplySuppression).toBe(true);
330+
});
331+
309332
it("does not resolve secrets before the enqueue-followup queue path", async () => {
310333
const { followupRun, resolvedQueue, replyParams } = createDirectRuntimeReplyParams({
311334
shouldFollowup: true,

src/auto-reply/reply/agent-runner-execution.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,7 @@ function collapseRepeatedFailureDetail(message: string): string {
625625
const SAFE_MISSING_API_KEY_PROVIDERS = new Set(["anthropic", "google", "openai"]);
626626
const EXTERNAL_RUN_FAILURE_DETAIL_MAX_CHARS = 900;
627627
const AGENT_FAILED_BEFORE_REPLY_TEXT = "Agent failed before reply:";
628+
const PREFLIGHT_COMPACTION_FAILURE_PREFIX = "Preflight compaction required but failed:";
628629

629630
type ExternalRunFailureReply = {
630631
text: string;
@@ -692,6 +693,27 @@ function buildCodexAppServerFailureText(message: string): string | null {
692693
return null;
693694
}
694695

696+
export function buildPreflightCompactionFailureText(
697+
message: string,
698+
options?: { includeDetails?: boolean },
699+
): string | null {
700+
const normalizedMessage = collapseRepeatedFailureDetail(message);
701+
if (!normalizedMessage.startsWith(PREFLIGHT_COMPACTION_FAILURE_PREFIX)) {
702+
return null;
703+
}
704+
const reason = sanitizeUserFacingText(
705+
normalizedMessage.slice(PREFLIGHT_COMPACTION_FAILURE_PREFIX.length),
706+
{ errorContext: true },
707+
)
708+
.trim()
709+
.replace(/\s+/gu, " ");
710+
const reasonSuffix = options?.includeDetails && reason ? ` Reason: ${reason}.` : "";
711+
return (
712+
"⚠️ Context is too large and auto-compaction could not recover this turn." +
713+
`${reasonSuffix} Try again, use /compact, or use /new to start a fresh session.`
714+
);
715+
}
716+
695717
function buildCliBackendTimeoutFailureText(message: string): string | null {
696718
const normalizedMessage = collapseRepeatedFailureDetail(message);
697719
const stall = normalizedMessage.match(CLI_BACKEND_NO_OUTPUT_STALL_RE);
@@ -820,6 +842,20 @@ export function buildKnownAgentRunFailureReplyPayload(params: {
820842
});
821843
}
822844

845+
const preflightCompactionFailureText = buildPreflightCompactionFailureText(message, {
846+
includeDetails: isVerboseFailureDetailEnabled(params.resolvedVerboseLevel),
847+
});
848+
if (preflightCompactionFailureText) {
849+
return markAgentRunFailureReplyPayload({
850+
text: resolveExternalRunFailureTextForConversation({
851+
text: preflightCompactionFailureText,
852+
sessionCtx: params.sessionCtx,
853+
isGenericRunnerFailure: false,
854+
cfg: params.cfg,
855+
}),
856+
});
857+
}
858+
823859
const isPureTransientSummary = isFallbackSummary
824860
? isPureTransientRateLimitSummary(params.err)
825861
: false;

src/auto-reply/reply/agent-runner-memory.test.ts

Lines changed: 60 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,10 @@ type CompactEmbeddedAgentSessionParams = {
106106
sandboxSessionKey?: string;
107107
currentTokenCount?: number;
108108
cwd?: string;
109+
force?: boolean;
110+
forcePreflight?: boolean;
111+
preflightRequired?: boolean;
112+
preflightCompactionTrigger?: string;
109113
sessionFile?: string;
110114
sessionId?: string;
111115
trigger?: string;
@@ -999,6 +1003,10 @@ describe("runMemoryFlushIfNeeded", () => {
9991003
expect(compactEmbeddedAgentSessionMock).toHaveBeenCalledTimes(1);
10001004
expect(requireCompactEmbeddedAgentSessionCall()).toMatchObject({
10011005
trigger: "budget",
1006+
force: true,
1007+
forcePreflight: true,
1008+
preflightRequired: true,
1009+
preflightCompactionTrigger: "tokens",
10021010
deferOwningContextEngineCompaction: false,
10031011
contextTokenBudget: 100,
10041012
});
@@ -1112,7 +1120,7 @@ describe("runMemoryFlushIfNeeded", () => {
11121120
["stale_thread_binding", "thread not found: <codex-thread-id>"],
11131121
["missing_thread_binding", "no thread binding for session"],
11141122
])(
1115-
"continues after recoverable native harness %s failure during preflight compaction",
1123+
"fails required preflight compaction after native harness %s failure",
11161124
async (failureReason, reason) => {
11171125
const sessionFile = path.join(rootDir, "session.jsonl");
11181126
await fs.writeFile(
@@ -1143,30 +1151,31 @@ describe("runMemoryFlushIfNeeded", () => {
11431151
};
11441152
const sessionStore = { "agent:main:telegram:group:redacted": sessionEntry };
11451153

1146-
const entry = await runPreflightCompactionIfNeeded({
1147-
cfg: { agents: { defaults: { compaction: { memoryFlush: {} } } } },
1148-
followupRun: createTestFollowupRun({
1149-
sessionId: "session",
1150-
sessionFile,
1154+
await expect(
1155+
runPreflightCompactionIfNeeded({
1156+
cfg: { agents: { defaults: { compaction: { memoryFlush: {} } } } },
1157+
followupRun: createTestFollowupRun({
1158+
sessionId: "session",
1159+
sessionFile,
1160+
sessionKey: "agent:main:telegram:group:redacted",
1161+
}),
1162+
defaultModel: "anthropic/claude-opus-4-6",
1163+
agentCfgContextTokens: 100,
1164+
sessionEntry,
1165+
sessionStore,
11511166
sessionKey: "agent:main:telegram:group:redacted",
1167+
storePath: path.join(rootDir, "sessions.json"),
1168+
isHeartbeat: false,
1169+
replyOperation: createReplyOperation(),
11521170
}),
1153-
defaultModel: "anthropic/claude-opus-4-6",
1154-
agentCfgContextTokens: 100,
1155-
sessionEntry,
1156-
sessionStore,
1157-
sessionKey: "agent:main:telegram:group:redacted",
1158-
storePath: path.join(rootDir, "sessions.json"),
1159-
isHeartbeat: false,
1160-
replyOperation: createReplyOperation(),
1161-
});
1171+
).rejects.toThrow(`Preflight compaction required but failed: ${reason}`);
11621172

1163-
expect(entry).toBe(sessionEntry);
11641173
expect(compactEmbeddedAgentSessionMock).toHaveBeenCalledTimes(1);
11651174
expect(incrementCompactionCountMock).not.toHaveBeenCalled();
11661175
},
11671176
);
11681177

1169-
it("continues after an unstructured thread-not-found preflight compaction failure", async () => {
1178+
it("fails required preflight compaction after an unstructured thread-not-found failure", async () => {
11701179
const sessionFile = path.join(rootDir, "session.jsonl");
11711180
await fs.writeFile(
11721181
sessionFile,
@@ -1195,24 +1204,27 @@ describe("runMemoryFlushIfNeeded", () => {
11951204
};
11961205
const sessionStore = { "agent:main:telegram:group:redacted": sessionEntry };
11971206

1198-
const entry = await runPreflightCompactionIfNeeded({
1199-
cfg: { agents: { defaults: { compaction: { memoryFlush: {} } } } },
1200-
followupRun: createTestFollowupRun({
1201-
sessionId: "session",
1202-
sessionFile,
1207+
await expect(
1208+
runPreflightCompactionIfNeeded({
1209+
cfg: { agents: { defaults: { compaction: { memoryFlush: {} } } } },
1210+
followupRun: createTestFollowupRun({
1211+
sessionId: "session",
1212+
sessionFile,
1213+
sessionKey: "agent:main:telegram:group:redacted",
1214+
}),
1215+
defaultModel: "anthropic/claude-opus-4-6",
1216+
agentCfgContextTokens: 100,
1217+
sessionEntry,
1218+
sessionStore,
12031219
sessionKey: "agent:main:telegram:group:redacted",
1220+
storePath: path.join(rootDir, "sessions.json"),
1221+
isHeartbeat: false,
1222+
replyOperation: createReplyOperation(),
12041223
}),
1205-
defaultModel: "anthropic/claude-opus-4-6",
1206-
agentCfgContextTokens: 100,
1207-
sessionEntry,
1208-
sessionStore,
1209-
sessionKey: "agent:main:telegram:group:redacted",
1210-
storePath: path.join(rootDir, "sessions.json"),
1211-
isHeartbeat: false,
1212-
replyOperation: createReplyOperation(),
1213-
});
1224+
).rejects.toThrow(
1225+
"Preflight compaction required but failed: thread not found: <codex-thread-id>",
1226+
);
12141227

1215-
expect(entry).toBe(sessionEntry);
12161228
expect(compactEmbeddedAgentSessionMock).toHaveBeenCalledTimes(1);
12171229
expect(incrementCompactionCountMock).not.toHaveBeenCalled();
12181230
});
@@ -1469,7 +1481,7 @@ describe("runMemoryFlushIfNeeded", () => {
14691481
expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1);
14701482
});
14711483

1472-
it("continues when preflight compaction returns a successful no-op", async () => {
1484+
it("fails when required preflight compaction returns an unknown successful no-op", async () => {
14731485
compactEmbeddedAgentSessionMock.mockResolvedValueOnce({
14741486
ok: true,
14751487
compacted: false,
@@ -1485,23 +1497,24 @@ describe("runMemoryFlushIfNeeded", () => {
14851497
const sessionStore = { main: sessionEntry };
14861498
const replyOperation = createReplyOperation();
14871499

1488-
const entry = await runPreflightCompactionIfNeeded({
1489-
cfg: { agents: { defaults: { compaction: { memoryFlush: {} } } } },
1490-
followupRun: createTestFollowupRun({
1491-
sessionId: "session",
1500+
await expect(
1501+
runPreflightCompactionIfNeeded({
1502+
cfg: { agents: { defaults: { compaction: { memoryFlush: {} } } } },
1503+
followupRun: createTestFollowupRun({
1504+
sessionId: "session",
1505+
sessionKey: "main",
1506+
}),
1507+
defaultModel: "anthropic/claude-opus-4-6",
1508+
agentCfgContextTokens: 200_000,
1509+
sessionEntry,
1510+
sessionStore,
14921511
sessionKey: "main",
1512+
storePath: path.join(rootDir, "sessions.json"),
1513+
isHeartbeat: false,
1514+
replyOperation,
14931515
}),
1494-
defaultModel: "anthropic/claude-opus-4-6",
1495-
agentCfgContextTokens: 200_000,
1496-
sessionEntry,
1497-
sessionStore,
1498-
sessionKey: "main",
1499-
storePath: path.join(rootDir, "sessions.json"),
1500-
isHeartbeat: false,
1501-
replyOperation,
1502-
});
1516+
).rejects.toThrow("Preflight compaction required but failed: plugin already stored this turn");
15031517

1504-
expect(entry).toBe(sessionEntry);
15051518
expect(compactEmbeddedAgentSessionMock).toHaveBeenCalledTimes(1);
15061519
const compactCall = requireCompactEmbeddedAgentSessionCall();
15071520
expect(compactCall.contextTokenBudget).toBe(200_000);

0 commit comments

Comments
 (0)