fix(compaction): share real-conversation predicate and ignore tool-call-only assistant blocks

This commit is contained in:
samzong 2026-03-10 21:12:42 +08:00 committed by Josh Lehman
parent b1eaf639d5
commit 5a6d94c051
No known key found for this signature in database
GPG Key ID: D141B425AC7F876B
5 changed files with 151 additions and 141 deletions

View File

@ -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;
}

View File

@ -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: "<b>HEARTBEAT_OK</b>", 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: "<b>HEARTBEAT_OK</b>" },
{
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 () => {

View File

@ -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;

View File

@ -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: "<b>HEARTBEAT_OK</b>" } 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: "<b>HEARTBEAT_OK</b>",
} as AgentMessage),
).toBe(false);
});
});
async function expectWorkspaceSummaryEmptyForAgentsAlias(

View File

@ -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[];