mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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".
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user