Skip to content
Open
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
76 changes: 65 additions & 11 deletions libs/deepagents/deepagents/middleware/_overflow_clip.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
- Any other tool result: full offload to `/large_tool_results/{tool_call_id}`
via the shared eviction helper, then replace the message with a
large-tool-result stub.

Results already smaller than the slice size are left untouched -- rewriting
them would not shrink the batch and would falsely tell the agent its output
was truncated.
"""

from __future__ import annotations
Expand All @@ -32,6 +36,9 @@

from deepagents.backends.protocol import BackendProtocol

_SLICE_CHARS = 4_000
"""Head-slice size for a clipped `read_file` result."""


def _derive_overflow_clip_threshold_tokens(keep: ContextSize, max_input_tokens: int | None) -> int:
"""Derive a token threshold for tail-ToolMessage clipping from `keep`.
Expand Down Expand Up @@ -82,15 +89,52 @@ def _slice_read_file_tm(msg: ToolMessage, original_path: str) -> ToolMessage:
truncation notice mirrors `READ_FILE_TRUNCATION_MSG` in shape so the
agent encounters a consistent format whether the tool truncated itself
or the middleware did.

Media blocks (image/audio/video) are dropped rather than carried over: this
path runs *because* the batch overflowed the context window, so keeping an
inline base64 payload would defeat the clip. They are replaced by a pointer
to `original_path`, which still holds the file, so the agent can re-read it.

Each notice is emitted only for what actually happened, so a result that
was not really truncated doesn't claim it was.
"""
content = _extract_text_from_message(msg)
notice = (
f"\n\n[Output was truncated due to context window size limits. "
f"The full content is at {original_path}. "
f"Use read_file with offset and limit parameters to retrieve specific portions. "
f"For example, to read the first 100 lines, call read_file with file_path='{original_path}', offset=0, limit=100.]"
)
return msg.model_copy(update={"content": content[:4_000] + notice})
has_media = any(block["type"] != "text" for block in msg.content_blocks)
truncated = len(content) > _SLICE_CHARS
notice = ""
if truncated:
notice += (
f"\n\n[Output was truncated due to context window size limits. "
f"The full content is at {original_path}. "
f"Use read_file with offset and limit parameters to retrieve specific portions. "
f"For example, to read the first 100 lines, call read_file with file_path='{original_path}', offset=0, limit=100.]"
)
if has_media:
notice += (
f"\n\n[Media content was removed due to context window size limits. "
f"The original file is at {original_path}. "
f"Call read_file with file_path='{original_path}' to view it again.]"
)
return msg.model_copy(update={"content": content[:_SLICE_CHARS] + notice})


def _is_worth_clipping(msg: ToolMessage) -> bool:
"""Whether clipping `msg` can actually shrink the batch.

A result already smaller than the slice size has nothing to give: the
replacement is a fixed truncation notice (or, on the generic path, a
large-tool-result stub with a head+tail preview) that can easily be
*longer* than the result it replaces. Rewriting it only destroys a result
that was never the problem and tells the agent its output was truncated
when it wasn't.

Media blocks always count as worth clipping -- an inline base64 payload is
exactly the kind of bulk this path exists to shed, and its size doesn't
show up in the extracted text.
"""
if any(block["type"] != "text" for block in msg.content_blocks):
return True
return len(_extract_text_from_message(msg)) > _SLICE_CHARS


def _read_file_original_path(msg: ToolMessage, tc_index: dict[str, dict[str, Any]]) -> str | None:
Expand All @@ -108,7 +152,13 @@ def _clip_one_tail_message(
backend: BackendProtocol,
large_tool_results_prefix: str,
) -> ToolMessage | None:
"""Apply the appropriate per-TM clip: read_file slice vs generic eviction."""
"""Apply the appropriate per-TM clip: read_file slice vs generic eviction.

