openclaw/src/agents/compaction.ts
Josh Lehman a69f64470d
fix: truncate oversized messages before compaction summarization
Truncate oversized messages before compaction summarization to avoid
context limit failures. When individual messages exceed the chunk token
budget, they are flattened to text and truncated to fit within limits.

This prevents the 'prompt is too long' error during compaction that was
causing sessions to reset with empty summaries.

- Add truncateMessagesForSummary function to pre-process messages
- Apply truncation in summarizeChunks before chunking
- Preserve tool call names and image placeholders in truncated content
- Add tests for truncation behavior
2026-01-27 21:14:38 -08:00

493 lines
15 KiB
TypeScript

import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import { estimateTokens, generateSummary } from "@mariozechner/pi-coding-agent";
import { DEFAULT_CONTEXT_TOKENS } from "./defaults.js";
export const BASE_CHUNK_RATIO = 0.4;
export const MIN_CHUNK_RATIO = 0.15;
export const SAFETY_MARGIN = 1.2; // 20% buffer for estimateTokens() inaccuracy
const MIN_TRUNCATED_CHARS = 32;
const TOOLCALL_ARGS_MAX_CHARS = 240;
const DEFAULT_SUMMARY_FALLBACK = "No prior history.";
const DEFAULT_PARTS = 2;
const MERGE_SUMMARIES_INSTRUCTIONS =
"Merge these partial summaries into a single cohesive summary. Preserve decisions," +
" TODOs, open questions, and any constraints.";
export function estimateMessagesTokens(messages: AgentMessage[]): number {
return messages.reduce((sum, message) => sum + estimateTokens(message), 0);
}
function normalizeParts(parts: number, messageCount: number): number {
if (!Number.isFinite(parts) || parts <= 1) return 1;
return Math.min(Math.max(1, Math.floor(parts)), Math.max(1, messageCount));
}
export function splitMessagesByTokenShare(
messages: AgentMessage[],
parts = DEFAULT_PARTS,
): AgentMessage[][] {
if (messages.length === 0) return [];
const normalizedParts = normalizeParts(parts, messages.length);
if (normalizedParts <= 1) return [messages];
const totalTokens = estimateMessagesTokens(messages);
const targetTokens = totalTokens / normalizedParts;
const chunks: AgentMessage[][] = [];
let current: AgentMessage[] = [];
let currentTokens = 0;
for (const message of messages) {
const messageTokens = estimateTokens(message);
if (
chunks.length < normalizedParts - 1 &&
current.length > 0 &&
currentTokens + messageTokens > targetTokens
) {
chunks.push(current);
current = [];
currentTokens = 0;
}
current.push(message);
currentTokens += messageTokens;
}
if (current.length > 0) {
chunks.push(current);
}
return chunks;
}
export function chunkMessagesByMaxTokens(
messages: AgentMessage[],
maxTokens: number,
): AgentMessage[][] {
if (messages.length === 0) return [];
const chunks: AgentMessage[][] = [];
let currentChunk: AgentMessage[] = [];
let currentTokens = 0;
for (const message of messages) {
const messageTokens = estimateTokens(message);
if (currentChunk.length > 0 && currentTokens + messageTokens > maxTokens) {
chunks.push(currentChunk);
currentChunk = [];
currentTokens = 0;
}
currentChunk.push(message);
currentTokens += messageTokens;
if (messageTokens > maxTokens) {
// Split oversized messages to avoid unbounded chunk growth.
chunks.push(currentChunk);
currentChunk = [];
currentTokens = 0;
}
}
if (currentChunk.length > 0) {
chunks.push(currentChunk);
}
return chunks;
}
/**
* Compute adaptive chunk ratio based on average message size.
* When messages are large, we use smaller chunks to avoid exceeding model limits.
*/
export function computeAdaptiveChunkRatio(messages: AgentMessage[], contextWindow: number): number {
if (messages.length === 0) return BASE_CHUNK_RATIO;
const totalTokens = estimateMessagesTokens(messages);
const avgTokens = totalTokens / messages.length;
// Apply safety margin to account for estimation inaccuracy
const safeAvgTokens = avgTokens * SAFETY_MARGIN;
const avgRatio = safeAvgTokens / contextWindow;
// If average message is > 10% of context, reduce chunk ratio
if (avgRatio > 0.1) {
const reduction = Math.min(avgRatio * 2, BASE_CHUNK_RATIO - MIN_CHUNK_RATIO);
return Math.max(MIN_CHUNK_RATIO, BASE_CHUNK_RATIO - reduction);
}
return BASE_CHUNK_RATIO;
}
/**
* Check if a single message is too large to summarize.
* If single message > 50% of context, it can't be summarized safely.
*/
export function isOversizedForSummary(msg: AgentMessage, contextWindow: number): boolean {
const tokens = estimateTokens(msg) * SAFETY_MARGIN;
return tokens > contextWindow * 0.5;
}
function truncateText(text: string, maxChars: number): string {
if (text.length <= maxChars) return text;
if (maxChars <= 0) return "";
return `${text.slice(0, Math.max(0, maxChars - 3))}...`;
}
function stringifyToolCallArguments(args: unknown, maxChars: number): string | undefined {
if (args == null) return undefined;
try {
const serialized = JSON.stringify(args);
return truncateText(serialized, maxChars);
} catch {
return undefined;
}
}
function collectTextFromContentBlocks(blocks: unknown[]): string {
const parts: string[] = [];
for (const block of blocks) {
if (!block || typeof block !== "object") continue;
const rec = block as { type?: unknown; text?: unknown };
if (rec.type === "text" && typeof rec.text === "string") {
parts.push(rec.text);
continue;
}
if (rec.type === "image") {
parts.push("[image omitted]");
}
}
return parts.join("\n");
}
function collectAssistantContentText(blocks: unknown[]): string {
const parts: string[] = [];
for (const block of blocks) {
if (!block || typeof block !== "object") continue;
const rec = block as {
type?: unknown;
text?: unknown;
thinking?: unknown;
name?: unknown;
arguments?: unknown;
};
// Preserve readable content while compacting tool calls.
if (rec.type === "text" && typeof rec.text === "string") {
parts.push(rec.text);
continue;
}
if (rec.type === "thinking" && typeof rec.thinking === "string") {
parts.push(rec.thinking);
continue;
}
if (rec.type === "toolCall") {
const name = typeof rec.name === "string" && rec.name.trim() ? rec.name : "tool";
const args = stringifyToolCallArguments(rec.arguments, TOOLCALL_ARGS_MAX_CHARS);
parts.push(args ? `Tool call ${name}(${args})` : `Tool call ${name}`);
}
}
return parts.join("\n");
}
function extractMessageText(msg: AgentMessage): string {
// Flatten message content so oversize messages can be safely summarized.
if (!msg || typeof msg !== "object") return "";
const role = (msg as { role?: unknown }).role;
if (role === "user") {
const user = msg as Extract<AgentMessage, { role: "user" }>;
if (typeof user.content === "string") return user.content;
if (Array.isArray(user.content)) return collectTextFromContentBlocks(user.content);
return "";
}
if (role === "assistant") {
const assistant = msg as Extract<AgentMessage, { role: "assistant" }>;
if (Array.isArray(assistant.content)) {
return collectAssistantContentText(assistant.content);
}
return "";
}
if (role === "toolResult") {
const tool = msg as Extract<AgentMessage, { role: "toolResult" }>;
if (Array.isArray(tool.content)) return collectTextFromContentBlocks(tool.content);
return "";
}
return "";
}
function replaceMessageContentWithText(msg: AgentMessage, text: string): AgentMessage {
const role = (msg as { role?: unknown }).role;
if (role === "user") {
const user = msg as Extract<AgentMessage, { role: "user" }>;
const content = typeof user.content === "string" ? text : [{ type: "text", text }];
return { ...user, content };
}
if (role === "assistant") {
const assistant = msg as Extract<AgentMessage, { role: "assistant" }>;
return { ...assistant, content: [{ type: "text", text }] };
}
if (role === "toolResult") {
const tool = msg as Extract<AgentMessage, { role: "toolResult" }>;
return { ...tool, content: [{ type: "text", text }] };
}
return msg;
}
function computeMaxCharsForTokens(
text: string,
estimatedTokens: number,
targetTokens: number,
): number {
if (text.length === 0) return 0;
if (estimatedTokens <= 0) return Math.min(text.length, targetTokens * 4);
const ratio = targetTokens / estimatedTokens;
const scaled = Math.floor(text.length * ratio);
return Math.max(MIN_TRUNCATED_CHARS, Math.min(text.length, scaled));
}
function truncateMessageForSummary(msg: AgentMessage, maxTokens: number): AgentMessage {
// Keep each message within the chunk budget so summarization doesn't fail.
const estimatedTokens = estimateTokens(msg);
if (estimatedTokens <= maxTokens) return msg;
const targetTokens = Math.max(1, Math.floor(maxTokens / SAFETY_MARGIN));
const rawText = extractMessageText(msg).trim() || "[message omitted for summary]";
const maxChars = computeMaxCharsForTokens(rawText, estimatedTokens, targetTokens);
const truncatedText = truncateText(rawText, maxChars);
const truncatedMessage = replaceMessageContentWithText(msg, truncatedText);
// Fall back to a minimal marker if the trimmed message is still too large.
if (estimateTokens(truncatedMessage) <= maxTokens) {
return truncatedMessage;
}
return replaceMessageContentWithText(msg, "[message truncated for summary]");
}
export function truncateMessagesForSummary(
messages: AgentMessage[],
maxTokens: number,
): AgentMessage[] {
if (messages.length === 0) return messages;
return messages.map((msg) => truncateMessageForSummary(msg, maxTokens));
}
async function summarizeChunks(params: {
messages: AgentMessage[];
model: NonNullable<ExtensionContext["model"]>;
apiKey: string;
signal: AbortSignal;
reserveTokens: number;
maxChunkTokens: number;
customInstructions?: string;
previousSummary?: string;
}): Promise<string> {
if (params.messages.length === 0) {
return params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK;
}
// Truncate oversized messages before chunking to avoid context limit failures
const truncatedMessages = truncateMessagesForSummary(params.messages, params.maxChunkTokens);
const chunks = chunkMessagesByMaxTokens(truncatedMessages, params.maxChunkTokens);
let summary = params.previousSummary;
for (const chunk of chunks) {
summary = await generateSummary(
chunk,
params.model,
params.reserveTokens,
params.apiKey,
params.signal,
params.customInstructions,
summary,
);
}
return summary ?? DEFAULT_SUMMARY_FALLBACK;
}
/**
* Summarize with progressive fallback for handling oversized messages.
* If full summarization fails, tries partial summarization excluding oversized messages.
*/
export async function summarizeWithFallback(params: {
messages: AgentMessage[];
model: NonNullable<ExtensionContext["model"]>;
apiKey: string;
signal: AbortSignal;
reserveTokens: number;
maxChunkTokens: number;
contextWindow: number;
customInstructions?: string;
previousSummary?: string;
}): Promise<string> {
const { messages, contextWindow } = params;
if (messages.length === 0) {
return params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK;
}
// Try full summarization first
try {
return await summarizeChunks(params);
} catch (fullError) {
console.warn(
`Full summarization failed, trying partial: ${
fullError instanceof Error ? fullError.message : String(fullError)
}`,
);
}
// Fallback 1: Summarize only small messages, note oversized ones
const smallMessages: AgentMessage[] = [];
const oversizedNotes: string[] = [];
for (const msg of messages) {
if (isOversizedForSummary(msg, contextWindow)) {
const role = (msg as { role?: string }).role ?? "message";
const tokens = estimateTokens(msg);
oversizedNotes.push(
`[Large ${role} (~${Math.round(tokens / 1000)}K tokens) omitted from summary]`,
);
} else {
smallMessages.push(msg);
}
}
if (smallMessages.length > 0) {
try {
const partialSummary = await summarizeChunks({
...params,
messages: smallMessages,
});
const notes = oversizedNotes.length > 0 ? `\n\n${oversizedNotes.join("\n")}` : "";
return partialSummary + notes;
} catch (partialError) {
console.warn(
`Partial summarization also failed: ${
partialError instanceof Error ? partialError.message : String(partialError)
}`,
);
}
}
// Final fallback: Just note what was there
return (
`Context contained ${messages.length} messages (${oversizedNotes.length} oversized). ` +
`Summary unavailable due to size limits.`
);
}
export async function summarizeInStages(params: {
messages: AgentMessage[];
model: NonNullable<ExtensionContext["model"]>;
apiKey: string;
signal: AbortSignal;
reserveTokens: number;
maxChunkTokens: number;
contextWindow: number;
customInstructions?: string;
previousSummary?: string;
parts?: number;
minMessagesForSplit?: number;
}): Promise<string> {
const { messages } = params;
if (messages.length === 0) {
return params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK;
}
const minMessagesForSplit = Math.max(2, params.minMessagesForSplit ?? 4);
const parts = normalizeParts(params.parts ?? DEFAULT_PARTS, messages.length);
const totalTokens = estimateMessagesTokens(messages);
if (parts <= 1 || messages.length < minMessagesForSplit || totalTokens <= params.maxChunkTokens) {
return summarizeWithFallback(params);
}
const splits = splitMessagesByTokenShare(messages, parts).filter((chunk) => chunk.length > 0);
if (splits.length <= 1) {
return summarizeWithFallback(params);
}
const partialSummaries: string[] = [];
for (const chunk of splits) {
partialSummaries.push(
await summarizeWithFallback({
...params,
messages: chunk,
previousSummary: undefined,
}),
);
}
if (partialSummaries.length === 1) {
return partialSummaries[0];
}
const summaryMessages: AgentMessage[] = partialSummaries.map((summary) => ({
role: "user",
content: summary,
timestamp: Date.now(),
}));
const mergeInstructions = params.customInstructions
? `${MERGE_SUMMARIES_INSTRUCTIONS}\n\nAdditional focus:\n${params.customInstructions}`
: MERGE_SUMMARIES_INSTRUCTIONS;
return summarizeWithFallback({
...params,
messages: summaryMessages,
customInstructions: mergeInstructions,
});
}
export function pruneHistoryForContextShare(params: {
messages: AgentMessage[];
maxContextTokens: number;
maxHistoryShare?: number;
parts?: number;
}): {
messages: AgentMessage[];
droppedMessagesList: AgentMessage[];
droppedChunks: number;
droppedMessages: number;
droppedTokens: number;
keptTokens: number;
budgetTokens: number;
} {
const maxHistoryShare = params.maxHistoryShare ?? 0.5;
const budgetTokens = Math.max(1, Math.floor(params.maxContextTokens * maxHistoryShare));
let keptMessages = params.messages;
const allDroppedMessages: AgentMessage[] = [];
let droppedChunks = 0;
let droppedMessages = 0;
let droppedTokens = 0;
const parts = normalizeParts(params.parts ?? DEFAULT_PARTS, keptMessages.length);
while (keptMessages.length > 0 && estimateMessagesTokens(keptMessages) > budgetTokens) {
const chunks = splitMessagesByTokenShare(keptMessages, parts);
if (chunks.length <= 1) break;
const [dropped, ...rest] = chunks;
droppedChunks += 1;
droppedMessages += dropped.length;
droppedTokens += estimateMessagesTokens(dropped);
allDroppedMessages.push(...dropped);
keptMessages = rest.flat();
}
return {
messages: keptMessages,
droppedMessagesList: allDroppedMessages,
droppedChunks,
droppedMessages,
droppedTokens,
keptTokens: estimateMessagesTokens(keptMessages),
budgetTokens,
};
}
export function resolveContextWindowTokens(model?: ExtensionContext["model"]): number {
return Math.max(1, Math.floor(model?.contextWindow ?? DEFAULT_CONTEXT_TOKENS));
}