Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""Internal primitives for conversation context compression.

Not part of the public API. Consumers import directly from the submodules
(``pin_message``, ``context_compression``).
(``pin_message``, ``context_compression``, ``truncate_text``).
"""
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""Last-resort text truncation for when summarization cannot reduce the context."""

import re
from collections.abc import Iterator

from ....types.content import ContentBlock, Message, Messages
from ....types.tools import ToolResultContent
from .pin_message import is_pinned

_TRUNCATION_MARKER = re.compile(r"\.\.\. \[truncated: \d+ chars removed\] \.\.\.")
_PRESERVE_CHARS = 2_000


def _text_blocks(message: Message) -> Iterator[ContentBlock | ToolResultContent]:
"""Yield every dict carrying a ``text`` key: content blocks and items inside toolResult content."""
for content in message.get("content", []):
if not isinstance(content, dict):
continue # type: ignore[unreachable]
if "text" in content:
yield content
for item in content.get("toolResult", {}).get("content", []):
if isinstance(item, dict) and "text" in item:
yield item


def _truncate_oversized_text_blocks(messages: Messages, preserve_chars: int = _PRESERVE_CHARS) -> bool:
"""Replace every oversized, untruncated text block in non-pinned messages with a head+tail preview.

A block qualifies when truncating it saves more than ``preserve_chars`` characters, so each call
either shrinks the history by a meaningful amount or reports that nothing is left to truncate.

Args:
messages: The conversation history, modified in place.
preserve_chars: Characters kept at each end of a truncated text.

Returns:
True if at least one block was truncated.
"""
if preserve_chars <= 0:
return False

truncated = False
for index, message in enumerate(messages):
if is_pinned(messages, index):
continue
for block in _text_blocks(message):
text = block["text"]
removed = len(text) - 2 * preserve_chars
if removed <= preserve_chars or _TRUNCATION_MARKER.search(text):
continue
marker = f"...\n\n... [truncated: {removed} chars removed] ...\n\n..."
block["text"] = f"{text[:preserve_chars]}{marker}{text[-preserve_chars:]}"
truncated = True
return truncated
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
generate_summary,
)
from .compression.pin_message import apply_pin_first, partition_pinned
from .compression.truncate_text import _truncate_oversized_text_blocks
from .conversation_manager import ConversationManager, ProactiveCompressionConfig

if TYPE_CHECKING:
Expand Down Expand Up @@ -118,8 +119,12 @@ def apply_management(self, agent: "Agent", **kwargs: Any) -> None:
def reduce_context(self, agent: "Agent", e: Exception | None = None, **kwargs: Any) -> None:
"""Reduce context using summarization.

When ``e`` is set (reactive overflow recovery), summarization failure is re-raised —
the agent loop must not proceed with an overflow.
When ``e`` is set (reactive overflow recovery) and summarization fails with a
``ContextWindowOverflowException`` — too few messages to summarize, or the summary call itself
overflows — oversized text blocks in non-pinned messages are truncated to head+tail previews
instead; the failure is re-raised when nothing is left to truncate. Any other summarization
error (throttling, network) is re-raised untouched, since a retry may still succeed.
Truncation edits ``agent.messages`` only; a session store keeps the original content.

When ``e`` is None (proactive compression), summarization failure is logged and
returns silently — the model call proceeds regardless.
Expand All @@ -134,17 +139,28 @@ def reduce_context(self, agent: "Agent", e: Exception | None = None, **kwargs: A
``invocation_context`` the summary call's usage adds to.

Raises:
Exception: If summarization fails during reactive overflow recovery (e is set).
Exception: If summarization fails during reactive overflow recovery (e is set) for a
reason other than overflow, or no text block is left to truncate.
"""
try:
self._summarize_oldest(agent, kwargs.get("invocation_context"))
except Exception as summarization_error:
if e is not None:
# Reactive: rethrow so the ContextWindowOverflowException propagates
logger.error("Summarization failed: %s", summarization_error)
raise summarization_error from e
# Proactive: best-effort, swallow errors so the model call can still proceed.
logger.warning("Proactive summarization failed, continuing: %s", summarization_error)
if e is None:
# Proactive: best-effort, swallow errors so the model call can still proceed.
logger.warning("Proactive summarization failed, continuing: %s", summarization_error)
return
# Only an overflow proves summarization cannot shrink the context; anything else may pass on retry.
if isinstance(summarization_error, ContextWindowOverflowException) and _truncate_oversized_text_blocks(
agent.messages
):
logger.warning(
"error=<%s> | summarization could not reduce context, truncated oversized text blocks instead",
summarization_error,
)
return
# Reactive: rethrow so the ContextWindowOverflowException propagates
logger.error("Summarization failed: %s", summarization_error)
raise summarization_error from e

