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 <264332276+ArthurNie@users.noreply.github.com>
This commit is contained in:
ArthurNie
2026-06-01 02:48:49 +08:00
committed by GitHub
parent 3ff86f3350
commit 9d54285b0d
10 changed files with 299 additions and 86 deletions
@@ -26,7 +26,7 @@ export function classifyCompactionReason(reason?: string): string {
if (!text) {
return "unknown";
}
if (text.includes("nothing to compact")) {
if (text.includes("nothing to compact") || text.includes("no real conversation messages")) {
return "no_compactable_entries";
}
// Backends use both phrases for the same harmless state: the transcript is
@@ -2164,6 +2164,46 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => {
expect(hookRunner.runAfterCompaction).not.toHaveBeenCalled();
});
it("forces engine-owned compaction for preflight-required budget compaction", async () => {
const result = await compactEmbeddedAgentSession(
wrappedCompactionArgs({
trigger: "budget",
forcePreflight: true,
preflightRequired: true,
preflightCompactionTrigger: "transcript_bytes",
}),
);
expect(result.ok).toBe(true);
const compactArg = mockCallArg(contextEngineCompactMock) as {
runtimeContext?: Record<string, unknown>;
};
expectRecordFields(compactArg, {
compactionTarget: "budget",
force: true,
});
expectRecordFields(compactArg.runtimeContext, {
forceReason: "preflight_required",
preflightCompactionTrigger: "transcript_bytes",
});
});
it("continues forcing engine-owned manual compaction with manual force reason", async () => {
const result = await compactEmbeddedAgentSession(wrappedCompactionArgs({ trigger: "manual" }));
expect(result.ok).toBe(true);
const compactArg = mockCallArg(contextEngineCompactMock) as {
runtimeContext?: Record<string, unknown>;
};
expectRecordFields(compactArg, {
compactionTarget: "threshold",
force: true,
});
expectRecordFields(compactArg.runtimeContext, {
forceReason: "manual",
});
});
it("threads the caller abort signal into the engine compact() call", async () => {
const controller = new AbortController();
@@ -363,8 +363,21 @@ export async function compactEmbeddedAgentSession(
currentTokenCount: params.currentTokenCount,
compactionTarget: params.trigger === "manual" ? "threshold" : "budget",
customInstructions: params.customInstructions,
force: params.trigger === "manual",
runtimeContext,
force:
params.force === true ||
params.forcePreflight === true ||
params.preflightRequired === true ||
params.trigger === "manual",
runtimeContext: {
...runtimeContext,
forceReason:
params.forcePreflight === true || params.preflightRequired === true
? "preflight_required"
: params.trigger === "manual"
? "manual"
: undefined,
preflightCompactionTrigger: params.preflightCompactionTrigger,
},
},
resolveCompactionTimeoutMs(params.config),
params.abortSignal,
@@ -66,6 +66,12 @@ export type CompactEmbeddedAgentSessionParams = {
customInstructions?: string;
tokenBudget?: number;
force?: boolean;
/** Force compaction because the caller already determined this turn must compact before prompt submission. */
forcePreflight?: boolean;
/** Alias for forcePreflight used by preflight budget gates. */
preflightRequired?: boolean;
/** Diagnostic trigger that made preflight compaction mandatory. */
preflightCompactionTrigger?: "tokens" | "transcript_bytes";
trigger?: "budget" | "overflow" | "manual";
/**
* Preflight callers can allow native/current-session harness compaction but
@@ -306,6 +306,29 @@ describe("runReplyAgent runtime config", () => {
expect(metadata?.deliverDespiteSourceReplySuppression).toBe(true);
});
it("surfaces preflight compaction failures before the agent starts", async () => {
const { replyParams } = createDirectRuntimeReplyParams({
shouldFollowup: false,
isActive: false,
});
runPreflightCompactionIfNeededMock.mockRejectedValue(
new Error("Preflight compaction required but failed: auth profile mismatch"),
);
runMemoryFlushIfNeededMock.mockResolvedValue(undefined);
const result = await runReplyAgent(replyParams);
if (!result || Array.isArray(result)) {
throw new Error("expected a single preflight compaction failure reply payload");
}
expect(result.text).toContain("Context is too large");
expect(result.text).toContain("auto-compaction could not recover");
expect(result.text).toContain("/compact");
expect(result.text).toContain("/new");
const metadata = getReplyPayloadMetadata(result);
expect(metadata?.deliverDespiteSourceReplySuppression).toBe(true);
});
it("does not resolve secrets before the enqueue-followup queue path", async () => {
const { followupRun, resolvedQueue, replyParams } = createDirectRuntimeReplyParams({
shouldFollowup: true,
@@ -625,6 +625,7 @@ function collapseRepeatedFailureDetail(message: string): string {
const SAFE_MISSING_API_KEY_PROVIDERS = new Set(["anthropic", "google", "openai"]);
const EXTERNAL_RUN_FAILURE_DETAIL_MAX_CHARS = 900;
const AGENT_FAILED_BEFORE_REPLY_TEXT = "Agent failed before reply:";
const PREFLIGHT_COMPACTION_FAILURE_PREFIX = "Preflight compaction required but failed:";
type ExternalRunFailureReply = {
text: string;
@@ -692,6 +693,27 @@ function buildCodexAppServerFailureText(message: string): string | null {
return null;
}
export function buildPreflightCompactionFailureText(
message: string,
options?: { includeDetails?: boolean },
): string | null {
const normalizedMessage = collapseRepeatedFailureDetail(message);
if (!normalizedMessage.startsWith(PREFLIGHT_COMPACTION_FAILURE_PREFIX)) {
return null;
}
const reason = sanitizeUserFacingText(
normalizedMessage.slice(PREFLIGHT_COMPACTION_FAILURE_PREFIX.length),
{ errorContext: true },
)
.trim()
.replace(/\s+/gu, " ");
const reasonSuffix = options?.includeDetails && reason ? ` Reason: ${reason}.` : "";
return (
"⚠️ Context is too large and auto-compaction could not recover this turn." +
`${reasonSuffix} Try again, use /compact, or use /new to start a fresh session.`
);
}
function buildCliBackendTimeoutFailureText(message: string): string | null {
const normalizedMessage = collapseRepeatedFailureDetail(message);
const stall = normalizedMessage.match(CLI_BACKEND_NO_OUTPUT_STALL_RE);
@@ -820,6 +842,20 @@ export function buildKnownAgentRunFailureReplyPayload(params: {
});
}
const preflightCompactionFailureText = buildPreflightCompactionFailureText(message, {
includeDetails: isVerboseFailureDetailEnabled(params.resolvedVerboseLevel),
});
if (preflightCompactionFailureText) {
return markAgentRunFailureReplyPayload({
text: resolveExternalRunFailureTextForConversation({
text: preflightCompactionFailureText,
sessionCtx: params.sessionCtx,
isGenericRunnerFailure: false,
cfg: params.cfg,
}),
});
}
const isPureTransientSummary = isFallbackSummary
? isPureTransientRateLimitSummary(params.err)
: false;
@@ -106,6 +106,10 @@ type CompactEmbeddedAgentSessionParams = {
sandboxSessionKey?: string;
currentTokenCount?: number;
cwd?: string;
force?: boolean;
forcePreflight?: boolean;
preflightRequired?: boolean;
preflightCompactionTrigger?: string;
sessionFile?: string;
sessionId?: string;
trigger?: string;
@@ -999,6 +1003,10 @@ describe("runMemoryFlushIfNeeded", () => {
expect(compactEmbeddedAgentSessionMock).toHaveBeenCalledTimes(1);
expect(requireCompactEmbeddedAgentSessionCall()).toMatchObject({
trigger: "budget",
force: true,
forcePreflight: true,
preflightRequired: true,
preflightCompactionTrigger: "tokens",
deferOwningContextEngineCompaction: false,
contextTokenBudget: 100,
});
@@ -1112,7 +1120,7 @@ describe("runMemoryFlushIfNeeded", () => {
["stale_thread_binding", "thread not found: <codex-thread-id>"],
["missing_thread_binding", "no thread binding for session"],
])(
"continues after recoverable native harness %s failure during preflight compaction",
"fails required preflight compaction after native harness %s failure",
async (failureReason, reason) => {
const sessionFile = path.join(rootDir, "session.jsonl");
await fs.writeFile(
@@ -1143,30 +1151,31 @@ describe("runMemoryFlushIfNeeded", () => {
};
const sessionStore = { "agent:main:telegram:group:redacted": sessionEntry };
const entry = await runPreflightCompactionIfNeeded({
cfg: { agents: { defaults: { compaction: { memoryFlush: {} } } } },
followupRun: createTestFollowupRun({
sessionId: "session",
sessionFile,
await expect(
runPreflightCompactionIfNeeded({
cfg: { agents: { defaults: { compaction: { memoryFlush: {} } } } },
followupRun: createTestFollowupRun({
sessionId: "session",
sessionFile,
sessionKey: "agent:main:telegram:group:redacted",
}),
defaultModel: "anthropic/claude-opus-4-6",
agentCfgContextTokens: 100,
sessionEntry,
sessionStore,
sessionKey: "agent:main:telegram:group:redacted",
storePath: path.join(rootDir, "sessions.json"),
isHeartbeat: false,
replyOperation: createReplyOperation(),
}),
defaultModel: "anthropic/claude-opus-4-6",
agentCfgContextTokens: 100,
sessionEntry,
sessionStore,
sessionKey: "agent:main:telegram:group:redacted",
storePath: path.join(rootDir, "sessions.json"),
isHeartbeat: false,
replyOperation: createReplyOperation(),
});
).rejects.toThrow(`Preflight compaction required but failed: ${reason}`);
expect(entry).toBe(sessionEntry);
expect(compactEmbeddedAgentSessionMock).toHaveBeenCalledTimes(1);
expect(incrementCompactionCountMock).not.toHaveBeenCalled();
},
);
it("continues after an unstructured thread-not-found preflight compaction failure", async () => {
it("fails required preflight compaction after an unstructured thread-not-found failure", async () => {
const sessionFile = path.join(rootDir, "session.jsonl");
await fs.writeFile(
sessionFile,
@@ -1195,24 +1204,27 @@ describe("runMemoryFlushIfNeeded", () => {
};
const sessionStore = { "agent:main:telegram:group:redacted": sessionEntry };
const entry = await runPreflightCompactionIfNeeded({
cfg: { agents: { defaults: { compaction: { memoryFlush: {} } } } },
followupRun: createTestFollowupRun({
sessionId: "session",
sessionFile,
await expect(
runPreflightCompactionIfNeeded({
cfg: { agents: { defaults: { compaction: { memoryFlush: {} } } } },
followupRun: createTestFollowupRun({
sessionId: "session",
sessionFile,
sessionKey: "agent:main:telegram:group:redacted",
}),
defaultModel: "anthropic/claude-opus-4-6",
agentCfgContextTokens: 100,
sessionEntry,
sessionStore,
sessionKey: "agent:main:telegram:group:redacted",
storePath: path.join(rootDir, "sessions.json"),
isHeartbeat: false,
replyOperation: createReplyOperation(),
}),
defaultModel: "anthropic/claude-opus-4-6",
agentCfgContextTokens: 100,
sessionEntry,
sessionStore,
sessionKey: "agent:main:telegram:group:redacted",
storePath: path.join(rootDir, "sessions.json"),
isHeartbeat: false,
replyOperation: createReplyOperation(),
});
).rejects.toThrow(
"Preflight compaction required but failed: thread not found: <codex-thread-id>",
);
expect(entry).toBe(sessionEntry);
expect(compactEmbeddedAgentSessionMock).toHaveBeenCalledTimes(1);
expect(incrementCompactionCountMock).not.toHaveBeenCalled();
});
@@ -1469,7 +1481,7 @@ describe("runMemoryFlushIfNeeded", () => {
expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1);
});
it("continues when preflight compaction returns a successful no-op", async () => {
it("fails when required preflight compaction returns an unknown successful no-op", async () => {
compactEmbeddedAgentSessionMock.mockResolvedValueOnce({
ok: true,
compacted: false,
@@ -1485,23 +1497,24 @@ describe("runMemoryFlushIfNeeded", () => {
const sessionStore = { main: sessionEntry };
const replyOperation = createReplyOperation();
const entry = await runPreflightCompactionIfNeeded({
cfg: { agents: { defaults: { compaction: { memoryFlush: {} } } } },
followupRun: createTestFollowupRun({
sessionId: "session",
await expect(
runPreflightCompactionIfNeeded({
cfg: { agents: { defaults: { compaction: { memoryFlush: {} } } } },
followupRun: createTestFollowupRun({
sessionId: "session",
sessionKey: "main",
}),
defaultModel: "anthropic/claude-opus-4-6",
agentCfgContextTokens: 200_000,
sessionEntry,
sessionStore,
sessionKey: "main",
storePath: path.join(rootDir, "sessions.json"),
isHeartbeat: false,
replyOperation,
}),
defaultModel: "anthropic/claude-opus-4-6",
agentCfgContextTokens: 200_000,
sessionEntry,
sessionStore,
sessionKey: "main",
storePath: path.join(rootDir, "sessions.json"),
isHeartbeat: false,
replyOperation,
});
).rejects.toThrow("Preflight compaction required but failed: plugin already stored this turn");
expect(entry).toBe(sessionEntry);
expect(compactEmbeddedAgentSessionMock).toHaveBeenCalledTimes(1);
const compactCall = requireCompactEmbeddedAgentSessionCall();
expect(compactCall.contextTokenBudget).toBe(200_000);
+11 -23
View File
@@ -7,11 +7,7 @@ import {
} from "@openclaw/normalization-core/string-coerce";
import { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-budget.js";
import { estimateMessagesTokens } from "../../agents/compaction.js";
import {
classifyCompactionReason,
DEFERRED_CONTEXT_ENGINE_COMPACTION_REASON,
} from "../../agents/embedded-agent-runner/compact-reasons.js";
import { isRecoverableNativeHarnessBindingFailure } from "../../agents/harness/compaction-recovery.js";
import { classifyCompactionReason } from "../../agents/embedded-agent-runner/compact-reasons.js";
import { resolveAgentHarnessPolicy } from "../../agents/harness/policy.js";
import { ensureSelectedAgentHarnessPlugin } from "../../agents/harness/runtime-plugin.js";
import { runWithModelFallback } from "../../agents/model-fallback.js";
@@ -189,10 +185,6 @@ function isPreflightCompactionSkipReason(reason?: string): boolean {
);
}
function isDeferredPreflightCompactionReason(reason?: string): boolean {
return normalizeOptionalString(reason) === DEFERRED_CONTEXT_ENGINE_COMPACTION_REASON;
}
function resolveMemoryFlushModelFallbackOptions(
run: FollowupRun["run"],
model?: string,
@@ -893,6 +885,10 @@ export async function runPreflightCompactionIfNeeded(params: {
thinkLevel: params.followupRun.run.thinkLevel,
bashElevated: params.followupRun.run.bashElevated,
trigger: "budget",
force: true,
forcePreflight: true,
preflightRequired: true,
preflightCompactionTrigger: compactionTrigger,
deferOwningContextEngineCompaction: false,
contextTokenBudget: contextWindowTokens,
currentTokenCount: tokenCountForCompaction ?? freshPersistedTokens,
@@ -907,25 +903,17 @@ export async function runPreflightCompactionIfNeeded(params: {
return entry ?? params.sessionEntry;
}
logVerbose(`preflightCompaction failed: sessionKey=${params.sessionKey} reason=${reason}`);
if (isRecoverableNativeHarnessBindingFailure(result)) {
logVerbose(
`preflightCompaction continuing after recoverable native harness binding failure: sessionKey=${params.sessionKey} reason=${reason}`,
);
return entry ?? params.sessionEntry;
}
throw new Error(`Preflight compaction required but failed: ${reason}`);
}
if (!result.compacted) {
const reason = normalizeOptionalString(result.reason);
if (isDeferredPreflightCompactionReason(reason)) {
logVerbose(`preflightCompaction failed: sessionKey=${params.sessionKey} reason=${reason}`);
throw new Error(`Preflight compaction required but failed: ${reason}`);
const reason = normalizeOptionalString(result.reason) ?? "not_compacted";
if (isPreflightCompactionSkipReason(reason)) {
logVerbose(`preflightCompaction skipped: sessionKey=${params.sessionKey} reason=${reason}`);
return entry ?? params.sessionEntry;
}
logVerbose(
`preflightCompaction skipped: sessionKey=${params.sessionKey} reason=${reason ?? "not_compacted"}`,
);
return entry ?? params.sessionEntry;
logVerbose(`preflightCompaction failed: sessionKey=${params.sessionKey} reason=${reason}`);
throw new Error(`Preflight compaction required but failed: ${reason}`);
}
await deps.incrementCompactionCount({
@@ -744,6 +744,77 @@ describe("createFollowupRunner reply-lane admission", () => {
);
realAgentEvents.resetAgentRunContextForTest();
});
it("routes preflight compaction failures before starting queued followup runs", async () => {
runPreflightCompactionIfNeededMock.mockRejectedValueOnce(
new Error("Preflight compaction required but failed: auth profile mismatch"),
);
const runner = createFollowupRunner({
typing: createMockTypingController(),
typingMode: "instant",
sessionKey: "main",
defaultModel: "anthropic/claude",
});
await runner(
createQueuedRun({
originatingChannel: "discord",
originatingTo: "channel:C1",
originatingAccountId: "acct-1",
originatingThreadId: "thread-1",
originatingChatType: "group",
run: {
messageProvider: "discord",
provider: "anthropic",
model: "claude",
verboseLevel: "off",
sessionKey: "main",
},
}),
);
expect(runEmbeddedAgentMock).not.toHaveBeenCalled();
expect(routeReplyMock).toHaveBeenCalledOnce();
expect(routeReplyMock).toHaveBeenCalledWith(
expect.objectContaining({
channel: "discord",
to: "channel:C1",
accountId: "acct-1",
threadId: "thread-1",
payload: expect.objectContaining({
text: expect.stringContaining("auto-compaction could not recover"),
}),
}),
);
});
it("preserves non-compaction preflight failures for queued followup runs", async () => {
runPreflightCompactionIfNeededMock.mockRejectedValueOnce(new Error("session load failed"));
const runner = createFollowupRunner({
typing: createMockTypingController(),
typingMode: "instant",
sessionKey: "main",
defaultModel: "anthropic/claude",
});
await expect(
runner(
createQueuedRun({
originatingChannel: "discord",
originatingTo: "channel:C1",
run: {
messageProvider: "discord",
provider: "anthropic",
model: "claude",
sessionKey: "main",
},
}),
),
).rejects.toThrow("session load failed");
expect(runEmbeddedAgentMock).not.toHaveBeenCalled();
expect(routeReplyMock).not.toHaveBeenCalled();
});
});
async function normalizeComparablePath(filePath: string): Promise<string> {
+36 -13
View File
@@ -28,6 +28,7 @@ import { formatErrorMessage } from "../../infra/errors.js";
import { defaultRuntime } from "../../runtime.js";
import { shouldPreserveUserFacingSessionStateForInputProvenance } from "../../sessions/input-provenance.js";
import { isInternalMessageChannel } from "../../utils/message-channel.js";
import { markReplyPayloadForSourceSuppressionDelivery } from "../reply-payload.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
import {
clearDroppedCliSessionBinding,
@@ -35,6 +36,7 @@ import {
runCliAgentWithLifecycle,
} from "./agent-runner-cli-dispatch.js";
import {
buildPreflightCompactionFailureText,
resolveRunAfterAutoFallbackPrimaryProbeRecheck,
resolveSessionRuntimeOverrideForProvider,
} from "./agent-runner-execution.js";
@@ -546,19 +548,40 @@ export function createFollowupRunner(params: {
let runResult: Awaited<ReturnType<typeof runEmbeddedAgent>>;
let fallbackProvider = run.provider;
let fallbackModel = run.model;
activeSessionEntry = await runPreflightCompactionIfNeeded({
cfg: runtimeConfig,
followupRun: effectiveQueued,
promptForEstimate: queued.prompt,
defaultModel,
agentCfgContextTokens,
sessionEntry: activeSessionEntry,
sessionStore,
sessionKey: replySessionKey,
storePath,
isHeartbeat: opts?.isHeartbeat === true,
replyOperation,
});
try {
activeSessionEntry = await runPreflightCompactionIfNeeded({
cfg: runtimeConfig,
followupRun: effectiveQueued,
promptForEstimate: queued.prompt,
defaultModel,
agentCfgContextTokens,
sessionEntry: activeSessionEntry,
sessionStore,
sessionKey: replySessionKey,
storePath,
isHeartbeat: opts?.isHeartbeat === true,
replyOperation,
});
} catch (err) {
const message = formatErrorMessage(err);
replyOperation.fail("run_failed", err);
const preflightCompactionFailureText = buildPreflightCompactionFailureText(message, {
includeDetails: run.verboseLevel === "on" || run.verboseLevel === "full",
});
if (preflightCompactionFailureText) {
await sendFollowupPayloads(
[
markReplyPayloadForSourceSuppressionDelivery({
text: preflightCompactionFailureText,
}),
],
effectiveQueued,
{ provider: fallbackProvider, modelId: fallbackModel },
);
return;
}
throw err;
}
if (run.sessionKey) {
const owningSessionId =
activeSessionEntry?.sessionId === run.sessionId