Skip to content

Commit 44d11a3

Browse files
authored
fix(sdk): explain truncation markers only when preview omits lines
1 parent 863c615 commit 44d11a3

6 files changed

Lines changed: 247 additions & 43 deletions

File tree

libs/deepagents/deepagents/backends/utils.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,17 @@
2424
MAX_VIDEO_INPUT_BYTES: Final = 1024 * 1024 * 1024
2525
"""Maximum raw video payload size accepted by `read_file` frame extraction."""
2626

27+
TRUNCATION_MARKER_TEMPLATE: Final = "... [{omitted_lines} lines truncated] ..."
28+
"""Marker standing in for lines dropped from the middle of a head/tail preview.
29+
30+
Shared by `_message_eviction._create_content_preview` and the capture wrapper
31+
in `backends.sandbox` so both emit identical marker text.
32+
33+
Never scan preview text for this marker to detect truncation: output can
34+
contain a literal marker line. Producers report marker presence out of band
35+
instead (see `ExecuteOffloadResult.preview_has_truncation_marker`).
36+
"""
37+
2738
FileType = Literal["text", "image", "audio", "video", "file"]
2839
"""Classification of a file by extension."""
2940

libs/deepagents/deepagents/middleware/_message_eviction.py

Lines changed: 131 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -11,56 +11,129 @@
1111

1212
from __future__ import annotations
1313

14-
from typing import TYPE_CHECKING, cast
14+
from dataclasses import dataclass
15+
from typing import TYPE_CHECKING, Final, cast
1516

1617
from langchain_core.messages import BaseMessage, ToolMessage
1718

18-
from deepagents.backends.utils import format_content_with_line_numbers, sanitize_tool_call_id
19+
from deepagents.backends.utils import (
20+
TRUNCATION_MARKER_TEMPLATE,
21+
format_content_with_line_numbers,
22+
sanitize_tool_call_id,
23+
)
1924

2025
if TYPE_CHECKING:
2126
from langchain_core.messages.content import ContentBlock
2227

2328
from deepagents.backends.protocol import BackendProtocol
2429

