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:
parent
14e4b88bf0
commit
a69f64470d
@ -5,6 +5,7 @@ import {
|
|||||||
estimateMessagesTokens,
|
estimateMessagesTokens,
|
||||||
pruneHistoryForContextShare,
|
pruneHistoryForContextShare,
|
||||||
splitMessagesByTokenShare,
|
splitMessagesByTokenShare,
|
||||||
|
truncateMessagesForSummary,
|
||||||
} from "./compaction.js";
|
} from "./compaction.js";
|
||||||
|
|
||||||
function makeMessage(id: number, size: number): AgentMessage {
|
function makeMessage(id: number, size: number): AgentMessage {
|
||||||
@ -147,3 +148,67 @@ describe("pruneHistoryForContextShare", () => {
|
|||||||
expect(pruned.messages.length).toBe(1);
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import { DEFAULT_CONTEXT_TOKENS } from "./defaults.js";
|
|||||||
export const BASE_CHUNK_RATIO = 0.4;
|
export const BASE_CHUNK_RATIO = 0.4;
|
||||||
export const MIN_CHUNK_RATIO = 0.15;
|
export const MIN_CHUNK_RATIO = 0.15;
|
||||||
export const SAFETY_MARGIN = 1.2; // 20% buffer for estimateTokens() inaccuracy
|
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_SUMMARY_FALLBACK = "No prior history.";
|
||||||
const DEFAULT_PARTS = 2;
|
const DEFAULT_PARTS = 2;
|
||||||
const MERGE_SUMMARIES_INSTRUCTIONS =
|
const MERGE_SUMMARIES_INSTRUCTIONS =
|
||||||
@ -127,6 +129,149 @@ export function isOversizedForSummary(msg: AgentMessage, contextWindow: number):
|
|||||||
return tokens > contextWindow * 0.5;
|
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: {
|
async function summarizeChunks(params: {
|
||||||
messages: AgentMessage[];
|
messages: AgentMessage[];
|
||||||
model: NonNullable<ExtensionContext["model"]>;
|
model: NonNullable<ExtensionContext["model"]>;
|
||||||
@ -141,7 +286,9 @@ async function summarizeChunks(params: {
|
|||||||
return params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK;
|
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;
|
let summary = params.previousSummary;
|
||||||
|
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
|
|||||||
@ -5,9 +5,9 @@ import {
|
|||||||
type MessagingTarget,
|
type MessagingTarget,
|
||||||
type MessagingTargetKind,
|
type MessagingTargetKind,
|
||||||
type MessagingTargetParseOptions,
|
type MessagingTargetParseOptions,
|
||||||
type DirectoryConfigParams,
|
|
||||||
type ChannelDirectoryEntry,
|
|
||||||
} from "../channels/targets.js";
|
} 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 { listDiscordDirectoryPeersLive } from "./directory-live.js";
|
||||||
import { resolveDiscordAccount } from "./accounts.js";
|
import { resolveDiscordAccount } from "./accounts.js";
|
||||||
@ -82,7 +82,7 @@ export async function resolveDiscordTarget(
|
|||||||
if (!trimmed) return undefined;
|
if (!trimmed) return undefined;
|
||||||
|
|
||||||
// If already a known format, parse directly
|
// If already a known format, parse directly
|
||||||
const directParse = parseDiscordTarget(trimmed, options);
|
const directParse = parseDiscordTarget(trimmed);
|
||||||
if (directParse && directParse.kind !== "channel" && !isLikelyUsername(trimmed)) {
|
if (directParse && directParse.kind !== "channel" && !isLikelyUsername(trimmed)) {
|
||||||
return directParse;
|
return directParse;
|
||||||
}
|
}
|
||||||
@ -107,7 +107,7 @@ export async function resolveDiscordTarget(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to original parsing (for channels, etc.)
|
// Fallback to original parsing (for channels, etc.)
|
||||||
return parseDiscordTarget(trimmed, options);
|
return parseDiscordTarget(trimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user