fix(agents): skip pre-prompt precheck when context engine owns compaction (#95342)

* fix(agents): skip pre-prompt precheck when context engine owns compaction

When a context engine advertises ownsCompaction=true (e.g. lossless-claw),
skip the pre-prompt preemptive overflow precheck entirely. The engine
already manages the context budget through assemble() and its own
compaction lifecycle — the built-in precheck is redundant and causes
false-positive overflow errors for CJK-heavy sessions due to its
conservative token estimation formula.

Safety is preserved: if the model's actual context limit is exceeded,
the model API returns an error that the outer overflow-compaction retry
loop handles normally.

* fix(agents): assert non-null context engine in precheck skip log

* test(context-engine): cover owning precheck contract

* fix(agents): preserve precheck after context assembly failure

---------

Co-authored-by: Josh Lehman <josh@martian.engineering>
This commit is contained in:
Kris Wu
2026-06-30 00:55:03 -07:00
committed by GitHub
co-authored by Josh Lehman
parent 58367137ea
commit 444a093593
5 changed files with 216 additions and 28 deletions
+10 -8
View File
@@ -200,13 +200,15 @@ Required members:
<ParamField path="promptAuthority" type='"assembled" | "preassembly_may_overflow"'>
Controls which token estimate the runner uses for preemptive overflow
prechecks. Defaults to `"assembled"`, which means only the assembled
prompt's estimate is checked - appropriate for engines that return a
windowed, self-contained context. Set to `"preassembly_may_overflow"` only
when your assembled view can hide overflow risk in the underlying
transcript; the runner then takes the maximum of the assembled estimate
and the pre-assembly (unwindowed) session-history estimate when deciding
whether to preemptively compact. Either way, the messages you return are
still what the model sees - `promptAuthority` only affects the precheck.
prompt's estimate is checked for engines that do not own compaction.
Engines that set `ownsCompaction: true` manage their own prompt admission,
so OpenClaw skips the generic pre-prompt precheck by default. Set
`"preassembly_may_overflow"` only when your assembled view can hide overflow
risk in the underlying transcript; the runner then keeps the generic
precheck active and takes the maximum of the assembled estimate and the
pre-assembly (unwindowed) session-history estimate when deciding whether to
preemptively compact. Either way, the messages you return are still what the
model sees - `promptAuthority` only affects the precheck.
</ParamField>
`compact` returns a `CompactResult`. When compaction rotates the active
@@ -294,7 +296,7 @@ protects engines that would corrupt state if they ran in an unsupported host.
<AccordionGroup>
<Accordion title="ownsCompaction: true">
The engine owns compaction behavior. OpenClaw disables OpenClaw runtime's built-in auto-compaction for that run, and the engine's `compact()` implementation is responsible for `/compact`, overflow recovery compaction, and any proactive compaction it wants to do in `afterTurn()`. OpenClaw may still run the pre-prompt overflow safeguard; when it predicts the full transcript will overflow, the recovery path calls the active engine's `compact()` before submitting another prompt.
The engine owns compaction behavior. OpenClaw disables OpenClaw runtime's built-in auto-compaction and generic pre-prompt overflow precheck for that run, and the engine's `compact()` implementation is responsible for `/compact`, provider overflow recovery compaction, and any proactive compaction it wants to do in `afterTurn()`. OpenClaw still runs the pre-prompt overflow safeguard when the engine returns `promptAuthority: "preassembly_may_overflow"` from `assemble()`.
</Accordion>
<Accordion title="ownsCompaction: false or unset">
OpenClaw runtime's built-in auto-compaction may still run during prompt execution, but the active engine's `compact()` method is still called for `/compact` and overflow recovery.
@@ -11,6 +11,7 @@ import {
overflowBaseRunParams as baseParams,
loadRunOverflowCompactionHarness,
mockedCompactDirect,
mockedContextEngine,
mockedIsCompactionFailureError,
mockedIsLikelyContextOverflowError,
mockedLog,
@@ -843,4 +844,48 @@ describe("overflow compaction in run loop", () => {
expect(result.meta.agentMeta?.usage?.input).toBe(4_000);
expect(result.meta.agentMeta?.promptTokens).toBe(2_000);
});
it("recovers from real model overflow when ownsCompaction context engine skips precheck", async () => {
mockedContextEngine.info.ownsCompaction = true;
mockOverflowRetrySuccess({
runEmbeddedAttempt: mockedRunEmbeddedAttempt,
compactDirect: mockedCompactDirect,
});
const result = await runEmbeddedAgent(baseParams);
expect(mockedCompactDirect).toHaveBeenCalledTimes(1);
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2);
expect(result.meta.error).toBeUndefined();
});
it("still handles precheck overflow when ownsCompaction engine uses preassembly_may_overflow", async () => {
mockedContextEngine.info.ownsCompaction = true;
mockedRunEmbeddedAttempt
.mockResolvedValueOnce(
makeAttemptResult({
promptError: makeOverflowError(
"Context overflow: prompt too large for the model (precheck).",
),
promptErrorSource: "precheck",
preflightRecovery: { route: "compact_only" },
}),
)
.mockResolvedValueOnce(makeAttemptResult({ promptError: null }));
mockedCompactDirect.mockResolvedValueOnce(
makeCompactionSuccess({
summary: "Compacted via preassembly overflow guard",
firstKeptEntryId: "entry-5",
tokensBefore: 150000,
}),
);
const result = await runEmbeddedAgent(baseParams);
expect(mockedCompactDirect).toHaveBeenCalledTimes(1);
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2);
expect(result.meta.error).toBeUndefined();
});
});
@@ -2066,6 +2066,86 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => {
expect(hoisted.preemptiveCompactionCalls.at(-1)).not.toHaveProperty("unwindowedMessages");
});
it("skips the generic precheck when the context engine owns compaction", async () => {
let sawPrompt = false;
const hugeHistory = "large raw history ".repeat(2_000);
const result = await createContextEngineAttemptRunner({
contextEngine: createTestContextEngine({
info: {
id: "test-context-engine",
name: "Test Context Engine",
version: "0.0.1",
ownsCompaction: true,
},
assemble: async () => ({
messages: [
{ role: "user", content: "small assembled context", timestamp: 1 },
] as AgentMessage[],
estimatedTokens: 8,
}),
}),
sessionKey,
tempPaths,
sessionMessages: [{ role: "user", content: hugeHistory, timestamp: 1 }] as AgentMessage[],
attemptOverrides: {
contextTokenBudget: 500,
},
sessionPrompt: async (session) => {
sawPrompt = true;
session.messages = [
...session.messages,
{ role: "assistant", content: "done", timestamp: 2 },
];
},
});
expect(sawPrompt).toBe(true);
expect(result.promptError).toBeNull();
expect(result.promptErrorSource).toBeNull();
expect(hoisted.preemptiveCompactionCalls).toHaveLength(0);
});
it("keeps the generic precheck active when owning context engine assembly fails", async () => {
const lockEvents = trackSessionWriteLocks();
let sawPrompt = false;
const hugeHistory = "large raw history ".repeat(2_000);
const result = await createContextEngineAttemptRunner({
contextEngine: createTestContextEngine({
info: {
id: "test-context-engine",
name: "Test Context Engine",
version: "0.0.1",
ownsCompaction: true,
},
assemble: async () => {
throw new Error("assembly failed");
},
}),
sessionKey,
tempPaths,
sessionMessages: [{ role: "user", content: hugeHistory, timestamp: 1 }] as AgentMessage[],
attemptOverrides: {
contextTokenBudget: 500,
},
sessionPrompt: async (session) => {
sawPrompt = true;
session.messages = [
...session.messages,
{ role: "assistant", content: "done", timestamp: 2 },
];
},
});
expect(sawPrompt).toBe(false);
expect(result.promptErrorSource).toBe("precheck");
expect(result.preflightRecovery?.route).toBe("compact_only");
expect(hoisted.preemptiveCompactionCalls).toHaveLength(1);
expect(hoisted.preemptiveCompactionCalls.at(-1)).not.toHaveProperty("unwindowedMessages");
expectInitialLockReleasedBeforePostTurnWrite(lockEvents);
});
it("repairs tool-result pairing after context engine assembly", async () => {
let promptMessages: AgentMessage[] = [];
@@ -2141,6 +2221,50 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => {
expectInitialLockReleasedBeforePostTurnWrite(lockEvents);
});
it("keeps the preassembly overflow precheck active for owning context engines", async () => {
const lockEvents = trackSessionWriteLocks();
let sawPrompt = false;
const hugeHistory = "large raw history ".repeat(2_000);
const result = await createContextEngineAttemptRunner({
contextEngine: createTestContextEngine({
info: {
id: "test-context-engine",
name: "Test Context Engine",
version: "0.0.1",
ownsCompaction: true,
},
assemble: async () => ({
messages: [
{ role: "user", content: "small assembled context", timestamp: 1 },
] as AgentMessage[],
estimatedTokens: 8,
promptAuthority: "preassembly_may_overflow",
}),
}),
sessionKey,
tempPaths,
sessionMessages: [{ role: "user", content: hugeHistory, timestamp: 1 }] as AgentMessage[],
attemptOverrides: {
contextTokenBudget: 500,
},
sessionPrompt: async (session) => {
sawPrompt = true;
session.messages = [
...session.messages,
{ role: "assistant", content: "done", timestamp: 2 },
];
},
});
expect(sawPrompt).toBe(false);
expect(result.promptErrorSource).toBe("precheck");
expect(result.preflightRecovery?.route).toBe("compact_only");
expect(hoisted.preemptiveCompactionCalls).toHaveLength(1);
expect(hoisted.preemptiveCompactionCalls.at(-1)).toHaveProperty("unwindowedMessages");
expectInitialLockReleasedBeforePostTurnWrite(lockEvents);
});
it("snapshots pre-assembly messages before assemble even when the engine windows in place", async () => {
const hugeHistory = "large raw history ".repeat(2_000);
const preassemblyMarker = { role: "user", content: hugeHistory, timestamp: 1 } as AgentMessage;
+33 -18
View File
@@ -2577,6 +2577,7 @@ export async function runEmbeddedAttempt(
let unwindowedContextEngineMessagesForPrecheck: AgentMessage[] | undefined;
let contextEnginePromptAuthority: NonNullable<AssembleResult["promptAuthority"]> =
"assembled";
let contextEngineAssemblySucceeded = false;
const inFlightPromptSettlePromises = new Set<Promise<void>>();
const inFlightAbortSettlePromises = new Set<Promise<void>>();
const trackSettlePromise = (
@@ -3334,6 +3335,7 @@ export async function runEmbeddedAttempt(
activeSession.agent.state.messages = assembledMessages;
}
contextEnginePromptAuthority = assembled.promptAuthority ?? "assembled";
contextEngineAssemblySucceeded = true;
if (contextEnginePromptAuthority === "preassembly_may_overflow") {
unwindowedContextEngineMessagesForPrecheck =
preassemblyContextEngineMessagesForPrecheck;
@@ -4601,24 +4603,37 @@ export async function runEmbeddedAttempt(
systemPrompt: systemPromptForHook,
prompt: llmBoundaryPromptForPrecheck,
});
const preemptiveCompaction = skipPromptSubmission
? null
: shouldPreemptivelyCompactBeforePrompt({
messages: hookMessagesForCurrentPrompt,
...(unwindowedLlmBoundaryMessagesForPrecheck
? { unwindowedMessages: unwindowedLlmBoundaryMessagesForPrecheck }
: {}),
systemPrompt: systemPromptForHook,
prompt: llmBoundaryPromptForPrecheck,
contextTokenBudget,
reserveTokens,
toolResultMaxChars: promptToolResultMaxChars,
llmBoundaryTokenPressure: {
estimatedPromptTokens: llmBoundaryTokenPressure,
source: "llm_boundary_normalized_prompt",
renderedChars: llmBoundaryPromptForPrecheck.length,
},
});
let preemptiveCompaction = null;
const shouldSkipPrecheck =
skipPromptSubmission ||
(contextEngineAssemblySucceeded &&
activeContextEngine?.info.ownsCompaction &&
contextEnginePromptAuthority !== "preassembly_may_overflow");
if (shouldSkipPrecheck && !skipPromptSubmission) {
log.info(
`[context-overflow-precheck] skipped: context engine "${activeContextEngine!.info.id}" owns compaction`,
);
}
if (!shouldSkipPrecheck) {
preemptiveCompaction = shouldPreemptivelyCompactBeforePrompt({
messages: hookMessagesForCurrentPrompt,
...(unwindowedLlmBoundaryMessagesForPrecheck
? { unwindowedMessages: unwindowedLlmBoundaryMessagesForPrecheck }
: {}),
systemPrompt: systemPromptForHook,
prompt: llmBoundaryPromptForPrecheck,
contextTokenBudget,
reserveTokens,
toolResultMaxChars: promptToolResultMaxChars,
llmBoundaryTokenPressure: {
estimatedPromptTokens: llmBoundaryTokenPressure,
source: "llm_boundary_normalized_prompt",
renderedChars: llmBoundaryPromptForPrecheck.length,
},
});
}
if (preemptiveCompaction) {
contextBudgetStatus = buildPrePromptContextBudgetStatus({
result: preemptiveCompaction,
+4 -2
View File
@@ -14,11 +14,13 @@ export type AssembleResult = {
* preemptive overflow prechecks. The returned `messages` are always the
* prompt sent to the model; this only affects the precheck's token comparison.
*
* - "assembled": the precheck uses only the assembled prompt's estimate.
* - "assembled": the generic precheck uses only the assembled prompt's estimate
* unless the engine owns compaction; owning engines manage prompt admission.
* - "preassembly_may_overflow": the precheck takes the maximum of the
* assembled estimate and the pre-assembly (unwindowed) session-history
* estimate. Engines opt into this when their assembled view can hide an
* overflow that would still affect the underlying transcript.
* overflow that would still affect the underlying transcript. This opt-in
* keeps the generic precheck active even for engines that own compaction.
*
* Defaults to "assembled".
*/