25-
TOO_LARGE_TOOL_MSG = """Tool result too large, the result of this tool call {tool_call_id} was saved in the filesystem at this path: {file_path}
30+
_TOO_LARGE_TOOL_MSG = """Tool result too large, the result of this tool call {tool_call_id} was saved in the filesystem at this path: {file_path}
2631
2732
You can read the result from the filesystem by using the read_file tool, but make sure to only read part of the result at a time.
2833
2934
You can do this by specifying an offset and limit in the read_file tool call. For example, to read the first 100 lines, you can use the read_file tool with offset=0 and limit=100.
3035
31-
Here is a preview showing the head and tail of the result (lines of the form `... [N lines truncated] ...` indicate omitted lines in the middle of the content):
36+
{preview_note}
3237
3338
{content_sample}
3439
"""
3540

41+
_PREVIEW_LINE_CHAR_LIMIT: Final = 1000
42+
"""Per-line character budget for preview lines.
3643
37-
def _create_content_preview(content_str: str, *, head_lines: int = 5, tail_lines: int = 5) -> str:
38-
"""Create a preview of content showing head and tail with truncation marker.
44+
Bounds previews of few-but-huge lines (a `.jsonl` dump, a minified bundle).
45+
Keep below `backends.utils.MAX_LINE_LENGTH`, or clipped lines also pick up
46+
that renderer's `N.1` continuation gutters.
47+
"""
48+
49+
_PREVIEW_NOTE_PLAIN = "Here is a preview of the {subject}"
50+
_PREVIEW_NOTE_HEAD_TAIL = "Here is a preview showing the head and tail of the {subject}"
51+
52+
_CAVEAT_OMITTED_LINES = (
53+
f"lines of the form `{TRUNCATION_MARKER_TEMPLATE.format(omitted_lines='N')}` indicate omitted lines in the middle of the content"
54+
)
55+
56+
57+
@dataclass(frozen=True, slots=True)
58+
class ContentPreview:
59+
"""A rendered preview plus a record of what was left out to build it.
60+
61+
`lines_omitted` is reported by the code that built `text`, never inferred
62+
from the rendered bytes — a literal `... [N lines truncated] ...` line in
63+
the content would otherwise pass for a real marker.
64+
"""
65+
66+
text: str
67+
"""The rendered, line-numbered preview."""
68+
69+
lines_omitted: bool
70+
"""Whole lines were dropped from the middle, behind a truncation marker."""
71+
72+
73+
def _preview_note(*, lines_omitted: bool, subject: str = "result") -> str:
74+
"""Build the sentence introducing a preview.
75+
76+
Explains the truncation marker only when the preview actually has one, so
77+
the model is never told to look for a marker that was not inserted.
78+
79+
Args:
80+
lines_omitted: Whole lines were dropped from the middle behind a
81+
truncation marker.
82+
subject: Noun for what is being previewed, e.g. `result`.
83+
84+
Returns:
85+
The note, ending in a colon.
86+
87+
For example:
88+
89+
- `lines_omitted=False`: `Here is a preview of the result:`
90+
- `lines_omitted=True`: `Here is a preview showing the head and tail
91+
of the result (lines of the form ... indicate omitted lines ...):`
92+
"""
93+
base = _PREVIEW_NOTE_HEAD_TAIL if lines_omitted else _PREVIEW_NOTE_PLAIN
94+
note = base.format(subject=subject)
95+
if lines_omitted:
96+
note += f" ({_CAVEAT_OMITTED_LINES})"
97+
return f"{note}:"
98+
99+
100+
def _create_content_preview(content_str: str, *, head_lines: int = 5, tail_lines: int = 5) -> ContentPreview:
101+
"""Create a line-numbered preview of `content_str`.
102+
103+
Shows all lines when they fit within `head_lines + tail_lines`, otherwise
104+
the head and tail around a `... [N lines truncated] ...` marker.
39105
40106
Args:
41107
content_str: The full content string to preview.
42108
head_lines: Number of lines to show from the start.
43109
tail_lines: Number of lines to show from the end.
44110
45111
Returns:
46-
Formatted preview string with line numbers.
112+
The formatted preview plus a record of what was left out to build it.
47113
"""
48114
lines = content_str.splitlines()
49115

50116
if len(lines) <= head_lines + tail_lines:
51117
# If file is small enough, show all lines
52-
preview_lines = [line[:1000] for line in lines]
53-
return format_content_with_line_numbers(preview_lines, start_line=1)
118+
preview_lines = [line[:_PREVIEW_LINE_CHAR_LIMIT] for line in lines]
119+
return ContentPreview(
120+
format_content_with_line_numbers(preview_lines, start_line=1),
121+
lines_omitted=False,
122+
)
54123

55124
# Show head and tail with truncation marker
56-
head = [line[:1000] for line in lines[:head_lines]]
57-
tail = [line[:1000] for line in lines[-tail_lines:]]
125+
head = [line[:_PREVIEW_LINE_CHAR_LIMIT] for line in lines[:head_lines]]
126+
tail = [line[:_PREVIEW_LINE_CHAR_LIMIT] for line in lines[-tail_lines:]]
58127

59128
head_sample = format_content_with_line_numbers(head, start_line=1)
60-
truncation_notice = f"\n... [{len(lines) - head_lines - tail_lines} lines truncated] ...\n"
129+
marker = TRUNCATION_MARKER_TEMPLATE.format(omitted_lines=len(lines) - head_lines - tail_lines)
130+
truncation_notice = f"\n{marker}\n"
61131
tail_sample = format_content_with_line_numbers(tail, start_line=len(lines) - tail_lines + 1)
62132

