From b1eaf639d53e8d45595ab83403ce2add6c76d06a Mon Sep 17 00:00:00 2001 From: samzong Date: Tue, 10 Mar 2026 20:19:32 +0800 Subject: [PATCH 1/5] fix(compaction): skip compaction only when session has no meaningful conversation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #40727 The compaction safeguard and the embedded-runner compaction path both used a naive role check (user | assistant | toolResult) to decide whether a session contains real conversation worth summarising. In long-running heartbeat sessions nearly every message satisfies that check, yet the *content* is boilerplate: heartbeat polls produce empty or HEARTBEAT_OK user turns and NO_REPLY assistant turns. The guard therefore never fires, the session grows past the 200K ceiling, and the agent goes silent. Fix: replace the role-only predicate with a content-aware one: isRealConversationMessage / hasRealConversationContent (two separate copies – safeguard.ts and compact.ts – each get the same treatment) Rules: • user / assistant messages are real only when their text content is non-empty AND not a pure boilerplate sentinel (HEARTBEAT_OK or NO_REPLY). Non-text blocks (images, attachments) are always real. • toolResult messages are real only when a meaningful user message appears in the previous 20 messages of the window, so tool output that was triggered by a real human ask still counts. Both predicates are exported via __testing / direct export so the existing and new unit tests can cover them without mocks. Tests added: compaction-safeguard.test.ts – two new cases: • tool result linked to HEARTBEAT_OK user turn → not real • tool result linked to meaningful user ask → real compact.hooks.test.ts – two new integration cases: • boilerplate-only session → skips compaction (reason: no real conversation messages) • meaningful user ask + tool result → compaction runs --- .../compact.hooks.harness.ts | 43 ++++++++--- .../pi-embedded-runner/compact.hooks.test.ts | 73 ++++++++++++++++++ src/agents/pi-embedded-runner/compact.ts | 70 +++++++++++++++++- .../compaction-safeguard.test.ts | 44 +++++++++++ .../pi-extensions/compaction-safeguard.ts | 74 ++++++++++++++++++- 5 files changed, 285 insertions(+), 19 deletions(-) diff --git a/src/agents/pi-embedded-runner/compact.hooks.harness.ts b/src/agents/pi-embedded-runner/compact.hooks.harness.ts index e065b0105b3..9610209d007 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.harness.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.harness.ts @@ -67,6 +67,18 @@ export const resolveMemorySearchConfigMock = vi.fn(() => ({ })); export const resolveSessionAgentIdMock = vi.fn(() => "main"); export const estimateTokensMock = vi.fn((_message?: unknown) => 10); +export const sessionMessages: unknown[] = [ + { role: "user", content: "hello", timestamp: 1 }, + { role: "assistant", content: [{ type: "text", text: "hi" }], timestamp: 2 }, + { + role: "toolResult", + toolCallId: "t1", + toolName: "exec", + content: [{ type: "text", text: "output" }], + isError: false, + timestamp: 3, + }, +]; export const sessionAbortCompactionMock: Mock<(reason?: unknown) => void> = vi.fn(); export const createOpenClawCodingToolsMock = vi.fn(() => []); @@ -134,6 +146,20 @@ export function resetCompactHooksHarnessMocks(): void { resolveSessionAgentIdMock.mockReturnValue("main"); estimateTokensMock.mockReset(); estimateTokensMock.mockReturnValue(10); + sessionMessages.splice( + 0, + sessionMessages.length, + { role: "user", content: "hello", timestamp: 1 }, + { role: "assistant", content: [{ type: "text", text: "hi" }], timestamp: 2 }, + { + role: "toolResult", + toolCallId: "t1", + toolName: "exec", + content: [{ type: "text", text: "output" }], + isError: false, + timestamp: 3, + }, + ); sessionAbortCompactionMock.mockReset(); createOpenClawCodingToolsMock.mockReset(); createOpenClawCodingToolsMock.mockReturnValue([]); @@ -176,18 +202,11 @@ export async function loadCompactHooksHarness(): Promise<{ createAgentSession: vi.fn(async () => { const session = { sessionId: "session-1", - messages: [ - { role: "user", content: "hello", timestamp: 1 }, - { role: "assistant", content: [{ type: "text", text: "hi" }], timestamp: 2 }, - { - role: "toolResult", - toolCallId: "t1", - toolName: "exec", - content: [{ type: "text", text: "output" }], - isError: false, - timestamp: 3, - }, - ], + messages: sessionMessages.map((message) => + typeof structuredClone === "function" + ? structuredClone(message) + : JSON.parse(JSON.stringify(message)), + ), agent: { replaceMessages: vi.fn((messages: unknown[]) => { session.messages = [...(messages as typeof session.messages)]; diff --git a/src/agents/pi-embedded-runner/compact.hooks.test.ts b/src/agents/pi-embedded-runner/compact.hooks.test.ts index 1a97501959e..3819ca98a17 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.test.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.test.ts @@ -16,6 +16,7 @@ import { resetCompactHooksHarnessMocks, sanitizeSessionHistoryMock, sessionAbortCompactionMock, + sessionMessages, sessionCompactImpl, triggerInternalHook, } from "./compact.hooks.harness.js"; @@ -154,6 +155,20 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { estimateTokensMock.mockReset(); estimateTokensMock.mockReturnValue(10); sessionAbortCompactionMock.mockReset(); + sessionMessages.splice( + 0, + sessionMessages.length, + { role: "user", content: "hello", timestamp: 1 }, + { role: "assistant", content: [{ type: "text", text: "hi" }], timestamp: 2 }, + { + role: "toolResult", + toolCallId: "t1", + toolName: "exec", + content: [{ type: "text", text: "output" }], + isError: false, + timestamp: 3, + }, + ); unregisterApiProviders(getCustomApiRegistrySourceId("ollama")); }); @@ -490,6 +505,64 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { }); }); + it("skips compaction when the transcript only contains boilerplate replies and tool output", async () => { + sessionMessages.splice( + 0, + sessionMessages.length, + { role: "user", content: "HEARTBEAT_OK", timestamp: 1 }, + { + role: "toolResult", + toolCallId: "t1", + toolName: "exec", + content: [{ type: "text", text: "checked" }], + isError: false, + timestamp: 2, + }, + ); + + const result = await compactEmbeddedPiSessionDirect({ + sessionId: "session-1", + sessionKey: "agent:main:session-1", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + customInstructions: "focus on decisions", + }); + + expect(result).toMatchObject({ + ok: true, + compacted: false, + reason: "no real conversation messages", + }); + expect(sessionCompactImpl).not.toHaveBeenCalled(); + }); + + it("keeps compaction enabled when tool output follows a meaningful user request", async () => { + sessionMessages.splice( + 0, + sessionMessages.length, + { role: "user", content: "please inspect the failing PR", timestamp: 1 }, + { + role: "toolResult", + toolCallId: "t1", + toolName: "exec", + content: [{ type: "text", text: "checked" }], + isError: false, + timestamp: 2, + }, + ); + + const result = await compactEmbeddedPiSessionDirect({ + sessionId: "session-1", + sessionKey: "agent:main:session-1", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + customInstructions: "focus on decisions", + }); + + expect(result.ok).toBe(true); + expect(sessionCompactImpl).toHaveBeenCalled(); + }); + it("registers the Ollama api provider before compaction", async () => { resolveModelMock.mockReturnValue({ model: { diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 0dfc727dee1..622273f250f 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -167,8 +167,68 @@ type CompactionMessageMetrics = { contributors: Array<{ role: string; chars: number; tool?: string }>; }; -function hasRealConversationContent(msg: AgentMessage): boolean { - return msg.role === "user" || msg.role === "assistant" || msg.role === "toolResult"; +const BOILERPLATE_REPLY_TEXT = new Set(["HEARTBEAT_OK", "NO_REPLY"]); +const TOOL_RESULT_REAL_CONVERSATION_LOOKBACK = 20; + +function hasMeaningfulConversationContent(msg: AgentMessage): boolean { + const content = (msg as { content?: unknown }).content; + if (typeof content === "string") { + const trimmed = content.trim(); + if (!trimmed) { + return false; + } + return !BOILERPLATE_REPLY_TEXT.has(trimmed); + } + if (!Array.isArray(content)) { + return false; + } + let sawNonTextBlock = false; + for (const block of content) { + if (!block || typeof block !== "object") { + continue; + } + const type = (block as { type?: unknown }).type; + if (type !== "text") { + sawNonTextBlock = true; + continue; + } + const text = (block as { text?: unknown }).text; + if (typeof text !== "string") { + continue; + } + const trimmed = text.trim(); + if (!trimmed) { + continue; + } + if (!BOILERPLATE_REPLY_TEXT.has(trimmed)) { + return true; + } + } + return sawNonTextBlock; +} + +function hasRealConversationContent( + msg: AgentMessage, + messages: AgentMessage[], + index: number, +): boolean { + if (msg.role === "user" || msg.role === "assistant") { + return hasMeaningfulConversationContent(msg); + } + if (msg.role !== "toolResult") { + return false; + } + const start = Math.max(0, index - TOOL_RESULT_REAL_CONVERSATION_LOOKBACK); + for (let i = index - 1; i >= start; i -= 1) { + const candidate = messages[i]; + if (!candidate || candidate.role !== "user") { + continue; + } + if (hasMeaningfulConversationContent(candidate)) { + return true; + } + } + return false; } function createCompactionDiagId(): string { @@ -960,7 +1020,11 @@ export async function compactEmbeddedPiSessionDirect( ); } - if (!session.messages.some(hasRealConversationContent)) { + if ( + !session.messages.some((message, index, messages) => + hasRealConversationContent(message, messages, index), + ) + ) { log.info( `[compaction] skipping — no real conversation messages (sessionKey=${params.sessionKey ?? params.sessionId})`, ); diff --git a/src/agents/pi-extensions/compaction-safeguard.test.ts b/src/agents/pi-extensions/compaction-safeguard.test.ts index 509bbdd25b2..41906b36de7 100644 --- a/src/agents/pi-extensions/compaction-safeguard.test.ts +++ b/src/agents/pi-extensions/compaction-safeguard.test.ts @@ -1673,6 +1673,50 @@ describe("compaction-safeguard double-compaction guard", () => { expect(result).toEqual({ cancel: true }); expect(getApiKeyMock).toHaveBeenCalled(); }); + + it("treats tool results as real conversation only when linked to a meaningful user ask", async () => { + expect( + __testing.isRealConversationMessage( + { + role: "toolResult", + toolCallId: "t1", + toolName: "exec", + content: [{ type: "text", text: "done" }], + } as AgentMessage, + [ + { role: "user", content: "HEARTBEAT_OK" } as AgentMessage, + { + role: "toolResult", + toolCallId: "t1", + toolName: "exec", + content: [{ type: "text", text: "done" }], + } as AgentMessage, + ], + 1, + ), + ).toBe(false); + + expect( + __testing.isRealConversationMessage( + { + role: "toolResult", + toolCallId: "t2", + toolName: "exec", + content: [{ type: "text", text: "done" }], + } as AgentMessage, + [ + { role: "user", content: "please inspect the repo" } as AgentMessage, + { + role: "toolResult", + toolCallId: "t2", + toolName: "exec", + content: [{ type: "text", text: "done" }], + } as AgentMessage, + ], + 1, + ), + ).toBe(true); + }); }); async function expectWorkspaceSummaryEmptyForAgentsAlias( diff --git a/src/agents/pi-extensions/compaction-safeguard.ts b/src/agents/pi-extensions/compaction-safeguard.ts index 92332140656..7cea062d216 100644 --- a/src/agents/pi-extensions/compaction-safeguard.ts +++ b/src/agents/pi-extensions/compaction-safeguard.ts @@ -179,8 +179,68 @@ function formatToolFailuresSection(failures: ToolFailure[]): string { return `\n\n## Tool Failures\n${lines.join("\n")}`; } -function isRealConversationMessage(message: AgentMessage): boolean { - return message.role === "user" || message.role === "assistant" || message.role === "toolResult"; +const BOILERPLATE_REPLY_TEXT = new Set(["HEARTBEAT_OK", "NO_REPLY"]); +const TOOL_RESULT_REAL_CONVERSATION_LOOKBACK = 20; + +function hasMeaningfulConversationContent(message: AgentMessage): boolean { + const content = (message as { content?: unknown }).content; + if (typeof content === "string") { + const trimmed = content.trim(); + if (!trimmed) { + return false; + } + return !BOILERPLATE_REPLY_TEXT.has(trimmed); + } + if (!Array.isArray(content)) { + return false; + } + let sawNonTextBlock = false; + for (const block of content) { + if (!block || typeof block !== "object") { + continue; + } + const type = (block as { type?: unknown }).type; + if (type !== "text") { + sawNonTextBlock = true; + continue; + } + const text = (block as { text?: unknown }).text; + if (typeof text !== "string") { + continue; + } + const trimmed = text.trim(); + if (!trimmed) { + continue; + } + if (!BOILERPLATE_REPLY_TEXT.has(trimmed)) { + return true; + } + } + return sawNonTextBlock; +} + +function isRealConversationMessage( + message: AgentMessage, + messages: AgentMessage[], + index: number, +): boolean { + if (message.role === "user" || message.role === "assistant") { + return hasMeaningfulConversationContent(message); + } + if (message.role !== "toolResult") { + return false; + } + const start = Math.max(0, index - TOOL_RESULT_REAL_CONVERSATION_LOOKBACK); + for (let i = index - 1; i >= start; i -= 1) { + const candidate = messages[i]; + if (!candidate || candidate.role !== "user") { + continue; + } + if (hasMeaningfulConversationContent(candidate)) { + return true; + } + } + return false; } function computeFileLists(fileOps: FileOperations): { @@ -702,8 +762,12 @@ async function readWorkspaceContextForSummary(): Promise { export default function compactionSafeguardExtension(api: ExtensionAPI): void { api.on("session_before_compact", async (event, ctx) => { const { preparation, customInstructions: eventInstructions, signal } = event; - const hasRealSummarizable = preparation.messagesToSummarize.some(isRealConversationMessage); - const hasRealTurnPrefix = preparation.turnPrefixMessages.some(isRealConversationMessage); + const hasRealSummarizable = preparation.messagesToSummarize.some((message, index, messages) => + isRealConversationMessage(message, messages, index), + ); + const hasRealTurnPrefix = preparation.turnPrefixMessages.some((message, index, messages) => + isRealConversationMessage(message, messages, index), + ); if (!hasRealSummarizable && !hasRealTurnPrefix) { // When there are no summarizable messages AND no real turn-prefix content, // cancelling compaction leaves context unchanged but the SDK re-triggers @@ -1026,6 +1090,8 @@ export const __testing = { computeAdaptiveChunkRatio, isOversizedForSummary, readWorkspaceContextForSummary, + hasMeaningfulConversationContent, + isRealConversationMessage, BASE_CHUNK_RATIO, MIN_CHUNK_RATIO, SAFETY_MARGIN, From 5a6d94c0517129fda343364ad5135be329b4daa1 Mon Sep 17 00:00:00 2001 From: samzong Date: Tue, 10 Mar 2026 21:12:42 +0800 Subject: [PATCH 2/5] fix(compaction): share real-conversation predicate and ignore tool-call-only assistant blocks --- src/agents/compaction-real-conversation.ts | 77 +++++++++++++++++++ .../pi-embedded-runner/compact.hooks.test.ts | 60 ++++++++++----- src/agents/pi-embedded-runner/compact.ts | 67 +++------------- .../compaction-safeguard.test.ts | 20 ++++- .../pi-extensions/compaction-safeguard.ts | 68 +--------------- 5 files changed, 151 insertions(+), 141 deletions(-) create mode 100644 src/agents/compaction-real-conversation.ts diff --git a/src/agents/compaction-real-conversation.ts b/src/agents/compaction-real-conversation.ts new file mode 100644 index 00000000000..67ca24714c0 --- /dev/null +++ b/src/agents/compaction-real-conversation.ts @@ -0,0 +1,77 @@ +import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import { stripHeartbeatToken } from "../auto-reply/heartbeat.js"; +import { isSilentReplyText } from "../auto-reply/tokens.js"; + +export const TOOL_RESULT_REAL_CONVERSATION_LOOKBACK = 20; +const TOOL_ONLY_BLOCK_TYPES = new Set(["toolCall", "toolUse", "functionCall"]); + +function hasMeaningfulText(text: string): boolean { + const trimmed = text.trim(); + if (!trimmed) { + return false; + } + if (isSilentReplyText(trimmed)) { + return false; + } + const heartbeat = stripHeartbeatToken(trimmed, { mode: "message" }); + if (heartbeat.didStrip) { + return heartbeat.text.trim().length > 0; + } + return true; +} + +export function hasMeaningfulConversationContent(message: AgentMessage): boolean { + const content = (message as { content?: unknown }).content; + if (typeof content === "string") { + return hasMeaningfulText(content); + } + if (!Array.isArray(content)) { + return false; + } + let sawMeaningfulNonTextBlock = false; + for (const block of content) { + if (!block || typeof block !== "object") { + continue; + } + const type = (block as { type?: unknown }).type; + if (type !== "text") { + if (typeof type === "string" && TOOL_ONLY_BLOCK_TYPES.has(type)) { + continue; + } + sawMeaningfulNonTextBlock = true; + continue; + } + const text = (block as { text?: unknown }).text; + if (typeof text !== "string") { + continue; + } + if (hasMeaningfulText(text)) { + return true; + } + } + return sawMeaningfulNonTextBlock; +} + +export function isRealConversationMessage( + message: AgentMessage, + messages: AgentMessage[], + index: number, +): boolean { + if (message.role === "user" || message.role === "assistant") { + return hasMeaningfulConversationContent(message); + } + if (message.role !== "toolResult") { + return false; + } + const start = Math.max(0, index - TOOL_RESULT_REAL_CONVERSATION_LOOKBACK); + for (let i = index - 1; i >= start; i -= 1) { + const candidate = messages[i]; + if (!candidate || candidate.role !== "user") { + continue; + } + if (hasMeaningfulConversationContent(candidate)) { + return true; + } + } + return false; +} diff --git a/src/agents/pi-embedded-runner/compact.hooks.test.ts b/src/agents/pi-embedded-runner/compact.hooks.test.ts index 3819ca98a17..dbe4a9182c5 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.test.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.test.ts @@ -1,3 +1,4 @@ +import type { AgentMessage } from "@mariozechner/pi-agent-core"; import { getApiProvider, unregisterApiProviders } from "@mariozechner/pi-ai"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { getCustomApiRegistrySourceId } from "../custom-api-registry.js"; @@ -23,6 +24,7 @@ import { let compactEmbeddedPiSessionDirect: typeof import("./compact.js").compactEmbeddedPiSessionDirect; let compactEmbeddedPiSession: typeof import("./compact.js").compactEmbeddedPiSession; +let compactTesting: typeof import("./compact.js").__testing; let onSessionTranscriptUpdate: typeof import("../../sessions/transcript-events.js").onSessionTranscriptUpdate; const TEST_SESSION_ID = "session-1"; @@ -109,6 +111,7 @@ beforeAll(async () => { const loaded = await loadCompactHooksHarness(); compactEmbeddedPiSessionDirect = loaded.compactEmbeddedPiSessionDirect; compactEmbeddedPiSession = loaded.compactEmbeddedPiSession; + compactTesting = loaded.__testing; onSessionTranscriptUpdate = loaded.onSessionTranscriptUpdate; }); @@ -509,7 +512,7 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { sessionMessages.splice( 0, sessionMessages.length, - { role: "user", content: "HEARTBEAT_OK", timestamp: 1 }, + { role: "user", content: "HEARTBEAT_OK", timestamp: 1 }, { role: "toolResult", toolCallId: "t1", @@ -536,31 +539,50 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { expect(sessionCompactImpl).not.toHaveBeenCalled(); }); - it("keeps compaction enabled when tool output follows a meaningful user request", async () => { - sessionMessages.splice( - 0, - sessionMessages.length, - { role: "user", content: "please inspect the failing PR", timestamp: 1 }, + it("does not treat assistant-only tool-call blocks as meaningful conversation", () => { + expect( + compactTesting.hasMeaningfulConversationContent({ + role: "assistant", + content: [{ type: "toolCall", id: "call_1", name: "exec", arguments: {} }], + } as AgentMessage), + ).toBe(false); + }); + + it("counts tool output as real only when a meaningful user ask exists in the lookback window", () => { + const heartbeatToolResultWindow = [ + { role: "user", content: "HEARTBEAT_OK" }, { role: "toolResult", toolCallId: "t1", toolName: "exec", content: [{ type: "text", text: "checked" }], - isError: false, - timestamp: 2, }, - ); + ] as AgentMessage[]; + expect( + compactTesting.hasRealConversationContent( + heartbeatToolResultWindow[1], + heartbeatToolResultWindow, + 1, + ), + ).toBe(false); - const result = await compactEmbeddedPiSessionDirect({ - sessionId: "session-1", - sessionKey: "agent:main:session-1", - sessionFile: "/tmp/session.jsonl", - workspaceDir: "/tmp", - customInstructions: "focus on decisions", - }); - - expect(result.ok).toBe(true); - expect(sessionCompactImpl).toHaveBeenCalled(); + const realAskToolResultWindow = [ + { role: "assistant", content: "NO_REPLY" }, + { role: "user", content: "please inspect the failing PR" }, + { + role: "toolResult", + toolCallId: "t2", + toolName: "exec", + content: [{ type: "text", text: "checked" }], + }, + ] as AgentMessage[]; + expect( + compactTesting.hasRealConversationContent( + realAskToolResultWindow[2], + realAskToolResultWindow, + 2, + ), + ).toBe(true); }); it("registers the Ollama api provider before compaction", async () => { diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 622273f250f..cb1787d3545 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -38,6 +38,10 @@ import { resolveSessionAgentId, resolveSessionAgentIds } from "../agent-scope.js import type { ExecElevatedDefaults } from "../bash-tools.js"; import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../bootstrap-files.js"; import { listChannelSupportedActions, resolveChannelMessageToolHints } from "../channel-tools.js"; +import { + hasMeaningfulConversationContent, + isRealConversationMessage, +} from "../compaction-real-conversation.js"; import { resolveContextWindowInfo } from "../context-window-guard.js"; import { ensureCustomApiRegistered } from "../custom-api-registry.js"; import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js"; @@ -167,68 +171,12 @@ type CompactionMessageMetrics = { contributors: Array<{ role: string; chars: number; tool?: string }>; }; -const BOILERPLATE_REPLY_TEXT = new Set(["HEARTBEAT_OK", "NO_REPLY"]); -const TOOL_RESULT_REAL_CONVERSATION_LOOKBACK = 20; - -function hasMeaningfulConversationContent(msg: AgentMessage): boolean { - const content = (msg as { content?: unknown }).content; - if (typeof content === "string") { - const trimmed = content.trim(); - if (!trimmed) { - return false; - } - return !BOILERPLATE_REPLY_TEXT.has(trimmed); - } - if (!Array.isArray(content)) { - return false; - } - let sawNonTextBlock = false; - for (const block of content) { - if (!block || typeof block !== "object") { - continue; - } - const type = (block as { type?: unknown }).type; - if (type !== "text") { - sawNonTextBlock = true; - continue; - } - const text = (block as { text?: unknown }).text; - if (typeof text !== "string") { - continue; - } - const trimmed = text.trim(); - if (!trimmed) { - continue; - } - if (!BOILERPLATE_REPLY_TEXT.has(trimmed)) { - return true; - } - } - return sawNonTextBlock; -} - function hasRealConversationContent( msg: AgentMessage, messages: AgentMessage[], index: number, ): boolean { - if (msg.role === "user" || msg.role === "assistant") { - return hasMeaningfulConversationContent(msg); - } - if (msg.role !== "toolResult") { - return false; - } - const start = Math.max(0, index - TOOL_RESULT_REAL_CONVERSATION_LOOKBACK); - for (let i = index - 1; i >= start; i -= 1) { - const candidate = messages[i]; - if (!candidate || candidate.role !== "user") { - continue; - } - if (hasMeaningfulConversationContent(candidate)) { - return true; - } - } - return false; + return isRealConversationMessage(msg, messages, index); } function createCompactionDiagId(): string { @@ -1314,3 +1262,8 @@ export async function compactEmbeddedPiSession( }), ); } + +export const __testing = { + hasRealConversationContent, + hasMeaningfulConversationContent, +} as const; diff --git a/src/agents/pi-extensions/compaction-safeguard.test.ts b/src/agents/pi-extensions/compaction-safeguard.test.ts index 41906b36de7..9901316745b 100644 --- a/src/agents/pi-extensions/compaction-safeguard.test.ts +++ b/src/agents/pi-extensions/compaction-safeguard.test.ts @@ -1684,7 +1684,7 @@ describe("compaction-safeguard double-compaction guard", () => { content: [{ type: "text", text: "done" }], } as AgentMessage, [ - { role: "user", content: "HEARTBEAT_OK" } as AgentMessage, + { role: "user", content: "HEARTBEAT_OK" } as AgentMessage, { role: "toolResult", toolCallId: "t1", @@ -1717,6 +1717,24 @@ describe("compaction-safeguard double-compaction guard", () => { ), ).toBe(true); }); + + it("does not treat assistant-only tool calls as meaningful conversation", () => { + expect( + __testing.hasMeaningfulConversationContent({ + role: "assistant", + content: [{ type: "toolCall", id: "call_1", name: "exec", arguments: {} }], + } as AgentMessage), + ).toBe(false); + }); + + it("treats markup-wrapped heartbeat tokens as boilerplate", () => { + expect( + __testing.hasMeaningfulConversationContent({ + role: "assistant", + content: "HEARTBEAT_OK", + } as AgentMessage), + ).toBe(false); + }); }); async function expectWorkspaceSummaryEmptyForAgentsAlias( diff --git a/src/agents/pi-extensions/compaction-safeguard.ts b/src/agents/pi-extensions/compaction-safeguard.ts index 7cea062d216..5d5e0e9b059 100644 --- a/src/agents/pi-extensions/compaction-safeguard.ts +++ b/src/agents/pi-extensions/compaction-safeguard.ts @@ -6,6 +6,10 @@ import { extractSections } from "../../auto-reply/reply/post-compaction-context. import { openBoundaryFile } from "../../infra/boundary-file-read.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { extractKeywords, isQueryStopWordToken } from "../../memory/query-expansion.js"; +import { + hasMeaningfulConversationContent, + isRealConversationMessage, +} from "../compaction-real-conversation.js"; import { BASE_CHUNK_RATIO, type CompactionSummarizationInstructions, @@ -179,70 +183,6 @@ function formatToolFailuresSection(failures: ToolFailure[]): string { return `\n\n## Tool Failures\n${lines.join("\n")}`; } -const BOILERPLATE_REPLY_TEXT = new Set(["HEARTBEAT_OK", "NO_REPLY"]); -const TOOL_RESULT_REAL_CONVERSATION_LOOKBACK = 20; - -function hasMeaningfulConversationContent(message: AgentMessage): boolean { - const content = (message as { content?: unknown }).content; - if (typeof content === "string") { - const trimmed = content.trim(); - if (!trimmed) { - return false; - } - return !BOILERPLATE_REPLY_TEXT.has(trimmed); - } - if (!Array.isArray(content)) { - return false; - } - let sawNonTextBlock = false; - for (const block of content) { - if (!block || typeof block !== "object") { - continue; - } - const type = (block as { type?: unknown }).type; - if (type !== "text") { - sawNonTextBlock = true; - continue; - } - const text = (block as { text?: unknown }).text; - if (typeof text !== "string") { - continue; - } - const trimmed = text.trim(); - if (!trimmed) { - continue; - } - if (!BOILERPLATE_REPLY_TEXT.has(trimmed)) { - return true; - } - } - return sawNonTextBlock; -} - -function isRealConversationMessage( - message: AgentMessage, - messages: AgentMessage[], - index: number, -): boolean { - if (message.role === "user" || message.role === "assistant") { - return hasMeaningfulConversationContent(message); - } - if (message.role !== "toolResult") { - return false; - } - const start = Math.max(0, index - TOOL_RESULT_REAL_CONVERSATION_LOOKBACK); - for (let i = index - 1; i >= start; i -= 1) { - const candidate = messages[i]; - if (!candidate || candidate.role !== "user") { - continue; - } - if (hasMeaningfulConversationContent(candidate)) { - return true; - } - } - return false; -} - function computeFileLists(fileOps: FileOperations): { readFiles: string[]; modifiedFiles: string[]; From d6b5891c2ad669bbfae37ac4333ccd910da023e1 Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Thu, 12 Mar 2026 16:00:47 -0700 Subject: [PATCH 3/5] fix: ignore wrapped heartbeat boilerplate in compaction --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c8098c0578..d7da1078cd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -368,6 +368,7 @@ Docs: https://docs.openclaw.ai - Agents/compaction safeguard: trim large kept `toolResult` payloads consistently for budgeting, pruning, and identifier seeding, then restore preserved payloads after prune so oversized safeguard summaries stay stable. (#44133) thanks @SayrWolfridge. - Agents/compaction: compare post-compaction token sanity checks against full-session pre-compaction totals and skip the check when token estimation fails, so sessions with large bootstrap context keep real token counts instead of falling back to unknown. (#28347) thanks @efe-arv. - Discord/gateway startup: treat plain-text and transient `/gateway/bot` metadata fetch failures as transient startup errors so Discord gateway boot no longer crashes on unhandled rejections. (#44397) Thanks @jalehman. +- Agents/compaction: treat markup-wrapped heartbeat boilerplate as non-meaningful session history when deciding whether to compact, so heartbeat-only sessions no longer keep compaction alive due to wrapper formatting. (#42119) thanks @samzong. - Agents/Ollama overflow: rewrite Ollama `prompt too long` API payloads through the normal context-overflow sanitizer so embedded sessions keep the friendly overflow copy and auto-compaction trigger. (#34019) thanks @lishuaigit. - Control UI/auth: restore one-time legacy `?token=` imports for shared Control UI links while keeping `#token=` preferred, and carry pending query tokens through gateway URL confirmation so compatibility links still authenticate after confirmation. (#43979) Thanks @stim64045-spec. From 2d826de3c915f5306afe8c07738c4823565ac677 Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Thu, 12 Mar 2026 16:03:32 -0700 Subject: [PATCH 4/5] test: use fixture for heartbeat boilerplate case --- src/agents/pi-extensions/compaction-safeguard.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/agents/pi-extensions/compaction-safeguard.test.ts b/src/agents/pi-extensions/compaction-safeguard.test.ts index 9901316745b..3c18a67e2a7 100644 --- a/src/agents/pi-extensions/compaction-safeguard.test.ts +++ b/src/agents/pi-extensions/compaction-safeguard.test.ts @@ -1729,10 +1729,12 @@ describe("compaction-safeguard double-compaction guard", () => { it("treats markup-wrapped heartbeat tokens as boilerplate", () => { expect( - __testing.hasMeaningfulConversationContent({ - role: "assistant", - content: "HEARTBEAT_OK", - } as AgentMessage), + __testing.hasMeaningfulConversationContent( + castAgentMessage({ + role: "assistant", + content: "HEARTBEAT_OK", + }), + ), ).toBe(false); }); }); From ee04354692eb8285bb4308483c064bbb6b5c4e89 Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Thu, 12 Mar 2026 16:06:44 -0700 Subject: [PATCH 5/5] test: preserve message-channel exports in compaction hooks --- .../pi-embedded-runner/compact.hooks.harness.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/agents/pi-embedded-runner/compact.hooks.harness.ts b/src/agents/pi-embedded-runner/compact.hooks.harness.ts index 9610209d007..28489ba8244 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.harness.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.harness.ts @@ -377,10 +377,15 @@ export async function loadCompactHooksHarness(): Promise<{ resolveChannelCapabilities: vi.fn(() => undefined), })); - vi.doMock("../../utils/message-channel.js", () => ({ - INTERNAL_MESSAGE_CHANNEL: "webchat", - normalizeMessageChannel: vi.fn(() => undefined), - })); + vi.doMock("../../utils/message-channel.js", async () => { + const actual = await vi.importActual( + "../../utils/message-channel.js", + ); + return { + ...actual, + normalizeMessageChannel: vi.fn(() => undefined), + }; + }); vi.doMock("../pi-embedded-helpers.js", () => ({ ensureSessionHeader: vi.fn(async () => {}),