From ff334af54beb22fda1e2bc1d9c02ec8078822db3 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 20 Jan 2026 16:44:04 +0100 Subject: [PATCH] feat(core): simplify truncation logic to only keep the newest message --- .../anthropic/scenario-media-truncation.mjs | 8 +- .../suites/tracing/anthropic/test.ts | 5 +- .../core/src/tracing/ai/messageTruncation.ts | 59 ++++-------- .../lib/tracing/ai-message-truncation.test.ts | 96 +++++-------------- 4 files changed, 47 insertions(+), 121 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-media-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-media-truncation.mjs index 73891ad30b6f..7ba92b0498c7 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-media-truncation.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-media-truncation.mjs @@ -53,6 +53,10 @@ async function run() { model: 'claude-3-haiku-20240307', max_tokens: 1024, messages: [ + { + role: 'user', + content: 'what number is this?', + }, { role: 'user', content: [ @@ -66,10 +70,6 @@ async function run() { }, ], }, - { - role: 'user', - content: 'what number is this?', - }, ], temperature: 0.7, }); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts index f62975dafb71..b847fcc796ce 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts @@ -677,6 +677,7 @@ describe('Anthropic integration', () => { 'sentry.origin': 'auto.ai.anthropic', 'gen_ai.system': 'anthropic', 'gen_ai.request.model': 'claude-3-haiku-20240307', + // Only the last message (with filtered media) should be kept 'gen_ai.request.messages': JSON.stringify([ { role: 'user', @@ -691,10 +692,6 @@ describe('Anthropic integration', () => { }, ], }, - { - role: 'user', - content: 'what number is this?', - }, ]), }), description: 'messages claude-3-haiku-20240307', diff --git a/packages/core/src/tracing/ai/messageTruncation.ts b/packages/core/src/tracing/ai/messageTruncation.ts index 9c8718387404..2d2512f30f37 100644 --- a/packages/core/src/tracing/ai/messageTruncation.ts +++ b/packages/core/src/tracing/ai/messageTruncation.ts @@ -374,19 +374,19 @@ function stripInlineMediaFromMessages(messages: unknown[]): unknown[] { * Truncate an array of messages to fit within a byte limit. * * Strategy: - * - Keeps the newest messages (from the end of the array) - * - Uses O(n) algorithm: precompute sizes once, then find largest suffix under budget - * - If no complete messages fit, attempts to truncate the newest single message + * - Always keeps only the last (newest) message + * - Strips inline media from the message + * - Truncates the message content if it exceeds the byte limit * * @param messages - Array of messages to truncate - * @param maxBytes - Maximum total byte limit for all messages - * @returns Truncated array of messages + * @param maxBytes - Maximum total byte limit for the message + * @returns Array containing only the last message (possibly truncated) * * @example * ```ts * const messages = [msg1, msg2, msg3, msg4]; // newest is msg4 * const truncated = truncateMessagesByBytes(messages, 10000); - * // Returns [msg3, msg4] if they fit, or [msg4] if only it fits, etc. + * // Returns [msg4] (truncated if needed) * ``` */ function truncateMessagesByBytes(messages: unknown[], maxBytes: number): unknown[] { @@ -395,46 +395,21 @@ function truncateMessagesByBytes(messages: unknown[], maxBytes: number): unknown return messages; } - // strip inline media first. This will often get us below the threshold, - // while preserving human-readable information about messages sent. - const stripped = stripInlineMediaFromMessages(messages); - - // Fast path: if all messages fit, return as-is - const totalBytes = jsonBytes(stripped); - if (totalBytes <= maxBytes) { - return stripped; - } + // Always keep only the last message + const lastMessage = messages[messages.length - 1]; - // Precompute each message's JSON size once for efficiency - const messageSizes = stripped.map(jsonBytes); + // Strip inline media from the single message + const stripped = stripInlineMediaFromMessages([lastMessage]); + const strippedMessage = stripped[0]; - // Find the largest suffix (newest messages) that fits within the budget - let bytesUsed = 0; - let startIndex = stripped.length; // Index where the kept suffix starts - - for (let i = stripped.length - 1; i >= 0; i--) { - const messageSize = messageSizes[i]; - - if (messageSize && bytesUsed + messageSize > maxBytes) { - // Adding this message would exceed the budget - break; - } - - if (messageSize) { - bytesUsed += messageSize; - } - startIndex = i; - } - - // If no complete messages fit, try truncating just the newest message - if (startIndex === stripped.length) { - // we're truncating down to one message, so all others dropped. - const newestMessage = stripped[stripped.length - 1]; - return truncateSingleMessage(newestMessage, maxBytes); + // Check if it fits + const messageBytes = jsonBytes(strippedMessage); + if (messageBytes <= maxBytes) { + return stripped; } - // Return the suffix that fits - return stripped.slice(startIndex); + // Truncate the single message if needed + return truncateSingleMessage(strippedMessage, maxBytes); } /** diff --git a/packages/core/test/lib/tracing/ai-message-truncation.test.ts b/packages/core/test/lib/tracing/ai-message-truncation.test.ts index 968cd2308bb7..8a8cefaffa5b 100644 --- a/packages/core/test/lib/tracing/ai-message-truncation.test.ts +++ b/packages/core/test/lib/tracing/ai-message-truncation.test.ts @@ -96,33 +96,8 @@ describe('message truncation utilities', () => { // original messages objects must not be mutated expect(JSON.stringify(messages, null, 2)).toBe(messagesJson); + // only the last message should be kept (with media stripped) expect(result).toStrictEqual([ - { - role: 'user', - content: [ - { - type: 'image', - source: { - type: 'base64', - media_type: 'image/png', - data: removed, - }, - }, - ], - }, - { - role: 'user', - content: { - image_url: removed, - }, - }, - { - role: 'agent', - type: 'image', - content: { - b64_json: removed, - }, - }, { role: 'system', inlineData: { @@ -177,39 +152,35 @@ describe('message truncation utilities', () => { const giant = 'this is a long string '.repeat(1_000); const big = 'this is a long string '.repeat(100); - it('drops older messages to fit in the limit', () => { + it('keeps only the last message without truncation when it fits the limit', () => { + // Multiple messages that together exceed 20KB, but last message is small const messages = [ - `0 ${giant}`, - { type: 'text', content: `1 ${big}` }, - { type: 'text', content: `2 ${big}` }, - { type: 'text', content: `3 ${giant}` }, - { type: 'text', content: `4 ${big}` }, - `5 ${big}`, - { type: 'text', content: `6 ${big}` }, - { type: 'text', content: `7 ${big}` }, - { type: 'text', content: `8 ${big}` }, - { type: 'text', content: `9 ${big}` }, - { type: 'text', content: `10 ${big}` }, - { type: 'text', content: `11 ${big}` }, - { type: 'text', content: `12 ${big}` }, + { content: `1 ${humongous}` }, + { content: `2 ${humongous}` }, + { content: `3 ${big}` }, // last message - small enough to fit ]; - const messagesJson = JSON.stringify(messages, null, 2); const result = truncateGenAiMessages(messages); - // should not mutate original messages list - expect(JSON.stringify(messages, null, 2)).toBe(messagesJson); - // just retain the messages that fit in the budget - expect(result).toStrictEqual([ - `5 ${big}`, - { type: 'text', content: `6 ${big}` }, - { type: 'text', content: `7 ${big}` }, - { type: 'text', content: `8 ${big}` }, - { type: 'text', content: `9 ${big}` }, - { type: 'text', content: `10 ${big}` }, - { type: 'text', content: `11 ${big}` }, - { type: 'text', content: `12 ${big}` }, - ]); + // Should only keep the last message, unchanged + expect(result).toStrictEqual([{ content: `3 ${big}` }]); + }); + + it('keeps only the last message with truncation when it does not fit the limit', () => { + const messages = [{ content: `1 ${humongous}` }, { content: `2 ${humongous}` }, { content: `3 ${humongous}` }]; + const result = truncateGenAiMessages(messages); + const truncLen = 20_000 - JSON.stringify({ content: '' }).length; + expect(result).toStrictEqual([{ content: `3 ${humongous}`.substring(0, truncLen) }]); + }); + + it('drops if last message cannot be safely truncated', () => { + const messages = [ + { content: `1 ${humongous}` }, + { content: `2 ${humongous}` }, + { what_even_is_this: `? ${humongous}` }, + ]; + const result = truncateGenAiMessages(messages); + expect(result).toStrictEqual([]); }); it('fully drops message if content cannot be made to fit', () => { @@ -315,22 +286,5 @@ describe('message truncation utilities', () => { }, ]); }); - - it('truncates first message if none fit', () => { - const messages = [{ content: `1 ${humongous}` }, { content: `2 ${humongous}` }, { content: `3 ${humongous}` }]; - const result = truncateGenAiMessages(messages); - const truncLen = 20_000 - JSON.stringify({ content: '' }).length; - expect(result).toStrictEqual([{ content: `3 ${humongous}`.substring(0, truncLen) }]); - }); - - it('drops if first message cannot be safely truncated', () => { - const messages = [ - { content: `1 ${humongous}` }, - { content: `2 ${humongous}` }, - { what_even_is_this: `? ${humongous}` }, - ]; - const result = truncateGenAiMessages(messages); - expect(result).toStrictEqual([]); - }); }); });