63-
return head_sample + truncation_notice + tail_sample
133+
return ContentPreview(
134+
head_sample + truncation_notice + tail_sample,
135+
lines_omitted=True,
136+
)
64137

65138

66139
def _extract_text_from_message(message: BaseMessage) -> str:
@@ -116,6 +189,48 @@ def _build_evicted_tool_message(message: ToolMessage, evicted_content: str | lis
116189
)
117190

118191

192+
def _render_preview_stub(template: str, preview: ContentPreview, *, subject: str = "result", **fields: str) -> str:
193+
"""Render `template` around `preview`, deriving the note from that same preview.
194+
195+
The only way to fill a `{preview_note}`/`{content_sample}` template, so the
196+
note cannot end up describing losses some other preview had.
197+
198+
Args:
199+
template: Stub text with `{preview_note}` and `{content_sample}`
200+
placeholders, plus whatever `fields` supplies.
201+
preview: The preview to render and to derive the note from.
202+
subject: Noun for what is being previewed, e.g. `result`.
203+
fields: Remaining template placeholders, e.g. `file_path`.
204+
205+
Returns:
206+
The rendered stub, ready to use as message content.
207+
"""
208+
return template.format(
209+
preview_note=_preview_note(lines_omitted=preview.lines_omitted, subject=subject),
210+
content_sample=preview.text,
211+
**fields,
212+
)
213+
214+
215+
def _render_too_large_tool_msg(*, tool_call_id: str, file_path: str, content_str: str) -> str:
216+
"""Render the large-tool-result stub for `content_str`.
217+
218+
Args:
219+
tool_call_id: Tool call whose result was offloaded.
220+
file_path: Path the full content was written to.
221+
content_str: The full content being previewed.
222+
223+
Returns:
224+
The rendered stub, ready to use as message content.
225+
"""
226+
return _render_preview_stub(
227+
_TOO_LARGE_TOOL_MSG,
228+
_create_content_preview(content_str),
229+
tool_call_id=tool_call_id,
230+
file_path=file_path,
231+
)
232+
233+
119234
def _offload_tool_message_content(
120235
message: ToolMessage,
121236
content_str: str,
@@ -125,7 +240,7 @@ def _offload_tool_message_content(
125240
"""Write `content_str` to `{prefix}/{tool_call_id}` and return a clipped replacement.
126241
127242
The replacement carries a head+tail preview and the offload path in
128-
`TOO_LARGE_TOOL_MSG` format so the agent can `read_file` the full content
243+
large-tool-result format so the agent can `read_file` the full content
129244
by tool_call_id. Returns `None` if the backend write fails — caller should
130245
keep the original message in that case.
131246
"""
@@ -134,11 +249,7 @@ def _offload_tool_message_content(
134249
result = backend.write(file_path, content_str)
135250
if result is None or result.error:
136251
return None
137-
replacement_text = TOO_LARGE_TOOL_MSG.format(
138-
tool_call_id=message.tool_call_id,
139-
file_path=file_path,
140-
content_sample=_create_content_preview(content_str),
141-
)
252+
replacement_text = _render_too_large_tool_msg(tool_call_id=message.tool_call_id, file_path=file_path, content_str=content_str)
142253
return _build_evicted_tool_message(message, _build_evicted_content(message, replacement_text))
143254

144255

@@ -154,9 +265,5 @@ async def _aoffload_tool_message_content(
154265
result = await backend.awrite(file_path, content_str)
155266
if result is None or result.error:
156267
return None
157-
replacement_text = TOO_LARGE_TOOL_MSG.format(
158-
tool_call_id=message.tool_call_id,
159-
file_path=file_path,
160-
content_sample=_create_content_preview(content_str),
161-
)
268+
replacement_text = _render_too_large_tool_msg(tool_call_id=message.tool_call_id, file_path=file_path, content_str=content_str)
162269
return _build_evicted_tool_message(message, _build_evicted_content(message, replacement_text))

libs/deepagents/deepagents/middleware/_overflow_clip.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
is needed because the original file already lives at that path.
1111
- Any other tool result: full offload to `/large_tool_results/{tool_call_id}`
1212
via the shared eviction helper, then replace the message with a
13-
`TOO_LARGE_TOOL_MSG` stub.
13+
large-tool-result stub.
1414
"""
1515

1616
from __future__ import annotations

libs/deepagents/deepagents/middleware/filesystem.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,13 @@
7777
validate_path,
7878
)
7979
from deepagents.middleware._message_eviction import (
80-
TOO_LARGE_TOOL_MSG as TOO_LARGE_TOOL_MSG,
80+
_TOO_LARGE_TOOL_MSG,
81+
ContentPreview,
8182
_aoffload_tool_message_content,
8283
_create_content_preview,
8384
_extract_text_from_message,
8485
_offload_tool_message_content,
86+
_render_preview_stub,
8587
)
8688
from deepagents.middleware._utils import append_to_system_message
8789
from deepagents.middleware._video import (
@@ -1485,11 +1487,11 @@ def supports_execution(backend: BackendProtocol) -> bool:
14851487
)
14861488

14871489

1488-
TOO_LARGE_HUMAN_MSG = """Message content too large and was saved to the filesystem at: {file_path}
1490+
_TOO_LARGE_HUMAN_MSG = """Message content too large and was saved to the filesystem at: {file_path}
14891491
14901492
You can read the full content using the read_file tool with pagination (offset and limit parameters).
14911493
1492-
Here is a preview showing the head and tail of the content:
1494+
{preview_note}
14931495
14941496
{content_sample}
14951497
"""
@@ -1535,10 +1537,11 @@ def _build_truncated_human_message(message: HumanMessage, file_path: str) -> Hum
15351537
A new HumanMessage with truncated content and the same `id`.
15361538
"""
15371539
content_str = _extract_text_from_message(message)
1538-
content_sample = _create_content_preview(content_str)
1539-
replacement_text = TOO_LARGE_HUMAN_MSG.format(
1540+
replacement_text = _render_preview_stub(
1541+
_TOO_LARGE_HUMAN_MSG,
1542+
_create_content_preview(content_str),
1543+
subject="content",
15401544
file_path=file_path,
1541-
content_sample=content_sample,
15421545
)
15431546
evicted = _build_evicted_human_content(message, replacement_text)
15441547
return message.model_copy(update={"content": evicted})
@@ -2803,10 +2806,11 @@ def _interpret_capture_output(self, offload: ExecuteOffloadResult, capture_path:
28032806
if response.truncated:
28042807
status_line += "\n[Output exceeded the capture size limit and was truncated; the saved file is incomplete]"
28052808
content_sample = f"{status_line}\n{response.output}"
2806-
return TOO_LARGE_TOOL_MSG.format(
2809+
return _render_preview_stub(
2810+
_TOO_LARGE_TOOL_MSG,
2811+
ContentPreview(content_sample, lines_omitted="lines truncated" in response.output),
28072812
tool_call_id=tool_call_id,
28082813
file_path=capture_path,
2809-
content_sample=content_sample,
28102814
)
28112815

28122816
def _create_execute_tool(self) -> BaseTool: # noqa: C901

libs/deepagents/tests/unit_tests/test_end_to_end.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4340,7 +4340,7 @@ def test_summarization_clips_ls_batch_on_overflow() -> None:
43404340
`ls` isn't read_file, so the tail-clip path falls through to
43414341
`_offload_tool_message_content`: full content written under
43424342
`/large_tool_results/{tcid}` and the message replaced with a
4343-
`TOO_LARGE_TOOL_MSG` stub.
4343+
large-tool-result stub.
43444344
"""
43454345
fake_model = _OverflowOnLargeInputModel(messages=iter([AIMessage(content="summary text"), AIMessage(content="final response")]))
43464346
fake_model.call_history = []

0 commit comments

Comments
 (0)