1111
1212from __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
1617from 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
2025if 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
2732You 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
2934You 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
66139def _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+
119234def _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 ))
0 commit comments