def _summarize_oldest(self, agent: "Agent", invocation_context: InvocationContext | None = None) -> None:
"""Summarize the oldest messages and replace them with a summary.
Expand All @@ -154,7 +170,9 @@ def _summarize_oldest(self, agent: "Agent", invocation_context: InvocationContex
invocation_context: The request the summary call's usage adds to.

Raises:
ContextWindowOverflowException: If there are insufficient messages for summarization.
ContextWindowOverflowException: If there are insufficient messages for summarization, or the
only message old enough to summarize is the previous summary (re-summarizing it would
not shrink the history, so the overflow would recur forever).
"""
# Calculate how many messages to summarize
messages_to_summarize_count = max(1, int(len(agent.messages) * self.summary_ratio))
Expand Down Expand Up @@ -185,19 +203,21 @@ def _summarize_oldest(self, agent: "Agent", invocation_context: InvocationContex

if not to_summarize:
raise ContextWindowOverflowException("Cannot summarize: all messages in summarize range are pinned")
if to_summarize == [self._summary_message]:
raise ContextWindowOverflowException("Cannot summarize: only the previous summary is old enough")

remaining_messages = agent.messages[messages_to_summarize_count:]

summary_message = self._generate_summary(to_summarize, agent, invocation_context)
# Assign tracking id to the summary message since it bypasses the append method.
_ensure_tracking_id(summary_message)

# Keep track of the number of messages that have been summarized thus far.
self.removed_message_count += len(to_summarize)
# If there is a summary message, don't count it in the removed_message_count.
if self._summary_message:
self.removed_message_count -= 1

# Generate summary
self._summary_message = self._generate_summary(to_summarize, agent, invocation_context)
# Assign tracking id to the summary message since it bypasses the append method.
_ensure_tracking_id(self._summary_message)
self._summary_message = summary_message

# Replace summarized range with protected messages + summary + remaining
agent.messages[:] = protected_to_preserve + [self._summary_message] + remaining_messages
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Tests for last-resort text truncation."""

from strands.agent.conversation_manager.compression.pin_message import pin_message
from strands.agent.conversation_manager.compression.truncate_text import _truncate_oversized_text_blocks


def _text_message(text, role="user"):
return {"role": role, "content": [{"text": text}]}


def _tool_result_message(text, tool_use_id="t1"):
return {
"role": "user",
"content": [{"toolResult": {"toolUseId": tool_use_id, "status": "success", "content": [{"text": text}]}}],
}


class TestTruncateOversizedTextBlocks:
def test_truncates_to_head_and_tail(self):
messages = [_text_message("small"), _text_message("h" * 100 + "m" * 200 + "t" * 100)]

assert _truncate_oversized_text_blocks(messages, preserve_chars=100) is True

assert messages[0]["content"][0]["text"] == "small"
truncated = messages[1]["content"][0]["text"]
assert truncated == "h" * 100 + "...\n\n... [truncated: 200 chars removed] ...\n\n..." + "t" * 100

def test_truncates_every_oversized_block_in_one_pass(self):
messages = [_text_message("a" * 1_000), _tool_result_message("b" * 1_000), _text_message("c" * 1_000)]

assert _truncate_oversized_text_blocks(messages, preserve_chars=10) is True

assert "[truncated: 980 chars removed]" in messages[0]["content"][0]["text"]
assert "[truncated: 980 chars removed]" in messages[1]["content"][0]["toolResult"]["content"][0]["text"]
assert "[truncated: 980 chars removed]" in messages[2]["content"][0]["text"]

def test_requires_a_meaningful_saving(self):
# Saving must exceed preserve_chars: 3 * preserve is the floor.
messages = [_text_message("z" * 300), _text_message("y" * 301)]

assert _truncate_oversized_text_blocks(messages, preserve_chars=100) is True

assert messages[0]["content"][0]["text"] == "z" * 300
assert "[truncated: 101 chars removed]" in messages[1]["content"][0]["text"]

def test_returns_false_when_nothing_qualifies(self):
messages = [_text_message("short"), _tool_result_message("also short"), {"role": "user", "content": ["raw"]}]

assert _truncate_oversized_text_blocks(messages, preserve_chars=100) is False

def test_does_not_retruncate(self):
messages = [_text_message("a" * 1_000)]

assert _truncate_oversized_text_blocks(messages, preserve_chars=10) is True
once = messages[0]["content"][0]["text"]
assert _truncate_oversized_text_blocks(messages, preserve_chars=10) is False
assert messages[0]["content"][0]["text"] == once

def test_user_text_mentioning_truncated_is_still_truncated(self):
messages = [_text_message("... [truncated: lol] " + "a" * 1_000)]

assert _truncate_oversized_text_blocks(messages, preserve_chars=10) is True

def test_skips_pinned_messages(self):
messages = [_text_message("p" * 1_000), _text_message("q" * 500)]
pin_message(messages, 0)

assert _truncate_oversized_text_blocks(messages, preserve_chars=10) is True

assert messages[0]["content"][0]["text"] == "p" * 1_000
assert "[truncated:" in messages[1]["content"][0]["text"]

def test_non_positive_preserve_chars_is_a_no_op(self):
messages = [_text_message("a" * 1_000)]

assert _truncate_oversized_text_blocks(messages, preserve_chars=0) is False
assert messages[0]["content"][0]["text"] == "a" * 1_000
Loading
Loading