Skip to content

Commit 46676a9

Browse files
feat(ai): Add single message truncation (#5079)
Implement character-based truncation for AI responses. If the last message's serialized size exceeds a byte threshold, truncate the message list to the last message and trim its content by characters. Co-authored-by: Alexander Alderman Webb <[email protected]>
1 parent 1b7085d commit 46676a9

File tree

2 files changed

+77
-5
lines changed

2 files changed

+77
-5
lines changed

sentry_sdk/ai/utils.py

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import inspect
22
import json
3+
from copy import deepcopy
34
from collections import deque
45
from typing import TYPE_CHECKING
56
from sys import getsizeof
@@ -13,6 +14,8 @@
1314
from sentry_sdk.utils import logger
1415

1516
MAX_GEN_AI_MESSAGE_BYTES = 20_000 # 20KB
17+
# Maximum characters when only a single message is left after bytes truncation
18+
MAX_SINGLE_MESSAGE_CONTENT_CHARS = 10_000
1619

1720

1821
class GEN_AI_ALLOWED_MESSAGE_ROLES:
@@ -107,6 +110,23 @@ def get_start_span_function():
107110
return sentry_sdk.start_span if transaction_exists else sentry_sdk.start_transaction
108111

109112

113+
def _truncate_single_message_content_if_present(message, max_chars):
114+
# type: (Dict[str, Any], int) -> Dict[str, Any]
115+
"""
116+
Truncate a message's content to at most `max_chars` characters and append an
117+
ellipsis if truncation occurs.
118+
"""
119+
if not isinstance(message, dict) or "content" not in message:
120+
return message
121+
content = message["content"]
122+
123+
if not isinstance(content, str) or len(content) <= max_chars:
124+
return message
125+
126+
message["content"] = content[:max_chars] + "..."
127+
return message
128+
129+
110130
def _find_truncation_index(messages, max_bytes):
111131
# type: (List[Dict[str, Any]], int) -> int
112132
"""
@@ -124,16 +144,41 @@ def _find_truncation_index(messages, max_bytes):
124144
return 0
125145

126146

127-
def truncate_messages_by_size(messages, max_bytes=MAX_GEN_AI_MESSAGE_BYTES):
128-
# type: (List[Dict[str, Any]], int) -> Tuple[List[Dict[str, Any]], int]
147+
def truncate_messages_by_size(
148+
messages,
149+
max_bytes=MAX_GEN_AI_MESSAGE_BYTES,
150+
max_single_message_chars=MAX_SINGLE_MESSAGE_CONTENT_CHARS,
151+
):
152+
# type: (List[Dict[str, Any]], int, int) -> Tuple[List[Dict[str, Any]], int]
153+
"""
154+
Returns a truncated messages list, consisting of
155+
- the last message, with its content truncated to `max_single_message_chars` characters,
156+
if the last message's size exceeds `max_bytes` bytes; otherwise,
157+
- the maximum number of messages, starting from the end of the `messages` list, whose total
158+
serialized size does not exceed `max_bytes` bytes.
159+
160+
In the single message case, the serialized message size may exceed `max_bytes`, because
161+
truncation is based only on character count in that case.
162+
"""
129163
serialized_json = json.dumps(messages, separators=(",", ":"))
130164
current_size = len(serialized_json.encode("utf-8"))
131165

132166
if current_size <= max_bytes:
133167
return messages, 0
134168

135169
truncation_index = _find_truncation_index(messages, max_bytes)
136-
return messages[truncation_index:], truncation_index
170+
if truncation_index < len(messages):
171+
truncated_messages = messages[truncation_index:]
172+
else:
173+
truncation_index = len(messages) - 1
174+
truncated_messages = messages[-1:]
175+
176+
if len(truncated_messages) == 1:
177+
truncated_messages[0] = _truncate_single_message_content_if_present(
178+
deepcopy(truncated_messages[0]), max_chars=max_single_message_chars
179+
)
180+
181+
return truncated_messages, truncation_index
137182

138183

139184
def truncate_and_annotate_messages(

tests/test_ai_monitoring.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from sentry_sdk.ai.monitoring import ai_track
99
from sentry_sdk.ai.utils import (
1010
MAX_GEN_AI_MESSAGE_BYTES,
11+
MAX_SINGLE_MESSAGE_CONTENT_CHARS,
1112
set_data_normalized,
1213
truncate_and_annotate_messages,
1314
truncate_messages_by_size,
@@ -226,8 +227,7 @@ def test_truncation_removes_oldest_first(self, large_messages):
226227
)
227228
assert len(result) < len(large_messages)
228229

229-
if result:
230-
assert result[-1] == large_messages[-1]
230+
assert result[-1] == large_messages[-1]
231231
assert truncation_index == len(large_messages) - len(result)
232232

233233
def test_empty_messages_list(self):
@@ -278,6 +278,33 @@ def test_progressive_truncation(self, large_messages):
278278
assert current_count >= 1
279279
prev_count = current_count
280280

281+
def test_single_message_truncation(self):
282+
large_content = "This is a very long message. " * 10_000
283+
284+
messages = [
285+
{"role": "system", "content": "You are a helpful assistant."},
286+
{"role": "user", "content": large_content},
287+
]
288+
289+
result, truncation_index = truncate_messages_by_size(
290+
messages, max_single_message_chars=MAX_SINGLE_MESSAGE_CONTENT_CHARS
291+
)
292+
293+
assert len(result) == 1
294+
assert (
295+
len(result[0]["content"].rstrip("...")) <= MAX_SINGLE_MESSAGE_CONTENT_CHARS
296+
)
297+
298+
# If the last message is too large, the system message is not present
299+
system_msgs = [m for m in result if m.get("role") == "system"]
300+
assert len(system_msgs) == 0
301+
302+
# Confirm the user message is truncated with '...'
303+
user_msgs = [m for m in result if m.get("role") == "user"]
304+
assert len(user_msgs) == 1
305+
assert user_msgs[0]["content"].endswith("...")
306+
assert len(user_msgs[0]["content"]) < len(large_content)
307+
281308

282309
class TestTruncateAndAnnotateMessages:
283310
def test_no_truncation_returns_list(self, sample_messages):

0 commit comments

Comments
 (0)