Skip to content

Commit 444a093

Browse files
mpz4lifejalehman
andauthored
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 <[email protected]>
1 parent 5836713 commit 444a093

5 files changed

Lines changed: 216 additions & 28 deletions

File tree

docs/concepts/context-engine.md

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -200,13 +200,15 @@ Required members:
200200
<ParamField path="promptAuthority" type='"assembled" | "preassembly_may_overflow"'>
201201
Controls which token estimate the runner uses for preemptive overflow
202202
prechecks. Defaults to `"assembled"`, which means only the assembled
203-
prompt's estimate is checked - appropriate for engines that return a
204-
windowed, self-contained context. Set to `"preassembly_may_overflow"` only
205-
when your assembled view can hide overflow risk in the underlying
206-
transcript; the runner then takes the maximum of the assembled estimate
207-
and the pre-assembly (unwindowed) session-history estimate when deciding
208-
whether to preemptively compact. Either way, the messages you return are
209-
still what the model sees - `promptAuthority` only affects the precheck.
203+
prompt's estimate is checked for engines that do not own compaction.
204+
Engines that set `ownsCompaction: true` manage their own prompt admission,
205+
so OpenClaw skips the generic pre-prompt precheck by default. Set
206+
`"preassembly_may_overflow"` only when your assembled view can hide overflow
207+
risk in the underlying transcript; the runner then keeps the generic
208+
precheck active and takes the maximum of the assembled estimate and the
209+
pre-assembly (unwindowed) session-history estimate when deciding whether to
210+
preemptively compact. Either way, the messages you return are still what the
211+
model sees - `promptAuthority` only affects the precheck.
210212
</ParamField>
211213

212214
`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.
294296

295297
<AccordionGroup>
296298
<Accordion title="ownsCompaction: true">
297-
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.
299+
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()`.
298300
</Accordion>
299301
<Accordion title="ownsCompaction: false or unset">
300302
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.

src/agents/embedded-agent-runner/run.overflow-compaction.loop.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
overflowBaseRunParams as baseParams,
1212
loadRunOverflowCompactionHarness,
1313
mockedCompactDirect,
14+
mockedContextEngine,
1415
mockedIsCompactionFailureError,
1516
mockedIsLikelyContextOverflowError,
1617
mockedLog,
@@ -843,4 +844,48 @@ describe("overflow compaction in run loop", () => {
843844
expect(result.meta.agentMeta?.usage?.input).toBe(4_000);
844845
expect(result.meta.agentMeta?.promptTokens).toBe(2_000);
845846
});
847+
848+
it("recovers from real model overflow when ownsCompaction context engine skips precheck", async () => {
849+
mockedContextEngine.info.ownsCompaction = true;
850+
mockOverflowRetrySuccess({
851+
runEmbeddedAttempt: mockedRunEmbeddedAttempt,
852+
compactDirect: mockedCompactDirect,
853+
});
854+
855+
const result = await runEmbeddedAgent(baseParams);
856+
857+
expect(mockedCompactDirect).toHaveBeenCalledTimes(1);
858+
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2);
859+
expect(result.meta.error).toBeUndefined();
860+
});
861+
862+
it("still handles precheck overflow when ownsCompaction engine uses preassembly_may_overflow", async () => {
863+
mockedContextEngine.info.ownsCompaction = true;
864+
865+
mockedRunEmbeddedAttempt
866+
.mockResolvedValueOnce(
867+
makeAttemptResult({
868+
promptError: makeOverflowError(
869+
"Context overflow: prompt too large for the model (precheck).",
870+
),
871+
promptErrorSource: "precheck",
872+
preflightRecovery: { route: "compact_only" },
873+
}),
874+
)
875+
.mockResolvedValueOnce(makeAttemptResult({ promptError: null }));
876+
877+
mockedCompactDirect.mockResolvedValueOnce(
878+
makeCompactionSuccess({
879+
summary: "Compacted via preassembly overflow guard",
880+
firstKeptEntryId: "entry-5",
881+
tokensBefore: 150000,
882+
}),
883+
);
884+
885+
const result = await runEmbeddedAgent(baseParams);
886+
887+
expect(mockedCompactDirect).toHaveBeenCalledTimes(1);
888+
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2);
889+
expect(result.meta.error).toBeUndefined();
890+
});
846891
});

src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2066,6 +2066,86 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => {
20662066
expect(hoisted.preemptiveCompactionCalls.at(-1)).not.toHaveProperty("unwindowedMessages");
20672067
});
20682068

