mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix: preserve preflight overflow token counts into recovery budgeting (#101181)
Budget context engine assembly against the reserve and rendered prompt pressure, and carry the preflight estimated prompt tokens, prompt budget, and overflow tokens into the outer overflow recovery loop so compaction engines compact against the prompt OpenClaw actually rendered instead of a minimally over-budget guess.
This commit is contained in:
@@ -2294,6 +2294,41 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => {
|
||||
expect(result.meta.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes preflight prompt estimates into synthetic overflow compaction", async () => {
|
||||
mockedExtractObservedOverflowTokenCount.mockReturnValueOnce(undefined);
|
||||
mockedRunEmbeddedAttempt
|
||||
.mockResolvedValueOnce(
|
||||
makeAttemptResult({
|
||||
promptError: makeOverflowError(),
|
||||
promptErrorSource: "precheck",
|
||||
preflightRecovery: {
|
||||
route: "compact_then_truncate",
|
||||
estimatedPromptTokens: 268138,
|
||||
promptBudgetBeforeReserve: 241616,
|
||||
overflowTokens: 26522,
|
||||
},
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(makeAttemptResult({ promptError: null }));
|
||||
mockedCompactDirect.mockResolvedValueOnce(
|
||||
makeCompactionSuccess({
|
||||
summary: "Compacted session",
|
||||
firstKeptEntryId: "entry-preflight",
|
||||
tokensBefore: 268138,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runEmbeddedAgent(overflowBaseRunParams);
|
||||
|
||||
expectMockCallFields(mockedCompactDirect, {
|
||||
currentTokenCount: 268138,
|
||||
});
|
||||
expectRecordFields(expectMockCallFields(mockedCompactDirect, {}).runtimeContext, {
|
||||
currentTokenCount: 268138,
|
||||
});
|
||||
expect(result.meta.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes minimally over-budget count when overflow text is confirmed but unparseable", async () => {
|
||||
mockedExtractObservedOverflowTokenCount.mockReturnValueOnce(undefined);
|
||||
mockedRunEmbeddedAttempt
|
||||
|
||||
@@ -2674,8 +2674,15 @@ async function runEmbeddedAgentInternal(
|
||||
const errorText = contextOverflowError.text;
|
||||
const msgCount = attempt.messagesSnapshot?.length ?? 0;
|
||||
const observedOverflowTokens = extractObservedOverflowTokenCount(errorText);
|
||||
const preflightEstimatedPromptTokens =
|
||||
typeof preflightRecovery?.estimatedPromptTokens === "number" &&
|
||||
Number.isFinite(preflightRecovery.estimatedPromptTokens) &&
|
||||
preflightRecovery.estimatedPromptTokens > 0
|
||||
? Math.ceil(preflightRecovery.estimatedPromptTokens)
|
||||
: undefined;
|
||||
const overflowTokenCountForCompaction =
|
||||
observedOverflowTokens ??
|
||||
preflightEstimatedPromptTokens ??
|
||||
(ctxInfo.tokens > 0
|
||||
? // Confirmed overflow with an unparseable provider message still carries a
|
||||
// minimally over-budget count for compaction engines and diagnostics.
|
||||
@@ -2687,6 +2694,7 @@ async function runEmbeddedAgentInternal(
|
||||
`messages=${msgCount} sessionFile=${activeSessionFile} ` +
|
||||
`diagId=${overflowDiagId} compactionAttempts=${overflowCompactionAttempts} ` +
|
||||
`observedTokens=${observedOverflowTokens ?? "unknown"} ` +
|
||||
`preflightEstimatedTokens=${preflightEstimatedPromptTokens ?? "unknown"} ` +
|
||||
`compactionTokens=${overflowTokenCountForCompaction ?? "unknown"} ` +
|
||||
`error=${errorText.slice(0, 200)}`,
|
||||
);
|
||||
|
||||
+35
-1
@@ -3023,7 +3023,13 @@ describe("runEmbeddedAttempt context engine mid-turn precheck integration", () =
|
||||
});
|
||||
|
||||
expect(result.promptErrorSource).toBe("precheck");
|
||||
expect(result.preflightRecovery).toEqual({ route: "compact_only", source: "mid-turn" });
|
||||
expect(result.preflightRecovery).toEqual({
|
||||
route: "compact_only",
|
||||
source: "mid-turn",
|
||||
estimatedPromptTokens: 9000,
|
||||
promptBudgetBeforeReserve: 7000,
|
||||
overflowTokens: 2000,
|
||||
});
|
||||
expect(result.messagesSnapshot).toEqual([seedMessage]);
|
||||
});
|
||||
});
|
||||
@@ -3066,6 +3072,34 @@ describe("runEmbeddedAttempt tool-result guard budget wiring", () => {
|
||||
).toBe(1_000_000);
|
||||
});
|
||||
|
||||
it("passes context engines the message budget after reserve and rendered prompt pressure", async () => {
|
||||
const contextEngine = createContextEngineBootstrapAndAssemble();
|
||||
hoisted.compactionReserveTokens = 20_000;
|
||||
|
||||
await createContextEngineAttemptRunner({
|
||||
contextEngine,
|
||||
sessionKey,
|
||||
tempPaths,
|
||||
attemptOverrides: {
|
||||
contextTokenBudget: 100_000,
|
||||
prompt: "current prompt",
|
||||
transcriptPrompt: "current prompt",
|
||||
},
|
||||
});
|
||||
|
||||
const assembleParams = mockParams(
|
||||
contextEngine.assemble as MockCallSource,
|
||||
0,
|
||||
"assemble params",
|
||||
);
|
||||
expect(assembleParams.tokenBudget).toBeLessThan(80_000);
|
||||
expect(assembleParams.runtimeSettings).toMatchObject({
|
||||
limits: {
|
||||
maxOutputTokens: 20_000,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the cacheable prefix while bounding current prompt results", async () => {
|
||||
const toolText = "process output ".repeat(70);
|
||||
const sessionMessages: AgentMessage[] = [{ role: "user", content: "seed", timestamp: 1 }];
|
||||
|
||||
@@ -96,6 +96,7 @@ type AttemptSpawnWorkspaceHoisted = {
|
||||
>;
|
||||
limitHistoryTurnsMock: Mock<<T>(messages: T, limit: number | undefined) => T>;
|
||||
preemptiveCompactionCalls: Parameters<ShouldPreemptivelyCompactBeforePromptFn>[0][];
|
||||
compactionReserveTokens: number;
|
||||
systemPromptTexts: string[];
|
||||
embeddedSystemPromptInputs: unknown[];
|
||||
sessionManager: SessionManagerMocks;
|
||||
@@ -201,6 +202,7 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => {
|
||||
(messages) => messages,
|
||||
);
|
||||
const preemptiveCompactionCalls: Parameters<ShouldPreemptivelyCompactBeforePromptFn>[0][] = [];
|
||||
const compactionReserveTokens = 0;
|
||||
const systemPromptTexts: string[] = [];
|
||||
const embeddedSystemPromptInputs: unknown[] = [];
|
||||
const sessionManager = {
|
||||
@@ -245,6 +247,7 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => {
|
||||
getHistoryLimitFromSessionKeyMock,
|
||||
limitHistoryTurnsMock,
|
||||
preemptiveCompactionCalls,
|
||||
compactionReserveTokens,
|
||||
systemPromptTexts,
|
||||
embeddedSystemPromptInputs,
|
||||
sessionManager,
|
||||
@@ -437,7 +440,7 @@ vi.mock("../../docs-path.js", () => ({
|
||||
vi.mock("../../agent-project-settings.js", () => ({
|
||||
createPreparedEmbeddedAgentSettingsManager: () => ({
|
||||
reload: async () => {},
|
||||
getCompactionReserveTokens: () => 0,
|
||||
getCompactionReserveTokens: () => hoisted.compactionReserveTokens,
|
||||
getCompactionKeepRecentTokens: () => 40_000,
|
||||
getDefaultProvider: () => undefined,
|
||||
getDefaultModel: () => undefined,
|
||||
@@ -1033,6 +1036,7 @@ export function resetEmbeddedAttemptHarness(
|
||||
hoisted.getHistoryLimitFromSessionKeyMock.mockReset().mockReturnValue(undefined);
|
||||
hoisted.limitHistoryTurnsMock.mockReset().mockImplementation((messages) => messages);
|
||||
hoisted.preemptiveCompactionCalls.length = 0;
|
||||
hoisted.compactionReserveTokens = 0;
|
||||
hoisted.systemPromptTexts.length = 0;
|
||||
hoisted.embeddedSystemPromptInputs.length = 0;
|
||||
hoisted.sessionManager.getLeafEntry.mockReset().mockReturnValue(null);
|
||||
|
||||
@@ -500,6 +500,7 @@ import {
|
||||
PREEMPTIVE_OVERFLOW_ERROR_TEXT,
|
||||
buildPrePromptContextBudgetStatus,
|
||||
estimateLlmBoundaryTokenPressure,
|
||||
estimateRenderedLlmBoundaryTokenPressure,
|
||||
formatPrePromptPrecheckLog,
|
||||
shouldPreemptivelyCompactBeforePrompt,
|
||||
} from "./preemptive-compaction.js";
|
||||
@@ -510,6 +511,22 @@ import {
|
||||
} from "./runtime-context-prompt.js";
|
||||
import type { EmbeddedRunAttemptParams, EmbeddedRunAttemptResult } from "./types.js";
|
||||
|
||||
type PreflightRecoveryBudgetSnapshot = Pick<
|
||||
MidTurnPrecheckRequest,
|
||||
"estimatedPromptTokens" | "promptBudgetBeforeReserve" | "overflowTokens"
|
||||
>;
|
||||
|
||||
// Carries the measured prompt budget into the outer recovery loop. The synthetic
|
||||
// precheck error is only a routing signal, so compaction engines need these
|
||||
// fields to compact against the prompt OpenClaw actually rendered.
|
||||
function buildPreflightRecoveryBudgetSnapshot(snapshot: PreflightRecoveryBudgetSnapshot) {
|
||||
return {
|
||||
estimatedPromptTokens: snapshot.estimatedPromptTokens,
|
||||
promptBudgetBeforeReserve: snapshot.promptBudgetBeforeReserve,
|
||||
overflowTokens: snapshot.overflowTokens,
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
appendAttemptCacheTtlIfNeeded,
|
||||
composeSystemPromptWithHookContext,
|
||||
@@ -3334,15 +3351,42 @@ export async function runEmbeddedAttempt(
|
||||
// history in place would otherwise leave the precheck reading
|
||||
// already-windowed messages instead of the true pre-assembly state.
|
||||
const preassemblyContextEngineMessagesForPrecheck = activeSession.messages.slice();
|
||||
const contextEngineAssembleReserveTokens = Math.max(
|
||||
0,
|
||||
Math.floor(settingsManager.getCompactionReserveTokens()),
|
||||
);
|
||||
const contextEngineAssembleContextTokenBudget = Math.max(
|
||||
1,
|
||||
Math.floor(
|
||||
params.contextTokenBudget ??
|
||||
params.model.contextWindow ??
|
||||
params.model.maxTokens ??
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
),
|
||||
);
|
||||
const contextEngineAssemblePromptBudget = Math.max(
|
||||
1,
|
||||
contextEngineAssembleContextTokenBudget - contextEngineAssembleReserveTokens,
|
||||
);
|
||||
const contextEngineAssembleRenderedPromptTokens =
|
||||
estimateRenderedLlmBoundaryTokenPressure({
|
||||
systemPrompt: systemPromptText,
|
||||
prompt: params.prompt ?? "",
|
||||
});
|
||||
const contextEngineAssembleMessageBudget = Math.max(
|
||||
1,
|
||||
contextEngineAssemblePromptBudget - contextEngineAssembleRenderedPromptTokens,
|
||||
);
|
||||
const assembled = await assembleAttemptContextEngine({
|
||||
contextEngine: activeContextEngine,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
messages: activeSession.messages,
|
||||
tokenBudget: params.contextTokenBudget,
|
||||
tokenBudget: contextEngineAssembleMessageBudget,
|
||||
availableTools: new Set(capabilityToolNames),
|
||||
citationsMode: params.config?.memory?.citations,
|
||||
modelId: params.modelId,
|
||||
maxOutputTokens: contextEngineAssembleReserveTokens,
|
||||
contextEngineHostSupport: OPENCLAW_EMBEDDED_CONTEXT_ENGINE_HOST,
|
||||
providerId: params.provider,
|
||||
requestedModelId: params.requestedModelId,
|
||||
@@ -3911,6 +3955,7 @@ export async function runEmbeddedAttempt(
|
||||
preflightRecovery = {
|
||||
route: "truncate_tool_results_only",
|
||||
source: "mid-turn",
|
||||
...buildPreflightRecoveryBudgetSnapshot(request),
|
||||
handled: true,
|
||||
truncatedCount: truncationResult.truncatedCount,
|
||||
};
|
||||
@@ -3921,7 +3966,11 @@ export async function runEmbeddedAttempt(
|
||||
`handled=true truncatedCount=${truncationResult.truncatedCount}`,
|
||||
);
|
||||
} else {
|
||||
preflightRecovery = { route: "compact_only", source: "mid-turn" };
|
||||
preflightRecovery = {
|
||||
route: "compact_only",
|
||||
source: "mid-turn",
|
||||
...buildPreflightRecoveryBudgetSnapshot(request),
|
||||
};
|
||||
promptError = new Error(PREEMPTIVE_OVERFLOW_ERROR_TEXT);
|
||||
promptErrorSource = "precheck";
|
||||
logMidTurnPrecheck(
|
||||
@@ -3930,7 +3979,11 @@ export async function runEmbeddedAttempt(
|
||||
);
|
||||
}
|
||||
} else {
|
||||
preflightRecovery = { route: request.route, source: "mid-turn" };
|
||||
preflightRecovery = {
|
||||
route: request.route,
|
||||
source: "mid-turn",
|
||||
...buildPreflightRecoveryBudgetSnapshot(request),
|
||||
};
|
||||
promptError = new Error(PREEMPTIVE_OVERFLOW_ERROR_TEXT);
|
||||
promptErrorSource = "precheck";
|
||||
logMidTurnPrecheck(request.route);
|
||||
@@ -4761,6 +4814,7 @@ export async function runEmbeddedAttempt(
|
||||
if (truncationResult.truncated) {
|
||||
preflightRecovery = {
|
||||
route: "truncate_tool_results_only",
|
||||
...buildPreflightRecoveryBudgetSnapshot(preemptiveCompaction),
|
||||
handled: true,
|
||||
truncatedCount: truncationResult.truncatedCount,
|
||||
};
|
||||
@@ -4783,7 +4837,10 @@ export async function runEmbeddedAttempt(
|
||||
`${params.provider}/${params.modelId}; falling back to compaction ` +
|
||||
`reason=${truncationResult.reason ?? "unknown"} sessionFile=${params.sessionFile}`,
|
||||
);
|
||||
preflightRecovery = { route: "compact_only" };
|
||||
preflightRecovery = {
|
||||
route: "compact_only",
|
||||
...buildPreflightRecoveryBudgetSnapshot(preemptiveCompaction),
|
||||
};
|
||||
promptError = new Error(PREEMPTIVE_OVERFLOW_ERROR_TEXT);
|
||||
promptErrorSource = "precheck";
|
||||
skipPromptSubmission = true;
|
||||
@@ -4792,8 +4849,14 @@ export async function runEmbeddedAttempt(
|
||||
if (preemptiveCompaction?.shouldCompact) {
|
||||
preflightRecovery =
|
||||
preemptiveCompaction.route === "compact_then_truncate"
|
||||
? { route: "compact_then_truncate" }
|
||||
: { route: "compact_only" };
|
||||
? {
|
||||
route: "compact_then_truncate",
|
||||
...buildPreflightRecoveryBudgetSnapshot(preemptiveCompaction),
|
||||
}
|
||||
: {
|
||||
route: "compact_only",
|
||||
...buildPreflightRecoveryBudgetSnapshot(preemptiveCompaction),
|
||||
};
|
||||
promptError = new Error(PREEMPTIVE_OVERFLOW_ERROR_TEXT);
|
||||
promptErrorSource = "precheck";
|
||||
log.warn(
|
||||
|
||||
@@ -142,12 +142,18 @@ export type EmbeddedRunAttemptResult = {
|
||||
| {
|
||||
route: Exclude<PreemptiveCompactionRoute, "fits">;
|
||||
source?: "mid-turn";
|
||||
estimatedPromptTokens?: number;
|
||||
promptBudgetBeforeReserve?: number;
|
||||
overflowTokens?: number;
|
||||
handled: true;
|
||||
truncatedCount?: number;
|
||||
}
|
||||
| {
|
||||
route: Exclude<PreemptiveCompactionRoute, "fits">;
|
||||
source?: "mid-turn";
|
||||
estimatedPromptTokens?: number;
|
||||
promptBudgetBeforeReserve?: number;
|
||||
overflowTokens?: number;
|
||||
handled?: false;
|
||||
};
|
||||
sessionIdUsed: string;
|
||||
|
||||
Reference in New Issue
Block a user