From a69f64470d54699b4718cd91ebeac4bb827fb2ad Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Tue, 27 Jan 2026 20:29:50 -0800 Subject: [PATCH] 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 --- src/agents/compaction.test.ts | 65 +++++++++++++++ src/agents/compaction.ts | 149 +++++++++++++++++++++++++++++++++- src/discord/targets.ts | 8 +- 3 files changed, 217 insertions(+), 5 deletions(-) diff --git a/src/agents/compaction.test.ts b/src/agents/compaction.test.ts index 32511a586..7dd973585 100644 --- a/src/agents/compaction.test.ts +++ b/src/agents/compaction.test.ts @@ -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).content).toBe("string"); + const content = (result as Extract).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).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).content).toBe("short message"); + }); + + it("returns empty array for empty input", () => { + const result = truncateMessagesForSummary([], 100); + expect(result).toEqual([]); + }); +}); diff --git a/src/agents/compaction.ts b/src/agents/compaction.ts index a88447307..5d750919d 100644 --- a/src/agents/compaction.ts +++ b/src/agents/compaction.ts @@ -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; + 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; + if (Array.isArray(assistant.content)) { + return collectAssistantContentText(assistant.content); + } + return ""; + } + if (role === "toolResult") { + const tool = msg as Extract; + 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; + const content = typeof user.content === "string" ? text : [{ type: "text", text }]; + return { ...user, content }; + } + if (role === "assistant") { + const assistant = msg as Extract; + return { ...assistant, content: [{ type: "text", text }] }; + } + if (role === "toolResult") { + const tool = msg as Extract; + 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; @@ -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) { diff --git a/src/discord/targets.ts b/src/discord/targets.ts index 311955182..02ded763b 100644 --- a/src/discord/targets.ts +++ b/src/discord/targets.ts @@ -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); } /**