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
This commit is contained in:
Josh Lehman 2026-01-27 20:29:50 -08:00
parent 14e4b88bf0
commit a69f64470d
No known key found for this signature in database
GPG Key ID: D141B425AC7F876B
3 changed files with 217 additions and 5 deletions

View File

@ -5,6 +5,7 @@ import {
estimateMessagesTokens,
pruneHistoryForContextShare,
splitMessagesByTokenShare,
truncateMessagesForSummary,
} from "./compaction.js";
function makeMessage(id: number, size: number): AgentMessage {
@ -147,3 +148,67 @@ describe("pruneHistoryForContextShare", () => {
expect(pruned.messages.length).toBe(1);
});
});
describe("truncateMessagesForSummary", () => {
it("truncates oversized user messages", () => {
const message: AgentMessage = {
role: "user",
content: "x".repeat(5000),
timestamp: Date.now(),
};
const [result] = truncateMessagesForSummary([message], 50);
expect(typeof (result as Extract<AgentMessage, { role: "user" }>).content).toBe("string");
const content = (result as Extract<AgentMessage, { role: "user" }>).content as string;
expect(content.length).toBeLessThan(message.content.length);
});
it("flattens oversized assistant messages to text", () => {
const message: AgentMessage = {
role: "assistant",
content: [
{
type: "toolCall",
id: "call-1",
name: "exec",
arguments: { script: "x".repeat(5000) },
},
{ type: "text", text: "Done." },
],
api: "openai-responses",
provider: "openai",
model: "gpt-4.1",
usage: {
input: 1,
output: 1,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 2,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
};
const [result] = truncateMessagesForSummary([message], 50);
const content = (result as Extract<AgentMessage, { role: "assistant" }>).content;
expect(content[0]?.type).toBe("text");
expect((content[0] as { text?: string }).text).toContain("Tool call exec");
});
it("preserves small messages unchanged", () => {
const message: AgentMessage = {
role: "user",
content: "short message",
timestamp: Date.now(),
};
const [result] = truncateMessagesForSummary([message], 1000);
expect((result as Extract<AgentMessage, { role: "user" }>).content).toBe("short message");
});
it("returns empty array for empty input", () => {
const result = truncateMessagesForSummary([], 100);
expect(result).toEqual([]);
});
});

View File

@ -7,6 +7,8 @@ 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 =
@ -127,6 +129,149 @@ export function isOversizedForSummary(msg: AgentMessage, contextWindow: number):
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"]>;
@ -141,7 +286,9 @@ async function summarizeChunks(params: {
return params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK;
}
const chunks = chunkMessagesByMaxTokens(params.messages, params.maxChunkTokens);
// 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) {

View File

@ -5,9 +5,9 @@ import {
type MessagingTarget,
type MessagingTargetKind,
type MessagingTargetParseOptions,
type DirectoryConfigParams,
type ChannelDirectoryEntry,
} from "../channels/targets.js";
import type { DirectoryConfigParams } from "../channels/plugins/directory-config.js";
import type { ChannelDirectoryEntry } from "../channels/plugins/types.core.js";
import { listDiscordDirectoryPeersLive } from "./directory-live.js";
import { resolveDiscordAccount } from "./accounts.js";
@ -82,7 +82,7 @@ export async function resolveDiscordTarget(
if (!trimmed) return undefined;
// If already a known format, parse directly
const directParse = parseDiscordTarget(trimmed, options);
const directParse = parseDiscordTarget(trimmed);
if (directParse && directParse.kind !== "channel" && !isLikelyUsername(trimmed)) {
return directParse;
}
@ -107,7 +107,7 @@ export async function resolveDiscordTarget(
}
// Fallback to original parsing (for channels, etc.)
return parseDiscordTarget(trimmed, options);
return parseDiscordTarget(trimmed);
}
/**