deepagents 0.3.5__py3-none-any.whl → 0.3.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,10 +20,8 @@ from langgraph.types import Command
20
20
  from typing_extensions import TypedDict
21
21
 
22
22
  from deepagents.backends import StateBackend
23
-
24
- # Re-export type here for backwards compatibility
25
- from deepagents.backends.protocol import BACKEND_TYPES as BACKEND_TYPES
26
23
  from deepagents.backends.protocol import (
24
+ BACKEND_TYPES as BACKEND_TYPES, # Re-export for backwards compatibility
27
25
  BackendProtocol,
28
26
  EditResult,
29
27
  SandboxBackendProtocol,
@@ -35,12 +33,12 @@ from deepagents.backends.utils import (
35
33
  sanitize_tool_call_id,
36
34
  truncate_if_too_long,
37
35
  )
36
+ from deepagents.middleware._utils import append_to_system_message
38
37
 
39
38
  EMPTY_CONTENT_WARNING = "System reminder: File exists but has empty contents"
40
- MAX_LINE_LENGTH = 2000
41
39
  LINE_NUMBER_WIDTH = 6
42
40
  DEFAULT_READ_OFFSET = 0
43
- DEFAULT_READ_LIMIT = 500
41
+ DEFAULT_READ_LIMIT = 100
44
42
 
45
43
 
46
44
  class FileData(TypedDict):
@@ -156,27 +154,24 @@ class FilesystemState(AgentState):
156
154
  """Files in the filesystem."""
157
155
 
158
156
 
159
- LIST_FILES_TOOL_DESCRIPTION = """Lists all files in the filesystem, filtering by directory.
157
+ LIST_FILES_TOOL_DESCRIPTION = """Lists all files in a directory.
160
158
 
161
- Usage:
162
- - The path parameter must be an absolute path, not a relative path
163
- - The list_files tool will return a list of all files in the specified directory.
164
- - This is very useful for exploring the file system and finding the right file to read or edit.
165
- - You should almost ALWAYS use this tool before using the Read or Edit tools."""
159
+ This is useful for exploring the filesystem and finding the right file to read or edit.
160
+ You should almost ALWAYS use this tool before using the read_file or edit_file tools."""
166
161
 
167
- READ_FILE_TOOL_DESCRIPTION = """Reads a file from the filesystem. You can access any file directly by using this tool.
168
- Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
162
+ READ_FILE_TOOL_DESCRIPTION = """Reads a file from the filesystem.
163
+
164
+ Assume this tool is able to read all files. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
169
165
 
170
166
  Usage:
171
- - The file_path parameter must be an absolute path, not a relative path
172
- - By default, it reads up to 500 lines starting from the beginning of the file
167
+ - By default, it reads up to 100 lines starting from the beginning of the file
173
168
  - **IMPORTANT for large files and codebase exploration**: Use pagination with offset and limit parameters to avoid context overflow
174
169
  - First scan: read_file(path, limit=100) to see file structure
175
170
  - Read more sections: read_file(path, offset=100, limit=200) for next 200 lines
176
171
  - Only omit limit (read full file) when necessary for editing
177
172
  - Specify offset and limit: read_file(path, offset=0, limit=100) reads first 100 lines
178
- - Any lines longer than 2000 characters will be truncated
179
173
  - Results are returned using cat -n format, with line numbers starting at 1
174
+ - Lines longer than 5,000 characters will be split into multiple lines with continuation markers (e.g., 5.1, 5.2, etc.). When you specify a limit, these continuation lines count towards the limit.
180
175
  - You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful.
181
176
  - If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.
182
177
  - You should ALWAYS make sure a file has been read before editing it."""
@@ -184,61 +179,46 @@ Usage:
184
179
  EDIT_FILE_TOOL_DESCRIPTION = """Performs exact string replacements in files.
185
180
 
186
181
  Usage:
187
- - You must use your `Read` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
188
- - When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual file content to match. Never include any part of the line number prefix in the old_string or new_string.
189
- - ALWAYS prefer editing existing files. NEVER write new files unless explicitly required.
190
- - Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
191
- - The edit will FAIL if `old_string` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use `replace_all` to change every instance of `old_string`.
192
- - Use `replace_all` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance."""
182
+ - You must read the file before editing. This tool will error if you attempt an edit without reading the file first.
183
+ - When editing, preserve the exact indentation (tabs/spaces) from the read output. Never include line number prefixes in old_string or new_string.
184
+ - ALWAYS prefer editing existing files over creating new ones.
185
+ - Only use emojis if the user explicitly requests it."""
193
186
 
194
187
 
195
188
  WRITE_FILE_TOOL_DESCRIPTION = """Writes to a new file in the filesystem.
196
189
 
197
190
  Usage:
198
- - The file_path parameter must be an absolute path, not a relative path
199
- - The content parameter must be a string
200
191
  - The write_file tool will create the a new file.
201
- - Prefer to edit existing files over creating new ones when possible."""
202
-
192
+ - Prefer to edit existing files (with the edit_file tool) over creating new ones when possible.
193
+ """
203
194
 
204
195
  GLOB_TOOL_DESCRIPTION = """Find files matching a glob pattern.
205
196
 
206
- Usage:
207
- - The glob tool finds files by matching patterns with wildcards
208
- - Supports standard glob patterns: `*` (any characters), `**` (any directories), `?` (single character)
209
- - Patterns can be absolute (starting with `/`) or relative
210
- - Returns a list of absolute file paths that match the pattern
197
+ Supports standard glob patterns: `*` (any characters), `**` (any directories), `?` (single character).
198
+ Returns a list of absolute file paths that match the pattern.
211
199
 
212
200
  Examples:
213
201
  - `**/*.py` - Find all Python files
214
202
  - `*.txt` - Find all text files in root
215
203
  - `/subdir/**/*.md` - Find all markdown files under /subdir"""
216
204
 
217
- GREP_TOOL_DESCRIPTION = """Search for a pattern in files.
205
+ GREP_TOOL_DESCRIPTION = """Search for a text pattern across files.
218
206
 
219
- Usage:
220
- - The grep tool searches for text patterns across files
221
- - The pattern parameter is the text to search for (literal string, not regex)
222
- - The path parameter filters which directory to search in (default is the current working directory)
223
- - The glob parameter accepts a glob pattern to filter which files to search (e.g., `*.py`)
224
- - The output_mode parameter controls the output format:
225
- - `files_with_matches`: List only file paths containing matches (default)
226
- - `content`: Show matching lines with file path and line numbers
227
- - `count`: Show count of matches per file
207
+ Searches for literal text (not regex) and returns matching files or content based on output_mode.
228
208
 
229
209
  Examples:
230
210
  - Search all files: `grep(pattern="TODO")`
231
211
  - Search Python files only: `grep(pattern="import", glob="*.py")`
232
212
  - Show matching lines: `grep(pattern="error", output_mode="content")`"""
233
213
 
234
- EXECUTE_TOOL_DESCRIPTION = """Executes a given command in the sandbox environment with proper handling and security measures.
214
+ EXECUTE_TOOL_DESCRIPTION = """Executes a shell command in an isolated sandbox environment.
235
215
 
216
+ Usage:
217
+ Executes a given command in the sandbox environment with proper handling and security measures.
236
218
  Before executing the command, please follow these steps:
237
-
238
219
  1. Directory Verification:
239
220
  - If the command will create new directories or files, first use the ls tool to verify the parent directory exists and is the correct location
240
221
  - For example, before running "mkdir foo/bar", first use ls to check that "foo" exists and is the intended parent directory
241
-
242
222
  2. Command Execution:
243
223
  - Always quote file paths that contain spaces with double quotes (e.g., cd "path with spaces/file.txt")
244
224
  - Examples of proper quoting:
@@ -248,9 +228,7 @@ Before executing the command, please follow these steps:
248
228
  - python /path/with spaces/script.py (incorrect - will fail)
249
229
  - After ensuring proper quoting, execute the command
250
230
  - Capture the output of the command
251
-
252
231
  Usage notes:
253
- - The command parameter is required
254
232
  - Commands run in an isolated sandbox environment
255
233
  - Returns combined stdout/stderr output with exit code
256
234
  - If the output is very large, it may be truncated
@@ -325,7 +303,10 @@ def _ls_tool_generator(
325
303
  """
326
304
  tool_description = custom_description or LIST_FILES_TOOL_DESCRIPTION
327
305
 
328
- def sync_ls(runtime: ToolRuntime[None, FilesystemState], path: str) -> str:
306
+ def sync_ls(
307
+ runtime: ToolRuntime[None, FilesystemState],
308
+ path: Annotated[str, "Absolute path to the directory to list. Must be absolute, not relative."],
309
+ ) -> str:
329
310
  """Synchronous wrapper for ls tool."""
330
311
  resolved_backend = _get_backend(backend, runtime)
331
312
  validated_path = _validate_path(path)
@@ -334,7 +315,10 @@ def _ls_tool_generator(
334
315
  result = truncate_if_too_long(paths)
335
316
  return str(result)
336
317
 
337
- async def async_ls(runtime: ToolRuntime[None, FilesystemState], path: str) -> str:
318
+ async def async_ls(
319
+ runtime: ToolRuntime[None, FilesystemState],
320
+ path: Annotated[str, "Absolute path to the directory to list. Must be absolute, not relative."],
321
+ ) -> str:
338
322
  """Asynchronous wrapper for ls tool."""
339
323
  resolved_backend = _get_backend(backend, runtime)
340
324
  validated_path = _validate_path(path)
@@ -367,26 +351,40 @@ def _read_file_tool_generator(
367
351
  tool_description = custom_description or READ_FILE_TOOL_DESCRIPTION
368
352
 
369
353
  def sync_read_file(
370
- file_path: str,
354
+ file_path: Annotated[str, "Absolute path to the file to read. Must be absolute, not relative."],
371
355
  runtime: ToolRuntime[None, FilesystemState],
372
- offset: int = DEFAULT_READ_OFFSET,
373
- limit: int = DEFAULT_READ_LIMIT,
356
+ offset: Annotated[int, "Line number to start reading from (0-indexed). Use for pagination of large files."] = DEFAULT_READ_OFFSET,
357
+ limit: Annotated[int, "Maximum number of lines to read. Use for pagination of large files."] = DEFAULT_READ_LIMIT,
374
358
  ) -> str:
375
359
  """Synchronous wrapper for read_file tool."""
376
360
  resolved_backend = _get_backend(backend, runtime)
377
361
  file_path = _validate_path(file_path)
378
- return resolved_backend.read(file_path, offset=offset, limit=limit)
362
+ result = resolved_backend.read(file_path, offset=offset, limit=limit)
363
+
364
+ lines = result.splitlines(keepends=True)
365
+ if len(lines) > limit:
366
+ lines = lines[:limit]
367
+ result = "".join(lines)
368
+
369
+ return result
379
370
 
380
371
  async def async_read_file(
381
- file_path: str,
372
+ file_path: Annotated[str, "Absolute path to the file to read. Must be absolute, not relative."],
382
373
  runtime: ToolRuntime[None, FilesystemState],
383
- offset: int = DEFAULT_READ_OFFSET,
384
- limit: int = DEFAULT_READ_LIMIT,
374
+ offset: Annotated[int, "Line number to start reading from (0-indexed). Use for pagination of large files."] = DEFAULT_READ_OFFSET,
375
+ limit: Annotated[int, "Maximum number of lines to read. Use for pagination of large files."] = DEFAULT_READ_LIMIT,
385
376
  ) -> str:
386
377
  """Asynchronous wrapper for read_file tool."""
387
378
  resolved_backend = _get_backend(backend, runtime)
388
379
  file_path = _validate_path(file_path)
389
- return await resolved_backend.aread(file_path, offset=offset, limit=limit)
380
+ result = await resolved_backend.aread(file_path, offset=offset, limit=limit)
381
+
382
+ lines = result.splitlines(keepends=True)
383
+ if len(lines) > limit:
384
+ lines = lines[:limit]
385
+ result = "".join(lines)
386
+
387
+ return result
390
388
 
391
389
  return StructuredTool.from_function(
392
390
  name="read_file",
@@ -412,8 +410,8 @@ def _write_file_tool_generator(
412
410
  tool_description = custom_description or WRITE_FILE_TOOL_DESCRIPTION
413
411
 
414
412
  def sync_write_file(
415
- file_path: str,
416
- content: str,
413
+ file_path: Annotated[str, "Absolute path where the file should be created. Must be absolute, not relative."],
414
+ content: Annotated[str, "The text content to write to the file. This parameter is required."],
417
415
  runtime: ToolRuntime[None, FilesystemState],
418
416
  ) -> Command | str:
419
417
  """Synchronous wrapper for write_file tool."""
@@ -438,8 +436,8 @@ def _write_file_tool_generator(
438
436
  return f"Updated file {res.path}"
439
437
 
440
438
  async def async_write_file(
441
- file_path: str,
442
- content: str,
439
+ file_path: Annotated[str, "Absolute path where the file should be created. Must be absolute, not relative."],
440
+ content: Annotated[str, "The text content to write to the file. This parameter is required."],
443
441
  runtime: ToolRuntime[None, FilesystemState],
444
442
  ) -> Command | str:
445
443
  """Asynchronous wrapper for write_file tool."""
@@ -487,12 +485,12 @@ def _edit_file_tool_generator(
487
485
  tool_description = custom_description or EDIT_FILE_TOOL_DESCRIPTION
488
486
 
489
487
  def sync_edit_file(
490
- file_path: str,
491
- old_string: str,
492
- new_string: str,
488
+ file_path: Annotated[str, "Absolute path to the file to edit. Must be absolute, not relative."],
489
+ old_string: Annotated[str, "The exact text to find and replace. Must be unique in the file unless replace_all is True."],
490
+ new_string: Annotated[str, "The text to replace old_string with. Must be different from old_string."],
493
491
  runtime: ToolRuntime[None, FilesystemState],
494
492
  *,
495
- replace_all: bool = False,
493
+ replace_all: Annotated[bool, "If True, replace all occurrences of old_string. If False (default), old_string must be unique."] = False,
496
494
  ) -> Command | str:
497
495
  """Synchronous wrapper for edit_file tool."""
498
496
  resolved_backend = _get_backend(backend, runtime)
@@ -515,12 +513,12 @@ def _edit_file_tool_generator(
515
513
  return f"Successfully replaced {res.occurrences} instance(s) of the string in '{res.path}'"
516
514
 
517
515
  async def async_edit_file(
518
- file_path: str,
519
- old_string: str,
520
- new_string: str,
516
+ file_path: Annotated[str, "Absolute path to the file to edit. Must be absolute, not relative."],
517
+ old_string: Annotated[str, "The exact text to find and replace. Must be unique in the file unless replace_all is True."],
518
+ new_string: Annotated[str, "The text to replace old_string with. Must be different from old_string."],
521
519
  runtime: ToolRuntime[None, FilesystemState],
522
520
  *,
523
- replace_all: bool = False,
521
+ replace_all: Annotated[bool, "If True, replace all occurrences of old_string. If False (default), old_string must be unique."] = False,
524
522
  ) -> Command | str:
525
523
  """Asynchronous wrapper for edit_file tool."""
526
524
  resolved_backend = _get_backend(backend, runtime)
@@ -565,7 +563,11 @@ def _glob_tool_generator(
565
563
  """
566
564
  tool_description = custom_description or GLOB_TOOL_DESCRIPTION
567
565
 
568
- def sync_glob(pattern: str, runtime: ToolRuntime[None, FilesystemState], path: str = "/") -> str:
566
+ def sync_glob(
567
+ pattern: Annotated[str, "Glob pattern to match files (e.g., '**/*.py', '*.txt', '/subdir/**/*.md')."],
568
+ runtime: ToolRuntime[None, FilesystemState],
569
+ path: Annotated[str, "Base directory to search from. Defaults to root '/'."] = "/",
570
+ ) -> str:
569
571
  """Synchronous wrapper for glob tool."""
570
572
  resolved_backend = _get_backend(backend, runtime)
571
573
  infos = resolved_backend.glob_info(pattern, path=path)
@@ -573,7 +575,11 @@ def _glob_tool_generator(
573
575
  result = truncate_if_too_long(paths)
574
576
  return str(result)
575
577
 
576
- async def async_glob(pattern: str, runtime: ToolRuntime[None, FilesystemState], path: str = "/") -> str:
578
+ async def async_glob(
579
+ pattern: Annotated[str, "Glob pattern to match files (e.g., '**/*.py', '*.txt', '/subdir/**/*.md')."],
580
+ runtime: ToolRuntime[None, FilesystemState],
581
+ path: Annotated[str, "Base directory to search from. Defaults to root '/'."] = "/",
582
+ ) -> str:
577
583
  """Asynchronous wrapper for glob tool."""
578
584
  resolved_backend = _get_backend(backend, runtime)
579
585
  infos = await resolved_backend.aglob_info(pattern, path=path)
@@ -605,11 +611,14 @@ def _grep_tool_generator(
605
611
  tool_description = custom_description or GREP_TOOL_DESCRIPTION
606
612
 
607
613
  def sync_grep(
608
- pattern: str,
614
+ pattern: Annotated[str, "Text pattern to search for (literal string, not regex)."],
609
615
  runtime: ToolRuntime[None, FilesystemState],
610
- path: str | None = None,
611
- glob: str | None = None,
612
- output_mode: Literal["files_with_matches", "content", "count"] = "files_with_matches",
616
+ path: Annotated[str | None, "Directory to search in. Defaults to current working directory."] = None,
617
+ glob: Annotated[str | None, "Glob pattern to filter which files to search (e.g., '*.py')."] = None,
618
+ output_mode: Annotated[
619
+ Literal["files_with_matches", "content", "count"],
620
+ "Output format: 'files_with_matches' (file paths only, default), 'content' (matching lines with context), 'count' (match counts per file).",
621
+ ] = "files_with_matches",
613
622
  ) -> str:
614
623
  """Synchronous wrapper for grep tool."""
615
624
  resolved_backend = _get_backend(backend, runtime)
@@ -620,11 +629,14 @@ def _grep_tool_generator(
620
629
  return truncate_if_too_long(formatted) # type: ignore[arg-type]
621
630
 
622
631
  async def async_grep(
623
- pattern: str,
632
+ pattern: Annotated[str, "Text pattern to search for (literal string, not regex)."],
624
633
  runtime: ToolRuntime[None, FilesystemState],
625
- path: str | None = None,
626
- glob: str | None = None,
627
- output_mode: Literal["files_with_matches", "content", "count"] = "files_with_matches",
634
+ path: Annotated[str | None, "Directory to search in. Defaults to current working directory."] = None,
635
+ glob: Annotated[str | None, "Glob pattern to filter which files to search (e.g., '*.py')."] = None,
636
+ output_mode: Annotated[
637
+ Literal["files_with_matches", "content", "count"],
638
+ "Output format: 'files_with_matches' (file paths only, default), 'content' (matching lines with context), 'count' (match counts per file).",
639
+ ] = "files_with_matches",
628
640
  ) -> str:
629
641
  """Asynchronous wrapper for grep tool."""
630
642
  resolved_backend = _get_backend(backend, runtime)
@@ -681,7 +693,7 @@ def _execute_tool_generator(
681
693
  tool_description = custom_description or EXECUTE_TOOL_DESCRIPTION
682
694
 
683
695
  def sync_execute(
684
- command: str,
696
+ command: Annotated[str, "Shell command to execute in the sandbox environment."],
685
697
  runtime: ToolRuntime[None, FilesystemState],
686
698
  ) -> str:
687
699
  """Synchronous wrapper for execute tool."""
@@ -714,7 +726,7 @@ def _execute_tool_generator(
714
726
  return "".join(parts)
715
727
 
716
728
  async def async_execute(
717
- command: str,
729
+ command: Annotated[str, "Shell command to execute in the sandbox environment."],
718
730
  runtime: ToolRuntime[None, FilesystemState],
719
731
  ) -> str:
720
732
  """Asynchronous wrapper for execute tool."""
@@ -801,21 +813,32 @@ Here are the first 10 lines of the result:
801
813
  class FilesystemMiddleware(AgentMiddleware):
802
814
  """Middleware for providing filesystem and optional execution tools to an agent.
803
815
 
804
- This middleware adds filesystem tools to the agent: ls, read_file, write_file,
805
- edit_file, glob, and grep. Files can be stored using any backend that implements
806
- the BackendProtocol.
816
+ This middleware adds filesystem tools to the agent: `ls`, `read_file`, `write_file`,
817
+ `edit_file`, `glob`, and `grep`.
818
+
819
+ Files can be stored using any backend that implements the `BackendProtocol`.
807
820
 
808
- If the backend implements SandboxBackendProtocol, an execute tool is also added
821
+ If the backend implements `SandboxBackendProtocol`, an `execute` tool is also added
809
822
  for running shell commands.
810
823
 
824
+ This middleware also automatically evicts large tool results to the file system when
825
+ they exceed a token threshold, preventing context window saturation.
826
+
811
827
  Args:
812
- backend: Backend for file storage and optional execution. If not provided, defaults to StateBackend
813
- (ephemeral storage in agent state). For persistent storage or hybrid setups,
814
- use CompositeBackend with custom routes. For execution support, use a backend
815
- that implements SandboxBackendProtocol.
828
+ backend: Backend for file storage and optional execution.
829
+
830
+ If not provided, defaults to `StateBackend` (ephemeral storage in agent state).
831
+
832
+ For persistent storage or hybrid setups, use `CompositeBackend` with custom routes.
833
+
834
+ For execution support, use a backend that implements `SandboxBackendProtocol`.
816
835
  system_prompt: Optional custom system prompt override.
817
836
  custom_tool_descriptions: Optional custom tool descriptions override.
818
- tool_token_limit_before_evict: Optional token limit before evicting a tool result to the filesystem.
837
+ tool_token_limit_before_evict: Token limit before evicting a tool result to the
838
+ filesystem.
839
+
840
+ When exceeded, writes the result using the configured backend and replaces it
841
+ with a truncated preview and file reference.
819
842
 
820
843
  Example:
821
844
  ```python
@@ -923,7 +946,8 @@ class FilesystemMiddleware(AgentMiddleware):
923
946
  system_prompt = "\n\n".join(prompt_parts)
924
947
 
925
948
  if system_prompt:
926
- request = request.override(system_prompt=request.system_prompt + "\n\n" + system_prompt if request.system_prompt else system_prompt)
949
+ new_system_message = append_to_system_message(request.system_message, system_prompt)
950
+ request = request.override(system_message=new_system_message)
927
951
 
928
952
  return handler(request)
929
953
 
@@ -970,7 +994,8 @@ class FilesystemMiddleware(AgentMiddleware):
970
994
  system_prompt = "\n\n".join(prompt_parts)
971
995
 
972
996
  if system_prompt:
973
- request = request.override(system_prompt=request.system_prompt + "\n\n" + system_prompt if request.system_prompt else system_prompt)
997
+ new_system_message = append_to_system_message(request.system_message, system_prompt)
998
+ request = request.override(system_message=new_system_message)
974
999
 
975
1000
  return await handler(request)
976
1001
 
@@ -1044,6 +1069,66 @@ class FilesystemMiddleware(AgentMiddleware):
1044
1069
  processed_message = ToolMessage(
1045
1070
  content=replacement_text,
1046
1071
  tool_call_id=message.tool_call_id,
1072
+ name=message.name,
1073
+ )
1074
+ return processed_message, result.files_update
1075
+
1076
+ async def _aprocess_large_message(
1077
+ self,
1078
+ message: ToolMessage,
1079
+ resolved_backend: BackendProtocol,
1080
+ ) -> tuple[ToolMessage, dict[str, FileData] | None]:
1081
+ """Async version of _process_large_message.
1082
+
1083
+ Uses async backend methods to avoid sync calls in async context.
1084
+ See _process_large_message for full documentation.
1085
+ """
1086
+ # Early exit if eviction not configured
1087
+ if not self.tool_token_limit_before_evict:
1088
+ return message, None
1089
+
1090
+ # Convert content to string once for both size check and eviction
1091
+ # Special case: single text block - extract text directly for readability
1092
+ if (
1093
+ isinstance(message.content, list)
1094
+ and len(message.content) == 1
1095
+ and isinstance(message.content[0], dict)
1096
+ and message.content[0].get("type") == "text"
1097
+ and "text" in message.content[0]
1098
+ ):
1099
+ content_str = str(message.content[0]["text"])
1100
+ elif isinstance(message.content, str):
1101
+ content_str = message.content
1102
+ else:
1103
+ # Multiple blocks or non-text content - stringify entire structure
1104
+ content_str = str(message.content)
1105
+
1106
+ # Check if content exceeds eviction threshold
1107
+ # Using 4 chars per token as a conservative approximation (actual ratio varies by content)
1108
+ # This errs on the high side to avoid premature eviction of content that might fit
1109
+ if len(content_str) <= 4 * self.tool_token_limit_before_evict:
1110
+ return message, None
1111
+
1112
+ # Write content to filesystem using async method
1113
+ sanitized_id = sanitize_tool_call_id(message.tool_call_id)
1114
+ file_path = f"/large_tool_results/{sanitized_id}"
1115
+ result = await resolved_backend.awrite(file_path, content_str)
1116
+ if result.error:
1117
+ return message, None
1118
+
1119
+ # Create truncated preview for the replacement message
1120
+ content_sample = format_content_with_line_numbers([line[:1000] for line in content_str.splitlines()[:10]], start_line=1)
1121
+ replacement_text = TOO_LARGE_TOOL_MSG.format(
1122
+ tool_call_id=message.tool_call_id,
1123
+ file_path=file_path,
1124
+ content_sample=content_sample,
1125
+ )
1126
+
1127
+ # Always return as plain string after eviction
1128
+ processed_message = ToolMessage(
1129
+ content=replacement_text,
1130
+ tool_call_id=message.tool_call_id,
1131
+ name=message.name,
1047
1132
  )
1048
1133
  return processed_message, result.files_update
1049
1134
 
@@ -1103,6 +1188,52 @@ class FilesystemMiddleware(AgentMiddleware):
1103
1188
  return Command(update={**update, "messages": processed_messages, "files": accumulated_file_updates})
1104
1189
  raise AssertionError(f"Unreachable code reached in _intercept_large_tool_result: for tool_result of type {type(tool_result)}")
1105
1190
 
1191
+ async def _aintercept_large_tool_result(self, tool_result: ToolMessage | Command, runtime: ToolRuntime) -> ToolMessage | Command:
1192
+ """Async version of _intercept_large_tool_result.
1193
+
1194
+ Uses async backend methods to avoid sync calls in async context.
1195
+ See _intercept_large_tool_result for full documentation.
1196
+ """
1197
+ if isinstance(tool_result, ToolMessage):
1198
+ resolved_backend = self._get_backend(runtime)
1199
+ processed_message, files_update = await self._aprocess_large_message(
1200
+ tool_result,
1201
+ resolved_backend,
1202
+ )
1203
+ return (
1204
+ Command(
1205
+ update={
1206
+ "files": files_update,
1207
+ "messages": [processed_message],
1208
+ }
1209
+ )
1210
+ if files_update is not None
1211
+ else processed_message
1212
+ )
1213
+
1214
+ if isinstance(tool_result, Command):
1215
+ update = tool_result.update
1216
+ if update is None:
1217
+ return tool_result
1218
+ command_messages = update.get("messages", [])
1219
+ accumulated_file_updates = dict(update.get("files", {}))
1220
+ resolved_backend = self._get_backend(runtime)
1221
+ processed_messages = []
1222
+ for message in command_messages:
1223
+ if not isinstance(message, ToolMessage):
1224
+ processed_messages.append(message)
1225
+ continue
1226
+
1227
+ processed_message, files_update = await self._aprocess_large_message(
1228
+ message,
1229
+ resolved_backend,
1230
+ )
1231
+ processed_messages.append(processed_message)
1232
+ if files_update is not None:
1233
+ accumulated_file_updates.update(files_update)
1234
+ return Command(update={**update, "messages": processed_messages, "files": accumulated_file_updates})
1235
+ raise AssertionError(f"Unreachable code reached in _aintercept_large_tool_result: for tool_result of type {type(tool_result)}")
1236
+
1106
1237
  def wrap_tool_call(
1107
1238
  self,
1108
1239
  request: ToolCallRequest,
@@ -1141,4 +1272,4 @@ class FilesystemMiddleware(AgentMiddleware):
1141
1272
  return await handler(request)
1142
1273
 
1143
1274
  tool_result = await handler(request)
1144
- return self._intercept_large_tool_result(tool_result, request.runtime)
1275
+ return await self._aintercept_large_tool_result(tool_result, request.runtime)
@@ -53,7 +53,6 @@ import logging
53
53
  from collections.abc import Awaitable, Callable
54
54
  from typing import TYPE_CHECKING, Annotated, NotRequired, TypedDict
55
55
 
56
- from langchain.messages import SystemMessage
57
56
  from langchain_core.runnables import RunnableConfig
58
57
 
59
58
  if TYPE_CHECKING:
@@ -69,11 +68,13 @@ from langchain.agents.middleware.types import (
69
68
  from langchain.tools import ToolRuntime
70
69
  from langgraph.runtime import Runtime
71
70
 
71
+ from deepagents.middleware._utils import append_to_system_message
72
+
72
73
  logger = logging.getLogger(__name__)
73
74
 
74
75
 
75
76
  class MemoryState(AgentState):
76
- """State schema for MemoryMiddleware.
77
+ """State schema for `MemoryMiddleware`.
77
78
 
78
79
  Attributes:
79
80
  memory_contents: Dict mapping source paths to their loaded content.
@@ -84,7 +85,7 @@ class MemoryState(AgentState):
84
85
 
85
86
 
86
87
  class MemoryStateUpdate(TypedDict):
87
- """State update for MemoryMiddleware."""
88
+ """State update for `MemoryMiddleware`."""
88
89
 
89
90
  memory_contents: dict[str, str]
90
91
 
@@ -152,14 +153,15 @@ MEMORY_SYSTEM_PROMPT = """<agent_memory>
152
153
 
153
154
 
154
155
  class MemoryMiddleware(AgentMiddleware):
155
- """Middleware for loading agent memory from AGENTS.md files.
156
+ """Middleware for loading agent memory from `AGENTS.md` files.
156
157
 
157
158
  Loads memory content from configured sources and injects into the system prompt.
159
+
158
160
  Supports multiple sources that are combined together.
159
161
 
160
162
  Args:
161
163
  backend: Backend instance or factory function for file operations.
162
- sources: List of MemorySource configurations specifying paths and names.
164
+ sources: List of `MemorySource` configurations specifying paths and names.
163
165
  """
164
166
 
165
167
  state_schema = MemoryState
@@ -175,9 +177,12 @@ class MemoryMiddleware(AgentMiddleware):
175
177
  Args:
176
178
  backend: Backend instance or factory function that takes runtime
177
179
  and returns a backend. Use a factory for StateBackend.
178
- sources: List of memory file paths to load (e.g., ["~/.deepagents/AGENTS.md",
179
- "./.deepagents/AGENTS.md"]). Display names are automatically derived
180
- from the paths. Sources are loaded in order.
180
+ sources: List of memory file paths to load (e.g., `["~/.deepagents/AGENTS.md",
181
+ "./.deepagents/AGENTS.md"]`).
182
+
183
+ Display names are automatically derived from the paths.
184
+
185
+ Sources are loaded in order.
181
186
  """
182
187
  self._backend = backend
183
188
  self.sources = sources
@@ -354,23 +359,20 @@ class MemoryMiddleware(AgentMiddleware):
354
359
  return MemoryStateUpdate(memory_contents=contents)
355
360
 
356
361
  def modify_request(self, request: ModelRequest) -> ModelRequest:
357
- """Inject memory content into the system prompt.
362
+ """Inject memory content into the system message.
358
363
 
359
364
  Args:
360
365
  request: Model request to modify.
361
366
 
362
367
  Returns:
363
- Modified request with memory injected into system prompt.
368
+ Modified request with memory injected into system message.
364
369
  """
365
370
  contents = request.state.get("memory_contents", {})
366
371
  agent_memory = self._format_agent_memory(contents)
367
372
 
368
- if request.system_prompt:
369
- system_prompt = agent_memory + "\n\n" + request.system_prompt
370
- else:
371
- system_prompt = agent_memory
373
+ new_system_message = append_to_system_message(request.system_message, agent_memory)
372
374
 
373
- return request.override(system_message=SystemMessage(system_prompt))
375
+ return request.override(system_message=new_system_message)
374
376
 
375
377
  def wrap_model_call(
376
378
  self,