2069+
it("skips the generic precheck when the context engine owns compaction", async () => {
2070+
let sawPrompt = false;
2071+
const hugeHistory = "large raw history ".repeat(2_000);
2072+
2073+
const result = await createContextEngineAttemptRunner({
2074+
contextEngine: createTestContextEngine({
2075+
info: {
2076+
id: "test-context-engine",
2077+
name: "Test Context Engine",
2078+
version: "0.0.1",
2079+
ownsCompaction: true,
2080+
},
2081+
assemble: async () => ({
2082+
messages: [
2083+
{ role: "user", content: "small assembled context", timestamp: 1 },
2084+
] as AgentMessage[],
2085+
estimatedTokens: 8,
2086+
}),
2087+
}),
2088+
sessionKey,
2089+
tempPaths,
2090+
sessionMessages: [{ role: "user", content: hugeHistory, timestamp: 1 }] as AgentMessage[],
2091+
attemptOverrides: {
2092+
contextTokenBudget: 500,
2093+
},
2094+
sessionPrompt: async (session) => {
2095+
sawPrompt = true;
2096+
session.messages = [
2097+
...session.messages,
2098+
{ role: "assistant", content: "done", timestamp: 2 },
2099+
];
2100+
},
2101+
});
2102+
2103+
expect(sawPrompt).toBe(true);
2104+
expect(result.promptError).toBeNull();
2105+
expect(result.promptErrorSource).toBeNull();
2106+
expect(hoisted.preemptiveCompactionCalls).toHaveLength(0);
2107+
});
2108+
2109+
it("keeps the generic precheck active when owning context engine assembly fails", async () => {
2110+
const lockEvents = trackSessionWriteLocks();
2111+
let sawPrompt = false;
2112+
const hugeHistory = "large raw history ".repeat(2_000);
2113+
2114+
const result = await createContextEngineAttemptRunner({
2115+
contextEngine: createTestContextEngine({
2116+
info: {
2117+
id: "test-context-engine",
2118+
name: "Test Context Engine",
2119+
version: "0.0.1",
2120+
ownsCompaction: true,
2121+
},
2122+
assemble: async () => {
2123+
throw new Error("assembly failed");
2124+
},
2125+
}),
2126+
sessionKey,
2127+
tempPaths,
2128+
sessionMessages: [{ role: "user", content: hugeHistory, timestamp: 1 }] as AgentMessage[],
2129+
attemptOverrides: {
2130+
contextTokenBudget: 500,
2131+
},
2132+
sessionPrompt: async (session) => {
2133+
sawPrompt = true;
2134+
session.messages = [
2135+
...session.messages,
2136+
{ role: "assistant", content: "done", timestamp: 2 },
2137+
];
2138+
},
2139+
});
2140+
2141+
expect(sawPrompt).toBe(false);
2142+
expect(result.promptErrorSource).toBe("precheck");
2143+
expect(result.preflightRecovery?.route).toBe("compact_only");
2144+
expect(hoisted.preemptiveCompactionCalls).toHaveLength(1);
2145+
expect(hoisted.preemptiveCompactionCalls.at(-1)).not.toHaveProperty("unwindowedMessages");
2146+
expectInitialLockReleasedBeforePostTurnWrite(lockEvents);
2147+
});
2148+
20692149
it("repairs tool-result pairing after context engine assembly", async () => {
20702150
let promptMessages: AgentMessage[] = [];
20712151

@@ -2141,6 +2221,50 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => {
21412221
expectInitialLockReleasedBeforePostTurnWrite(lockEvents);
21422222
});
21432223

2224+
it("keeps the preassembly overflow precheck active for owning context engines", async () => {
2225+
const lockEvents = trackSessionWriteLocks();
2226+
let sawPrompt = false;
2227+
const hugeHistory = "large raw history ".repeat(2_000);
2228+
2229+
const result = await createContextEngineAttemptRunner({
2230+
contextEngine: createTestContextEngine({
2231+
info: {
2232+
id: "test-context-engine",
2233+
name: "Test Context Engine",
2234+
version: "0.0.1",
2235+
ownsCompaction: true,
2236+
},
2237+
assemble: async () => ({
2238+
messages: [
2239+
{ role: "user", content: "small assembled context", timestamp: 1 },
2240+
] as AgentMessage[],
2241+
estimatedTokens: 8,
2242+
promptAuthority: "preassembly_may_overflow",
2243+
}),
2244+
}),
2245+
sessionKey,
2246+
tempPaths,
2247+
sessionMessages: [{ role: "user", content: hugeHistory, timestamp: 1 }] as AgentMessage[],
2248+
attemptOverrides: {
2249+
contextTokenBudget: 500,
2250+
},
2251+
sessionPrompt: async (session) => {
2252+
sawPrompt = true;
2253+
session.messages = [
2254+
...session.messages,
2255+
{ role: "assistant", content: "done", timestamp: 2 },
2256+
];
2257+
},
2258+
});
2259+
2260+
expect(sawPrompt).toBe(false);
2261+
expect(result.promptErrorSource).toBe("precheck");
2262+
expect(result.preflightRecovery?.route).toBe("compact_only");
2263+
expect(hoisted.preemptiveCompactionCalls).toHaveLength(1);
2264+
expect(hoisted.preemptiveCompactionCalls.at(-1)).toHaveProperty("unwindowedMessages");
2265+
expectInitialLockReleasedBeforePostTurnWrite(lockEvents);
2266+
});
2267+
21442268
it("snapshots pre-assembly messages before assemble even when the engine windows in place", async () => {
21452269
const hugeHistory = "large raw history ".repeat(2_000);
21462270
const preassemblyMarker = { role: "user", content: hugeHistory, timestamp: 1 } as AgentMessage;

src/agents/embedded-agent-runner/run/attempt.ts

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2577,6 +2577,7 @@ export async function runEmbeddedAttempt(
25772577
let unwindowedContextEngineMessagesForPrecheck: AgentMessage[] | undefined;
25782578
let contextEnginePromptAuthority: NonNullable<AssembleResult["promptAuthority"]> =
25792579
"assembled";
2580+
let contextEngineAssemblySucceeded = false;
25802581
const inFlightPromptSettlePromises = new Set<Promise<void>>();
25812582
const inFlightAbortSettlePromises = new Set<Promise<void>>();
25822583
const trackSettlePromise = (
@@ -3334,6 +3335,7 @@ export async function runEmbeddedAttempt(
33343335
activeSession.agent.state.messages = assembledMessages;
33353336
}
33363337
contextEnginePromptAuthority = assembled.promptAuthority ?? "assembled";
3338+
contextEngineAssemblySucceeded = true;
33373339
if (contextEnginePromptAuthority === "preassembly_may_overflow") {
33383340
unwindowedContextEngineMessagesForPrecheck =
33393341
preassemblyContextEngineMessagesForPrecheck;
@@ -4601,24 +4603,37 @@ export async function runEmbeddedAttempt(
46014603
systemPrompt: systemPromptForHook,
46024604
prompt: llmBoundaryPromptForPrecheck,
46034605
});
4604-
const preemptiveCompaction = skipPromptSubmission
4605-
? null
4606-
: shouldPreemptivelyCompactBeforePrompt({
4607-
messages: hookMessagesForCurrentPrompt,
4608-
...(unwindowedLlmBoundaryMessagesForPrecheck
4609-
? { unwindowedMessages: unwindowedLlmBoundaryMessagesForPrecheck }
4610-
: {}),
4611-
systemPrompt: systemPromptForHook,
4612-
prompt: llmBoundaryPromptForPrecheck,
4613-
contextTokenBudget,
4614-
reserveTokens,
4615-
toolResultMaxChars: promptToolResultMaxChars,
4616-
llmBoundaryTokenPressure: {
4617-
estimatedPromptTokens: llmBoundaryTokenPressure,
4618-
source: "llm_boundary_normalized_prompt",
4619-
renderedChars: llmBoundaryPromptForPrecheck.length,
4620-
},
4621-
});
4606+
let preemptiveCompaction = null;
4607+
const shouldSkipPrecheck =
4608+
skipPromptSubmission ||
4609+
(contextEngineAssemblySucceeded &&
4610+
activeContextEngine?.info.ownsCompaction &&
4611+
contextEnginePromptAuthority !== "preassembly_may_overflow");
4612+
4613+
if (shouldSkipPrecheck && !skipPromptSubmission) {
4614+
log.info(
4615+
`[context-overflow-precheck] skipped: context engine "${activeContextEngine!.info.id}" owns compaction`,
4616+
);
4617+
}
4618+
4619+
if (!shouldSkipPrecheck) {
4620+
preemptiveCompaction = shouldPreemptivelyCompactBeforePrompt({
4621+
messages: hookMessagesForCurrentPrompt,
4622+
...(unwindowedLlmBoundaryMessagesForPrecheck
4623+
? { unwindowedMessages: unwindowedLlmBoundaryMessagesForPrecheck }
4624+
: {}),
4625+
systemPrompt: systemPromptForHook,
4626+
prompt: llmBoundaryPromptForPrecheck,
4627+
contextTokenBudget,
4628+
reserveTokens,
4629+
toolResultMaxChars: promptToolResultMaxChars,
4630+
llmBoundaryTokenPressure: {
4631+
estimatedPromptTokens: llmBoundaryTokenPressure,
4632+
source: "llm_boundary_normalized_prompt",
4633+
renderedChars: llmBoundaryPromptForPrecheck.length,
4634+
},
4635+
});
4636+
}
46224637
if (preemptiveCompaction) {
46234638
contextBudgetStatus = buildPrePromptContextBudgetStatus({
46244639
result: preemptiveCompaction,

src/context-engine/types.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@ export type AssembleResult = {
1414
* preemptive overflow prechecks. The returned `messages` are always the
1515
* prompt sent to the model; this only affects the precheck's token comparison.
1616
*
17-
* - "assembled": the precheck uses only the assembled prompt's estimate.
17+
* - "assembled": the generic precheck uses only the assembled prompt's estimate
18+
* unless the engine owns compaction; owning engines manage prompt admission.
1819
* - "preassembly_may_overflow": the precheck takes the maximum of the
1920
* assembled estimate and the pre-assembly (unwindowed) session-history
2021
* estimate. Engines opt into this when their assembled view can hide an
21-
* overflow that would still affect the underlying transcript.
22+
* overflow that would still affect the underlying transcript. This opt-in
23+
* keeps the generic precheck active even for engines that own compaction.
2224
*
2325
* Defaults to "assembled".
2426
*/

0 commit comments

Comments
 (0)