Skip to content

Commit c4cffe3

Browse files
author
FreeBird-ctrl
committed
fix(reply): gate preflight compaction fast-path on token threshold (#63892)
1 parent eec19d5 commit c4cffe3

3 files changed

Lines changed: 103 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ Docs: https://docs.openclaw.ai
109109
- Agents/Gemini: strip orphaned `required` entries from Gemini tool schemas so provider validation no longer rejects tools after schema cleanup or union flattening. (#64284) Thanks @xxxxxmax.
110110
- Assistant text: strip Qwen-style XML tool call payloads from visible replies so web and channel messages no longer show raw `<tool_call><function=...>` output. (#64214) Thanks @MoerAI.
111111
- Daemon/gateway: prevent systemd restart storms on configuration errors by exiting with `EX_CONFIG` and adding generated unit restart-prevention guards. (#63913) Thanks @neo1027144-creator.
112+
- Auto-reply/memory: gate the preflight compaction fast-path on the fresh-token threshold so proactive compaction re-fires on subsequent cycles instead of short-circuiting at `totalTokensFresh === true`; overflow-retry recovery was unaffected. (#63892) Thanks @martingarramon.
112113

113114
## 2026.4.9
114115

src/auto-reply/reply/agent-runner-memory.test.ts

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,18 @@ import {
88
registerMemoryFlushPlanResolver,
99
} from "../../plugins/memory-state.js";
1010
import type { TemplateContext } from "../templating.js";
11-
import { runMemoryFlushIfNeeded, setAgentRunnerMemoryTestDeps } from "./agent-runner-memory.js";
11+
import {
12+
runMemoryFlushIfNeeded,
13+
runPreflightCompactionIfNeeded,
14+
setAgentRunnerMemoryTestDeps,
15+
} from "./agent-runner-memory.js";
1216
import type { FollowupRun } from "./queue.js";
1317

1418
const runWithModelFallbackMock = vi.fn();
1519
const runEmbeddedPiAgentMock = vi.fn();
1620
const refreshQueuedFollowupSessionMock = vi.fn();
1721
const incrementCompactionCountMock = vi.fn();
22+
const compactEmbeddedPiSessionMock = vi.fn();
1823

1924
function createReplyOperation() {
2025
return {
@@ -104,11 +109,13 @@ describe("runMemoryFlushIfNeeded", () => {
104109
}
105110
return nextEntry.compactionCount;
106111
});
112+
compactEmbeddedPiSessionMock.mockReset().mockResolvedValue({ ok: false, reason: "test_skip" });
107113
setAgentRunnerMemoryTestDeps({
108114
runWithModelFallback: runWithModelFallbackMock as never,
109115
runEmbeddedPiAgent: runEmbeddedPiAgentMock as never,
110116
refreshQueuedFollowupSession: refreshQueuedFollowupSessionMock as never,
111117
incrementCompactionCount: incrementCompactionCountMock as never,
118+
compactEmbeddedPiSession: compactEmbeddedPiSessionMock as never,
112119
registerAgentRunContext: vi.fn() as never,
113120
randomUUID: () => "00000000-0000-0000-0000-000000000001",
114121
now: () => 1_700_000_000_000,
@@ -288,3 +295,85 @@ describe("runMemoryFlushIfNeeded", () => {
288295
expect(flushCall.bootstrapPromptWarningSignature).toBe("sig-b");
289296
});
290297
});
298+
299+
describe("runPreflightCompactionIfNeeded", () => {
300+
beforeEach(() => {
301+
registerMemoryFlushPlanResolver(() => ({
302+
softThresholdTokens: 4_000,
303+
forceFlushTranscriptBytes: 1_000_000_000,
304+
reserveTokensFloor: 20_000,
305+
prompt: "flush",
306+
systemPrompt: "flush",
307+
relativePath: "memory/2023-11-14.md",
308+
}));
309+
compactEmbeddedPiSessionMock.mockReset().mockResolvedValue({ ok: false, reason: "test_skip" });
310+
setAgentRunnerMemoryTestDeps({
311+
compactEmbeddedPiSession: compactEmbeddedPiSessionMock as never,
312+
registerAgentRunContext: vi.fn() as never,
313+
});
314+
});
315+
316+
afterEach(() => {
317+
setAgentRunnerMemoryTestDeps();
318+
clearMemoryPluginState();
319+
});
320+
321+
// Regression for #63892: after the first compaction, `totalTokensFresh` is
322+
// set to `true`. The early-return gate used to short-circuit unconditionally
323+
// in that state, so proactive compaction never re-fired once fresh tokens
324+
// grew back past the threshold. The gate must now also check that fresh
325+
// persisted tokens are still below the compaction threshold before skipping.
326+
it("re-runs preflight when totalTokensFresh is true but tokens exceed threshold (#63892)", async () => {
327+
const sessionKey = "main";
328+
const sessionEntry: SessionEntry = {
329+
sessionId: "session",
330+
updatedAt: Date.now(),
331+
totalTokens: 90_000,
332+
totalTokensFresh: true,
333+
compactionCount: 1,
334+
};
335+
336+
await runPreflightCompactionIfNeeded({
337+
cfg: { agents: { defaults: { compaction: {} } } },
338+
followupRun: createFollowupRun(),
339+
defaultModel: "anthropic/claude-opus-4-6",
340+
agentCfgContextTokens: 100_000,
341+
sessionEntry,
342+
sessionStore: { [sessionKey]: sessionEntry },
343+
sessionKey,
344+
isHeartbeat: false,
345+
replyOperation: createReplyOperation(),
346+
});
347+
348+
// threshold = 100_000 - 20_000 - 4_000 = 76_000; 90_000 > 76_000, so the
349+
// early-return must fall through and compaction must be attempted.
350+
expect(compactEmbeddedPiSessionMock).toHaveBeenCalledTimes(1);
351+
});
352+
353+
it("skips preflight when totalTokensFresh is true and tokens are below threshold", async () => {
354+
const sessionKey = "main";
355+
const sessionEntry: SessionEntry = {
356+
sessionId: "session",
357+
updatedAt: Date.now(),
358+
totalTokens: 10_000,
359+
totalTokensFresh: true,
360+
compactionCount: 1,
361+
};
362+
363+
const result = await runPreflightCompactionIfNeeded({
364+
cfg: { agents: { defaults: { compaction: {} } } },
365+
followupRun: createFollowupRun(),
366+
defaultModel: "anthropic/claude-opus-4-6",
367+
agentCfgContextTokens: 100_000,
368+
sessionEntry,
369+
sessionStore: { [sessionKey]: sessionEntry },
370+
sessionKey,
371+
isHeartbeat: false,
372+
replyOperation: createReplyOperation(),
373+
});
374+
375+
// Fresh tokens are below threshold — the fast-path early return must fire.
376+
expect(compactEmbeddedPiSessionMock).not.toHaveBeenCalled();
377+
expect(result).toBe(sessionEntry);
378+
});
379+
});

src/auto-reply/reply/agent-runner-memory.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,16 @@ export async function runPreflightCompactionIfNeeded(params: {
376376
Number.isFinite(persistedTotalTokens) &&
377377
persistedTotalTokens > 0;
378378
const shouldUseTranscriptFallback = entry.totalTokensFresh === false || !hasPersistedTotalTokens;
379-
if (!shouldUseTranscriptFallback) {
379+
const preflightThreshold = contextWindowTokens - reserveTokensFloor - softThresholdTokens;
380+
const freshPersistedTokensBelowThreshold =
381+
typeof freshPersistedTokens === "number" && freshPersistedTokens < preflightThreshold;
382+
// Skip preflight only when fresh persisted tokens are still below the
383+
// compaction threshold. Without this guard, the first compaction sets
384+
// entry.totalTokensFresh = true, and every subsequent run short-circuits
385+
// here without re-checking the (now-growing) token count, so proactive
386+
// compaction never re-fires. Overflow-retry remains unaffected because it
387+
// runs through a separate path in pi-embedded-runner/run.ts. (#63892)
388+
if (!shouldUseTranscriptFallback && freshPersistedTokensBelowThreshold) {
380389
return entry ?? params.sessionEntry;
381390
}
382391
const promptTokenEstimate = estimatePromptTokensForMemoryFlush(
@@ -401,11 +410,10 @@ export async function runPreflightCompactionIfNeeded(params: {
401410
? projectedTokenCount
402411
: undefined;
403412

404-
const threshold = contextWindowTokens - reserveTokensFloor - softThresholdTokens;
405413
logVerbose(
406414
`preflightCompaction check: sessionKey=${params.sessionKey} ` +
407415
`tokenCount=${tokenCountForCompaction ?? freshPersistedTokens ?? "undefined"} ` +
408-
`contextWindow=${contextWindowTokens} threshold=${threshold} ` +
416+
`contextWindow=${contextWindowTokens} threshold=${preflightThreshold} ` +
409417
`isHeartbeat=${params.isHeartbeat} isCli=${isCli} ` +
410418
`persistedFresh=${entry?.totalTokensFresh === true} ` +
411419
`transcriptPromptTokens=${transcriptPromptTokens ?? "undefined"} ` +
@@ -426,7 +434,7 @@ export async function runPreflightCompactionIfNeeded(params: {
426434
logVerbose(
427435
`preflightCompaction triggered: sessionKey=${params.sessionKey} ` +
428436
`tokenCount=${tokenCountForCompaction ?? freshPersistedTokens ?? "undefined"} ` +
429-
`threshold=${threshold}`,
437+
`threshold=${preflightThreshold}`,
430438
);
431439

432440
params.replyOperation.setPhase("preflight_compacting");

0 commit comments

Comments
 (0)