diff --git a/src/agents/pi-extensions/compaction-safeguard.test.ts b/src/agents/pi-extensions/compaction-safeguard.test.ts index ef294a4f4c9..8e0f0453196 100644 --- a/src/agents/pi-extensions/compaction-safeguard.test.ts +++ b/src/agents/pi-extensions/compaction-safeguard.test.ts @@ -445,6 +445,11 @@ describe("compaction-safeguard recent-turn preservation", () => { content: [{ type: "text", text: "recent result" }], timestamp: 6, } as unknown as AgentMessage, + { + role: "assistant", + content: [{ type: "text", text: "recent final answer" }], + timestamp: 7, + } as unknown as AgentMessage, ]; const split = splitPreservedRecentTurns({ @@ -452,6 +457,17 @@ describe("compaction-safeguard recent-turn preservation", () => { recentTurnsPreserve: 1, }); + expect(split.preservedMessages.map((msg) => msg.role)).toEqual([ + "user", + "assistant", + "assistant", + ]); + expect( + split.preservedMessages.some( + (msg) => msg.role === "user" && (msg as { content?: unknown }).content === "recent ask", + ), + ).toBe(true); + const summarizableToolResultIds = split.summarizableMessages .filter((msg) => msg.role === "toolResult") .map((msg) => (msg as { toolCallId?: unknown }).toolCallId); @@ -459,6 +475,24 @@ describe("compaction-safeguard recent-turn preservation", () => { expect(summarizableToolResultIds).not.toContain("call_recent"); }); + it("formats preserved non-text messages with placeholders", () => { + const section = formatPreservedTurnsSection([ + { + role: "user", + content: [{ type: "image", data: "abc", mimeType: "image/png" }], + timestamp: 1, + } as unknown as AgentMessage, + { + role: "assistant", + content: [{ type: "toolCall", id: "call_recent", name: "read", arguments: {} }], + timestamp: 2, + } as unknown as AgentMessage, + ]); + + expect(section).toContain("- User: [non-text content: image]"); + expect(section).toContain("- Assistant: [non-text content: toolCall]"); + }); + it("clamps preserve count into a safe range", () => { expect(resolveRecentTurnsPreserve(undefined)).toBe(3); expect(resolveRecentTurnsPreserve(-1)).toBe(0); diff --git a/src/agents/pi-extensions/compaction-safeguard.ts b/src/agents/pi-extensions/compaction-safeguard.ts index a801fb800f6..68ab218f588 100644 --- a/src/agents/pi-extensions/compaction-safeguard.ts +++ b/src/agents/pi-extensions/compaction-safeguard.ts @@ -196,6 +196,54 @@ function extractMessageText(message: AgentMessage): string { return parts.join("\n").trim(); } +function formatNonTextPlaceholder(content: unknown): string | null { + if (content === null || content === undefined) { + return null; + } + if (!Array.isArray(content)) { + return "[non-text content]"; + } + const typeCounts = new Map(); + for (const block of content) { + if (!block || typeof block !== "object") { + continue; + } + const typeRaw = (block as { type?: unknown }).type; + const type = typeof typeRaw === "string" && typeRaw.trim().length > 0 ? typeRaw : "unknown"; + if (type === "text") { + continue; + } + typeCounts.set(type, (typeCounts.get(type) ?? 0) + 1); + } + if (typeCounts.size === 0) { + return "[non-text content]"; + } + const parts = [...typeCounts.entries()].map(([type, count]) => + count > 1 ? `${type} x${count}` : type, + ); + return `[non-text content: ${parts.join(", ")}]`; +} + +function findPreservedStartIndexByTurnBoundary( + messages: AgentMessage[], + preserveTurns: number, +): number { + let seenUsers = 0; + let earliestSelectedUserIndex = -1; + for (let i = messages.length - 1; i >= 0; i -= 1) { + const role = (messages[i] as { role?: unknown }).role; + if (role !== "user") { + continue; + } + seenUsers += 1; + earliestSelectedUserIndex = i; + if (seenUsers >= preserveTurns) { + return i; + } + } + return earliestSelectedUserIndex; +} + function splitPreservedRecentTurns(params: { messages: AgentMessage[]; recentTurnsPreserve: number; @@ -207,21 +255,30 @@ function splitPreservedRecentTurns(params: { if (preserveTurns <= 0) { return { summarizableMessages: params.messages, preservedMessages: [] }; } - const preserveMessages = preserveTurns * 2; - const candidateIndexes: number[] = []; - for (let i = params.messages.length - 1; i >= 0; i -= 1) { - const role = (params.messages[i] as { role?: unknown }).role; - if (role === "user" || role === "assistant") { - candidateIndexes.push(i); + const boundaryStartIndex = findPreservedStartIndexByTurnBoundary(params.messages, preserveTurns); + const preservedIndexSet = new Set(); + if (boundaryStartIndex >= 0) { + for (let i = boundaryStartIndex; i < params.messages.length; i += 1) { + const role = (params.messages[i] as { role?: unknown }).role; + if (role === "user" || role === "assistant") { + preservedIndexSet.add(i); + } } - if (candidateIndexes.length >= preserveMessages) { - break; + } else { + const fallbackMessageCount = preserveTurns * 2; + for (let i = params.messages.length - 1; i >= 0; i -= 1) { + const role = (params.messages[i] as { role?: unknown }).role; + if (role === "user" || role === "assistant") { + preservedIndexSet.add(i); + } + if (preservedIndexSet.size >= fallbackMessageCount) { + break; + } } } - if (candidateIndexes.length === 0) { + if (preservedIndexSet.size === 0) { return { summarizableMessages: params.messages, preservedMessages: [] }; } - const preservedIndexSet = new Set(candidateIndexes); const summarizableMessages = params.messages.filter((_, idx) => !preservedIndexSet.has(idx)); // Preserving recent assistant turns can orphan downstream toolResult messages. // Repair pairings here so compaction summarization doesn't trip strict providers. @@ -243,13 +300,17 @@ function formatPreservedTurnsSection(messages: AgentMessage[]): string { .map((message) => { const role = message.role === "assistant" ? "Assistant" : "User"; const text = extractMessageText(message); - if (!text) { + const nonTextPlaceholder = formatNonTextPlaceholder( + (message as { content?: unknown }).content, + ); + const rendered = text || nonTextPlaceholder; + if (!rendered) { return null; } const trimmed = - text.length > MAX_RECENT_TURN_TEXT_CHARS - ? `${text.slice(0, MAX_RECENT_TURN_TEXT_CHARS)}...` - : text; + rendered.length > MAX_RECENT_TURN_TEXT_CHARS + ? `${rendered.slice(0, MAX_RECENT_TURN_TEXT_CHARS)}...` + : rendered; return `- ${role}: ${trimmed}`; }) .filter((line): line is string => Boolean(line));