From 4e651a43f46fd8bff320776c270d99a67991a402 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Jul 2026 00:23:44 -0400 Subject: [PATCH] fix(compaction): apply host transcript byte guard to codex sessions (#115192) (#115514) Co-authored-by: pcpilot-dev --- .../src/bot.create-telegram-bot.test.ts | 2 +- .../compact-reasons.test.ts | 6 +- .../embedded-agent-runner/compact-reasons.ts | 4 +- .../compact.hooks.test.ts | 5 +- .../reply/agent-runner-memory.test.ts | 112 +++++++++++++++++- src/auto-reply/reply/agent-runner-memory.ts | 23 ++-- src/auto-reply/reply/commands-compact.test.ts | 6 +- src/auto-reply/reply/commands-compact.ts | 4 +- 8 files changed, 140 insertions(+), 22 deletions(-) diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index 7c3fab935071..8cdcd7ebb204 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -4352,7 +4352,7 @@ describe("createTelegramBot", () => { sendMessageSpy.mockClear(); dispatchReplyWithBufferedBlockDispatcher.mockClear(); replySpy.mockResolvedValue({ - text: "⚙️ Compaction skipped: already_compacted_recently • ctx 0%", + text: "⚙️ Compaction skipped: already_compacted • ctx 0%", }); loadConfig.mockReturnValue({ diff --git a/src/agents/embedded-agent-runner/compact-reasons.test.ts b/src/agents/embedded-agent-runner/compact-reasons.test.ts index a7f48571fa83..22a9ea9f0f37 100644 --- a/src/agents/embedded-agent-runner/compact-reasons.test.ts +++ b/src/agents/embedded-agent-runner/compact-reasons.test.ts @@ -43,6 +43,10 @@ describe("classifyCompactionReason", () => { expect(classifyCompactionReason("already under target")).toBe("below_threshold"); }); + it('classifies "already compacted" without implying recency', () => { + expect(classifyCompactionReason("already compacted")).toBe("already_compacted"); + }); + it("classifies deferred background maintenance as a skip-like reason", () => { expect(classifyCompactionReason("deferred to background context-engine maintenance")).toBe( "deferred_background", @@ -63,7 +67,7 @@ describe("classifyCompactionReason", () => { }); describe("isBenignCompactionSkipReason", () => { - it.each(["already under target", "already compacted recently"])( + it.each(["already under target", "already compacted"])( "keeps the established %s skip contract", (reason) => { expect(isBenignCompactionSkipReason(reason)).toBe(true); diff --git a/src/agents/embedded-agent-runner/compact-reasons.ts b/src/agents/embedded-agent-runner/compact-reasons.ts index 14ab41677312..b52291a21ad1 100644 --- a/src/agents/embedded-agent-runner/compact-reasons.ts +++ b/src/agents/embedded-agent-runner/compact-reasons.ts @@ -40,7 +40,7 @@ export function classifyCompactionReason(reason?: string): string { return "below_threshold"; } if (text.includes("already compacted") || text.includes("already_compacted")) { - return "already_compacted_recently"; + return "already_compacted"; } if (text.includes("deferred to background")) { return "deferred_background"; @@ -79,7 +79,7 @@ export function classifyCompactionReason(reason?: string): string { /** Return whether a classified reason represents an intentional compaction no-op. */ export function isBenignCompactionSkipReason(reason?: string): boolean { const classification = classifyCompactionReason(reason); - return classification === "below_threshold" || classification === "already_compacted_recently"; + return classification === "below_threshold" || classification === "already_compacted"; } /** Return whether a compaction result is an intentional no-op rather than a failure. */ diff --git a/src/agents/embedded-agent-runner/compact.hooks.test.ts b/src/agents/embedded-agent-runner/compact.hooks.test.ts index 4dbafb59d5ab..ca192d1219cc 100644 --- a/src/agents/embedded-agent-runner/compact.hooks.test.ts +++ b/src/agents/embedded-agent-runner/compact.hooks.test.ts @@ -3890,20 +3890,21 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { }); }); - it("does not fire after_compaction when compaction fails", async () => { + it("does not fire after_compaction when the session is already compacted", async () => { hookRunner.hasHooks.mockReturnValue(true); const sync = vi.fn(async () => {}); getMemorySearchManagerMock.mockResolvedValue({ manager: { sync } }); contextEngineCompactMock.mockResolvedValue({ ok: false, compacted: false, - reason: "nothing to compact", + reason: "already_compacted", result: undefined, }); const result = await compactEmbeddedAgentSession(wrappedCompactionArgs()); expect(result.ok).toBe(false); + expect(result.reason).toBe("already_compacted"); expect(hookRunner.runBeforeCompaction).toHaveBeenCalledTimes(1); expect(hookRunner.runAfterCompaction).not.toHaveBeenCalled(); expect(sync).not.toHaveBeenCalled(); diff --git a/src/auto-reply/reply/agent-runner-memory.test.ts b/src/auto-reply/reply/agent-runner-memory.test.ts index d85f35f974f8..4a807db2be11 100644 --- a/src/auto-reply/reply/agent-runner-memory.test.ts +++ b/src/auto-reply/reply/agent-runner-memory.test.ts @@ -2664,7 +2664,117 @@ describe("runMemoryFlushIfNeeded", () => { expect(compactCall.sessionFile).toContain("large-session.jsonl"); }); - it("skips OpenClaw maintenance when model policy routes a SQLite session to native-compacting claude-cli", async () => { + it("byte-guards a Codex runtime session through SQLite semantic compaction", async () => { + const storePath = path.join(rootDir, "sqlite-codex-byte-guard.json"); + const sessionKey = "agent:main:main"; + const scope = { agentId: "main", sessionId: "session", sessionKey, storePath }; + await upsertSessionEntry(scope, { sessionId: "session", updatedAt: 10 }); + await replaceSqliteTranscriptEvents(scope, [ + { message: { role: "user", content: "x".repeat(256) }, type: "message" }, + ]); + expect(readTranscriptStatsSync(scope).sizeBytes).toBeGreaterThan(10); + + const sessionEntry: SessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + totalTokens: 10, + totalTokensFresh: true, + compactionCount: 0, + agentRuntimeOverride: "codex", + agentHarnessId: "openclaw", + }; + const sessionStore = { [sessionKey]: sessionEntry }; + const replyOperation = createReplyOperation(); + + const entry = await runPreflightCompactionIfNeeded({ + cfg: { + agents: { + defaults: { + compaction: { maxActiveTranscriptBytes: "10b" }, + }, + }, + }, + followupRun: createTestFollowupRun({ + provider: "openai", + model: "gpt-5.5", + sessionId: "session", + sessionKey, + }), + defaultModel: "gpt-5.5", + agentCfgContextTokens: 1_000_000, + sessionEntry, + sessionStore, + sessionKey, + storePath, + isHeartbeat: false, + replyOperation, + }); + + expect(entry?.compactionCount).toBe(1); + expect(replyOperation.setPhase).toHaveBeenCalledWith("preflight_compacting"); + expect(requireCompactEmbeddedAgentSessionCall()).toMatchObject({ + agentHarnessId: "openclaw", + deferOwningContextEngineCompaction: false, + preflightCompactionTrigger: "transcript_bytes", + preflightRequired: true, + sessionId: "session", + sessionKey, + trigger: "budget", + }); + }); + + it("leaves an under-limit SQLite Codex session to native token compaction", async () => { + const storePath = path.join(rootDir, "sqlite-codex-under-byte-guard.json"); + const sessionKey = "agent:main:main"; + const scope = { agentId: "main", sessionId: "session", sessionKey, storePath }; + await upsertSessionEntry(scope, { sessionId: "session", updatedAt: 10 }); + await replaceSqliteTranscriptEvents(scope, [ + { message: { role: "user", content: "small" }, type: "message" }, + ]); + expect(readTranscriptStatsSync(scope).sizeBytes).toBeLessThan(10 * 1024); + + const sessionEntry: SessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + totalTokens: 347_000, + totalTokensFresh: true, + compactionCount: 0, + agentRuntimeOverride: "codex", + agentHarnessId: "openclaw", + }; + const replyOperation = createReplyOperation(); + + const entry = await runPreflightCompactionIfNeeded({ + cfg: { + agents: { + defaults: { + compaction: { maxActiveTranscriptBytes: "10kb" }, + }, + }, + }, + followupRun: createTestFollowupRun({ + provider: "openai", + model: "gpt-5.5", + sessionId: "session", + sessionKey, + }), + defaultModel: "gpt-5.5", + agentCfgContextTokens: 350_000, + sessionEntry, + sessionStore: { [sessionKey]: sessionEntry }, + sessionKey, + storePath, + isHeartbeat: false, + replyOperation, + }); + + expect(entry).toBe(sessionEntry); + expect(replyOperation.setPhase).not.toHaveBeenCalled(); + expect(compactEmbeddedAgentSessionMock).not.toHaveBeenCalled(); + expect(incrementCompactionCountMock).not.toHaveBeenCalled(); + }); + + it("keeps ownsNativeCompaction absolute over the SQLite transcript byte guard", async () => { registerClaudeCliBackend(true); registerMemoryFlushPlanResolverForTest(() => ({ softThresholdTokens: 4_000, diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index f8e5d02fcfed..168679b55a9b 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -796,15 +796,7 @@ export async function runPreflightCompactionIfNeeded(params: { if (params.isHeartbeat || isCli || ownsNativeCompaction) { return entry ?? params.sessionEntry; } - if (normalizeLowercaseStringOrEmpty(runtimeId) === "codex") { - // Codex runtime sessions should reach Codex with their real thread state. - // Its harness owns automatic compaction; OpenClaw preflight compaction is - // only for non-Codex embedded runtimes. - logVerbose( - `preflightCompaction skipped: sessionKey=${params.sessionKey} runtime=codex reason=codex_native_auto_compaction`, - ); - return entry ?? params.sessionEntry; - } + const isCodexRuntime = normalizeLowercaseStringOrEmpty(runtimeId) === "codex"; const compactionSessionKey = params.sessionKey ?? params.followupRun.run.sessionKey; if (!compactionSessionKey) { @@ -866,7 +858,7 @@ export async function runPreflightCompactionIfNeeded(params: { const maxActiveTranscriptBytes = resolveMaxActiveTranscriptBytes(params.cfg); const shouldCheckActiveTranscriptBytes = typeof maxActiveTranscriptBytes === "number"; const transcriptUsageTokens = - typeof freshPersistedTokens === "number" && !freshNeedsOutputRead + isCodexRuntime || (typeof freshPersistedTokens === "number" && !freshNeedsOutputRead) ? undefined : await estimatePromptTokensFromSessionTranscript({ agentId: compactionAgentId, @@ -893,6 +885,17 @@ export async function runPreflightCompactionIfNeeded(params: { typeof activeTranscriptBytes === "number" && typeof maxActiveTranscriptBytes === "number" && activeTranscriptBytes >= maxActiveTranscriptBytes; + if (isCodexRuntime && !shouldCompactByTranscriptBytes) { + // Codex owns native-thread token pressure; OpenClaw owns the host transcript byte fuse + // that bounds fresh-thread bootstrap seeds. + logVerbose( + `preflightCompaction skipped: sessionKey=${params.sessionKey} runtime=codex ` + + `reason=codex_native_auto_compaction ` + + `activeTranscriptBytes=${activeTranscriptBytes ?? "undefined"} ` + + `maxActiveTranscriptBytes=${maxActiveTranscriptBytes ?? "undefined"}`, + ); + return entry ?? params.sessionEntry; + } const stalePersistedPromptTokens = hasPersistedTotalTokens && entry.totalTokensFresh !== false ? Math.floor(persistedTotalTokens) diff --git a/src/auto-reply/reply/commands-compact.test.ts b/src/auto-reply/reply/commands-compact.test.ts index 5b69b9c493c3..b506079d36ee 100644 --- a/src/auto-reply/reply/commands-compact.test.ts +++ b/src/auto-reply/reply/commands-compact.test.ts @@ -343,11 +343,11 @@ describe("handleCompactCommand", () => { expect(vi.mocked(incrementCompactionCount)).not.toHaveBeenCalled(); }); - it("treats already_compacted_recently manual compaction as skipped", async () => { + it("treats already_compacted manual compaction as skipped", async () => { vi.mocked(compactEmbeddedAgentSession).mockResolvedValueOnce({ ok: false, compacted: false, - reason: "already_compacted_recently", + reason: "already_compacted", }); const result = await handleCompactCommand( @@ -365,7 +365,7 @@ describe("handleCompactCommand", () => { ); expect(result?.reply?.text).toBe( - "⚙️ Compaction skipped: session was already compacted recently • Context 12.1k", + "⚙️ Compaction skipped: session is already compacted • Context 12.1k", ); expect(result?.reply?.isStatusNotice).toBe(true); }); diff --git a/src/auto-reply/reply/commands-compact.ts b/src/auto-reply/reply/commands-compact.ts index 0616c09555c2..a4003dfcd3e3 100644 --- a/src/auto-reply/reply/commands-compact.ts +++ b/src/auto-reply/reply/commands-compact.ts @@ -73,8 +73,8 @@ function formatCompactionReason(reason?: string): string | undefined { return lower.includes("already under target") ? "context is already under the compaction target" : "context is below the compaction threshold"; - case "already_compacted_recently": - return "session was already compacted recently"; + case "already_compacted": + return "session is already compacted"; default: return text; }