Returns `None` -- keep the original -- for results too small to be worth
clipping.
"""
if not _is_worth_clipping(msg):
return None
original_path = _read_file_original_path(msg, tc_index)
if original_path is not None:
return _slice_read_file_tm(msg, original_path)
Expand All @@ -122,6 +172,8 @@ async def _aclip_one_tail_message(
large_tool_results_prefix: str,
) -> ToolMessage | None:
"""Async variant of `_clip_one_tail_message`."""
if not _is_worth_clipping(msg):
return None
original_path = _read_file_original_path(msg, tc_index)
if original_path is not None:
return _slice_read_file_tm(msg, original_path)
Expand All @@ -141,8 +193,10 @@ def _clip_overflow_tail(

Engages only when `preserved_messages` ends with consecutive ToolMessages
whose combined token count reaches `_derive_overflow_clip_threshold_tokens()`.
Each large TM is written under `large_tool_results/{tool_call_id}` and
replaced in-place by an offload-pointer ToolMessage.
Only TMs big enough for clipping to help are rewritten, so results that
weren't the problem survive intact. Each clipped non-`read_file` TM is
written under `large_tool_results/{tool_call_id}` and replaced in-place by
an offload-pointer ToolMessage.

Returns `(modified preserved_messages, replacement TMs to persist in
state)`. Replacements carry the original ids so the `add_messages`
Expand Down Expand Up @@ -182,7 +236,7 @@ async def _aclip_overflow_tail(
token_counter: TokenCounter,
large_tool_results_prefix: str,
) -> tuple[list[AnyMessage], list[AnyMessage]]:
"""Async variant of `_clip_overflow_tail`. Offloads each tail TM concurrently."""
"""Async variant of `_clip_overflow_tail`. Offloads each clipped tail TM concurrently."""
found = _find_tail_tool_message_batch(preserved_messages)
if found is None:
return preserved_messages, []
Expand Down
106 changes: 106 additions & 0 deletions libs/deepagents/tests/unit_tests/middleware/test_overflow_clip.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""Tests for the summarization-on-overflow tail clipping (`_overflow_clip`)."""

from __future__ import annotations

import tempfile

import pytest
from langchain_core.messages import AIMessage, AnyMessage, ToolMessage

from deepagents.backends.filesystem import FilesystemBackend
from deepagents.middleware._overflow_clip import _aclip_overflow_tail, _clip_overflow_tail


def _backend() -> FilesystemBackend:
return FilesystemBackend(root_dir=tempfile.mkdtemp(), virtual_mode=True)


def _read_file_turn(tool_call_id: str, path: str, content: str | list[dict]) -> list[AnyMessage]:
ai = AIMessage(content="", tool_calls=[{"id": tool_call_id, "name": "read_file", "args": {"file_path": path}}])
tm = ToolMessage(tool_call_id=tool_call_id, name="read_file", content=content)
return [ai, tm]


def _chars(msgs: list[AnyMessage]) -> int:
return sum(len(str(m.content)) for m in msgs)


def _mixed_batch() -> list[AnyMessage]:
"""One oversized `read_file` result plus a small result from another tool."""
return [
AIMessage(
content="",
tool_calls=[
{"id": "big", "name": "read_file", "args": {"file_path": "/big.txt"}},
{"id": "small", "name": "grep", "args": {"pattern": "x"}},
],
),
ToolMessage(tool_call_id="big", name="read_file", content="x" * 10_000),
ToolMessage(tool_call_id="small", name="grep", content="tiny"),
]


def _clip(messages: list[AnyMessage], counter, keep_tokens: int = 1) -> list[AnyMessage]:
new_messages, _ = _clip_overflow_tail(
messages,
_backend(),
keep=("tokens", keep_tokens),
max_input_tokens=1000,
token_counter=counter,
large_tool_results_prefix="/large_tool_results",
)
return new_messages


def test_image_read_file_result_is_replaced_by_a_path_pointer() -> None:
"""A media-only `read_file` result is clipped to a pointer at its original path (#4954)."""
messages = _read_file_turn("call_1", "/pic.png", [{"type": "image", "base64": "aGVsbG8=", "mime_type": "image/png"}])

clipped = _clip(messages, lambda _msgs: 10_000)[-1]

assert isinstance(clipped.content, str)
assert "Media content was removed" in clipped.content
assert "/pic.png" in clipped.content
# The inline payload must not survive -- carrying it over would defeat the clip.
assert "aGVsbG8=" not in clipped.content


def test_small_results_are_left_untouched() -> None:
"""Clipping stops once the batch fits, so small siblings keep their content (#4954).

The sibling is a non-`read_file` result, which is always offloaded when
selected -- so surviving intact proves it was never selected.
"""
messages = _mixed_batch()

# Batch is 10_004 "tokens"; clipping the big result alone drops it to 4.
new_messages = _clip(messages, _chars, keep_tokens=5_000)

assert "Output was truncated" in new_messages[-2].content
assert new_messages[-1].content == "tiny"


def test_nothing_to_clip_leaves_messages_alone() -> None:
"""A short text result gains no false truncation notice (#4954)."""
messages = _read_file_turn("call_1", "/f.txt", "short")

new_messages = _clip(messages, lambda _msgs: 10_000)

assert new_messages is messages
assert new_messages[-1].content == "short"


@pytest.mark.asyncio
async def test_async_clip_matches_sync_selection() -> None:
"""The async path clips the same subset as the sync path (#4954)."""
new_messages, _ = await _aclip_overflow_tail(
_mixed_batch(),
_backend(),
keep=("tokens", 5_000),
max_input_tokens=1000,
token_counter=_chars,
large_tool_results_prefix="/large_tool_results",
)

assert "Output was truncated" in new_messages[-2].content
assert new_messages[-1].content == "tiny"