deepagents 0.2.0__tar.gz → 0.2.1rc2__tar.gz

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.
Files changed (24) hide show
  1. {deepagents-0.2.0/src/deepagents.egg-info → deepagents-0.2.1rc2}/PKG-INFO +2 -2
  2. {deepagents-0.2.0 → deepagents-0.2.1rc2}/pyproject.toml +2 -2
  3. {deepagents-0.2.0 → deepagents-0.2.1rc2}/src/deepagents/backends/composite.py +15 -14
  4. {deepagents-0.2.0 → deepagents-0.2.1rc2}/src/deepagents/backends/filesystem.py +54 -29
  5. {deepagents-0.2.0 → deepagents-0.2.1rc2}/src/deepagents/backends/state.py +34 -5
  6. {deepagents-0.2.0 → deepagents-0.2.1rc2}/src/deepagents/backends/store.py +35 -6
  7. {deepagents-0.2.0 → deepagents-0.2.1rc2}/src/deepagents/backends/utils.py +44 -17
  8. {deepagents-0.2.0 → deepagents-0.2.1rc2}/src/deepagents/middleware/filesystem.py +84 -46
  9. {deepagents-0.2.0 → deepagents-0.2.1rc2}/src/deepagents/middleware/patch_tool_calls.py +3 -3
  10. {deepagents-0.2.0 → deepagents-0.2.1rc2}/src/deepagents/middleware/subagents.py +0 -1
  11. {deepagents-0.2.0 → deepagents-0.2.1rc2/src/deepagents.egg-info}/PKG-INFO +2 -2
  12. {deepagents-0.2.0 → deepagents-0.2.1rc2}/src/deepagents.egg-info/requires.txt +1 -1
  13. {deepagents-0.2.0 → deepagents-0.2.1rc2}/tests/test_middleware.py +331 -75
  14. {deepagents-0.2.0 → deepagents-0.2.1rc2}/LICENSE +0 -0
  15. {deepagents-0.2.0 → deepagents-0.2.1rc2}/README.md +0 -0
  16. {deepagents-0.2.0 → deepagents-0.2.1rc2}/setup.cfg +0 -0
  17. {deepagents-0.2.0 → deepagents-0.2.1rc2}/src/deepagents/__init__.py +0 -0
  18. {deepagents-0.2.0 → deepagents-0.2.1rc2}/src/deepagents/backends/__init__.py +0 -0
  19. {deepagents-0.2.0 → deepagents-0.2.1rc2}/src/deepagents/backends/protocol.py +0 -0
  20. {deepagents-0.2.0 → deepagents-0.2.1rc2}/src/deepagents/graph.py +0 -0
  21. {deepagents-0.2.0 → deepagents-0.2.1rc2}/src/deepagents/middleware/__init__.py +0 -0
  22. {deepagents-0.2.0 → deepagents-0.2.1rc2}/src/deepagents.egg-info/SOURCES.txt +0 -0
  23. {deepagents-0.2.0 → deepagents-0.2.1rc2}/src/deepagents.egg-info/dependency_links.txt +0 -0
  24. {deepagents-0.2.0 → deepagents-0.2.1rc2}/src/deepagents.egg-info/top_level.txt +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepagents
3
- Version: 0.2.0
3
+ Version: 0.2.1rc2
4
4
  Summary: General purpose 'deep agent' with sub-agent spawning, todo list capabilities, and mock file system. Built on LangGraph.
5
5
  License: MIT
6
6
  Requires-Python: <4.0,>=3.11
7
7
  Description-Content-Type: text/markdown
8
8
  License-File: LICENSE
9
9
  Requires-Dist: langchain-anthropic<2.0.0,>=1.0.0
10
- Requires-Dist: langchain<2.0.0,>=1.0.0
10
+ Requires-Dist: langchain<2.0.0,>=1.0.2
11
11
  Requires-Dist: langchain-core<2.0.0,>=1.0.0
12
12
  Requires-Dist: wcmatch
13
13
  Provides-Extra: dev
@@ -1,13 +1,13 @@
1
1
  [project]
2
2
  name = "deepagents"
3
- version = "0.2.0"
3
+ version = "0.2.1rc2"
4
4
  description = "General purpose 'deep agent' with sub-agent spawning, todo list capabilities, and mock file system. Built on LangGraph."
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
7
7
  requires-python = ">=3.11,<4.0"
8
8
  dependencies = [
9
9
  "langchain-anthropic>=1.0.0,<2.0.0",
10
- "langchain>=1.0.0,<2.0.0",
10
+ "langchain>=1.0.2,<2.0.0",
11
11
  "langchain-core>=1.0.0,<2.0.0",
12
12
  "wcmatch"
13
13
  ]
@@ -1,13 +1,10 @@
1
1
  """CompositeBackend: Route operations to different backends based on path prefix."""
2
2
 
3
- from typing import Any, Literal, Optional, TYPE_CHECKING
3
+ from typing import Optional
4
4
 
5
- from langchain.tools import ToolRuntime
6
-
7
- from deepagents.backends.protocol import BackendProtocol, BackendFactory, WriteResult, EditResult
5
+ from deepagents.backends.protocol import BackendProtocol, WriteResult, EditResult
8
6
  from deepagents.backends.state import StateBackend
9
7
  from deepagents.backends.utils import FileInfo, GrepMatch
10
- from deepagents.backends.protocol import BackendFactory
11
8
 
12
9
 
13
10
  class CompositeBackend:
@@ -48,13 +45,14 @@ class CompositeBackend:
48
45
  return self.default, key
49
46
 
50
47
  def ls_info(self, path: str) -> list[FileInfo]:
51
- """List files from backends, with appropriate prefixes.
52
-
48
+ """List files and directories in the specified directory (non-recursive).
49
+
53
50
  Args:
54
51
  path: Absolute path to directory.
55
-
52
+
56
53
  Returns:
57
- List of FileInfo-like dicts with route prefixes added.
54
+ List of FileInfo-like dicts with route prefixes added, for files and directories directly in the directory.
55
+ Directories have a trailing / in their path and is_dir=True.
58
56
  """
59
57
  # Check if path matches a specific route
60
58
  for route_prefix, backend in self.sorted_routes:
@@ -75,11 +73,14 @@ class CompositeBackend:
75
73
  results: list[FileInfo] = []
76
74
  results.extend(self.default.ls_info(path))
77
75
  for route_prefix, backend in self.sorted_routes:
78
- infos = backend.ls_info("/")
79
- for fi in infos:
80
- fi = dict(fi)
81
- fi["path"] = f"{route_prefix[:-1]}{fi['path']}"
82
- results.append(fi)
76
+ # Add the route itself as a directory (e.g., /memories/)
77
+ results.append({
78
+ "path": route_prefix,
79
+ "is_dir": True,
80
+ "size": 0,
81
+ "modified_at": "",
82
+ })
83
+
83
84
  results.sort(key=lambda x: x.get("path", ""))
84
85
  return results
85
86
 
@@ -13,16 +13,12 @@ import json
13
13
  import subprocess
14
14
  from datetime import datetime
15
15
  from pathlib import Path
16
- from typing import Any, Optional, TYPE_CHECKING
17
-
18
- if TYPE_CHECKING:
19
- from langchain.tools import ToolRuntime
16
+ from typing import Optional
20
17
 
21
18
  from .utils import (
22
19
  check_empty_content,
23
20
  format_content_with_line_numbers,
24
21
  perform_string_replacement,
25
- truncate_if_too_long,
26
22
  )
27
23
  import wcmatch.glob as wcglob
28
24
  from deepagents.backends.utils import FileInfo, GrepMatch
@@ -51,7 +47,7 @@ class FilesystemBackend:
51
47
  all file paths will be resolved relative to this directory.
52
48
  If not provided, uses the current working directory.
53
49
  """
54
- self.cwd = Path(root_dir) if root_dir else Path.cwd()
50
+ self.cwd = Path(root_dir).resolve() if root_dir else Path.cwd()
55
51
  self.virtual_mode = virtual_mode
56
52
  self.max_file_size_bytes = max_file_size_mb * 1024 * 1024
57
53
 
@@ -77,7 +73,7 @@ class FilesystemBackend:
77
73
  try:
78
74
  full.relative_to(self.cwd)
79
75
  except ValueError:
80
- raise ValueError(f"Path outside root directory: {key}") from None
76
+ raise ValueError(f"Path:{full} outside root directory: {self.cwd}") from None
81
77
  return full
82
78
 
83
79
  path = Path(key)
@@ -86,13 +82,14 @@ class FilesystemBackend:
86
82
  return (self.cwd / path).resolve()
87
83
 
88
84
  def ls_info(self, path: str) -> list[FileInfo]:
89
- """List files from filesystem.
85
+ """List files and directories in the specified directory (non-recursive).
90
86
 
91
87
  Args:
92
88
  path: Absolute directory path to list files from.
93
-
89
+
94
90
  Returns:
95
- List of FileInfo-like dicts.
91
+ List of FileInfo-like dicts for files and directories directly in the directory.
92
+ Directories have a trailing / in their path and is_dir=True.
96
93
  """
97
94
  dir_path = self._resolve_path(path)
98
95
  if not dir_path.exists() or not dir_path.is_dir():
@@ -105,18 +102,22 @@ class FilesystemBackend:
105
102
  if not cwd_str.endswith("/"):
106
103
  cwd_str += "/"
107
104
 
108
- # Walk the directory tree
105
+ # List only direct children (non-recursive)
109
106
  try:
110
- for path in dir_path.rglob("*"):
107
+ for child_path in dir_path.iterdir():
111
108
  try:
112
- is_file = path.is_file()
109
+ is_file = child_path.is_file()
110
+ is_dir = child_path.is_dir()
113
111
  except OSError:
114
112
  continue
115
- if is_file:
116
- abs_path = str(path)
117
- if not self.virtual_mode:
113
+
114
+ abs_path = str(child_path)
115
+
116
+ if not self.virtual_mode:
117
+ # Non-virtual mode: use absolute paths
118
+ if is_file:
118
119
  try:
119
- st = path.stat()
120
+ st = child_path.stat()
120
121
  results.append({
121
122
  "path": abs_path,
122
123
  "is_dir": False,
@@ -125,8 +126,19 @@ class FilesystemBackend:
125
126
  })
126
127
  except OSError:
127
128
  results.append({"path": abs_path, "is_dir": False})
128
- continue
129
- # Strip the cwd prefix if present
129
+ elif is_dir:
130
+ try:
131
+ st = child_path.stat()
132
+ results.append({
133
+ "path": abs_path + "/",
134
+ "is_dir": True,
135
+ "size": 0,
136
+ "modified_at": datetime.fromtimestamp(st.st_mtime).isoformat(),
137
+ })
138
+ except OSError:
139
+ results.append({"path": abs_path + "/", "is_dir": True})
140
+ else:
141
+ # Virtual mode: strip cwd prefix
130
142
  if abs_path.startswith(cwd_str):
131
143
  relative_path = abs_path[len(cwd_str):]
132
144
  elif abs_path.startswith(str(self.cwd)):
@@ -137,16 +149,29 @@ class FilesystemBackend:
137
149
  relative_path = abs_path
138
150
 
139
151
  virt_path = "/" + relative_path
140
- try:
141
- st = path.stat()
142
- results.append({
143
- "path": virt_path,
144
- "is_dir": False,
145
- "size": int(st.st_size),
146
- "modified_at": datetime.fromtimestamp(st.st_mtime).isoformat(),
147
- })
148
- except OSError:
149
- results.append({"path": virt_path, "is_dir": False})
152
+
153
+ if is_file:
154
+ try:
155
+ st = child_path.stat()
156
+ results.append({
157
+ "path": virt_path,
158
+ "is_dir": False,
159
+ "size": int(st.st_size),
160
+ "modified_at": datetime.fromtimestamp(st.st_mtime).isoformat(),
161
+ })
162
+ except OSError:
163
+ results.append({"path": virt_path, "is_dir": False})
164
+ elif is_dir:
165
+ try:
166
+ st = child_path.stat()
167
+ results.append({
168
+ "path": virt_path + "/",
169
+ "is_dir": True,
170
+ "size": 0,
171
+ "modified_at": datetime.fromtimestamp(st.st_mtime).isoformat(),
172
+ })
173
+ except OSError:
174
+ results.append({"path": virt_path + "/", "is_dir": True})
150
175
  except (OSError, PermissionError):
151
176
  pass
152
177
 
@@ -40,19 +40,38 @@ class StateBackend:
40
40
  self.runtime = runtime
41
41
 
42
42
  def ls_info(self, path: str) -> list[FileInfo]:
43
- """List files from state.
44
-
43
+ """List files and directories in the specified directory (non-recursive).
44
+
45
45
  Args:
46
46
  path: Absolute path to directory.
47
-
47
+
48
48
  Returns:
49
- List of FileInfo-like dicts.
49
+ List of FileInfo-like dicts for files and directories directly in the directory.
50
+ Directories have a trailing / in their path and is_dir=True.
50
51
  """
51
52
  files = self.runtime.state.get("files", {})
52
53
  infos: list[FileInfo] = []
54
+ subdirs: set[str] = set()
55
+
56
+ # Normalize path to have trailing slash for proper prefix matching
57
+ normalized_path = path if path.endswith("/") else path + "/"
58
+
53
59
  for k, fd in files.items():
54
- if not k.startswith(path):
60
+ # Check if file is in the specified directory or a subdirectory
61
+ if not k.startswith(normalized_path):
62
+ continue
63
+
64
+ # Get the relative path after the directory
65
+ relative = k[len(normalized_path):]
66
+
67
+ # If relative path contains '/', it's in a subdirectory
68
+ if "/" in relative:
69
+ # Extract the immediate subdirectory name
70
+ subdir_name = relative.split("/")[0]
71
+ subdirs.add(normalized_path + subdir_name + "/")
55
72
  continue
73
+
74
+ # This is a file directly in the current directory
56
75
  size = len("\n".join(fd.get("content", [])))
57
76
  infos.append({
58
77
  "path": k,
@@ -60,6 +79,16 @@ class StateBackend:
60
79
  "size": int(size),
61
80
  "modified_at": fd.get("modified_at", ""),
62
81
  })
82
+
83
+ # Add directories to the results
84
+ for subdir in sorted(subdirs):
85
+ infos.append({
86
+ "path": subdir,
87
+ "is_dir": True,
88
+ "size": 0,
89
+ "modified_at": "",
90
+ })
91
+
63
92
  infos.sort(key=lambda x: x.get("path", ""))
64
93
  return infos
65
94
 
@@ -179,24 +179,43 @@ class StoreBackend:
179
179
  return all_items
180
180
 
181
181
  def ls_info(self, path: str) -> list[FileInfo]:
182
- """List files from store.
183
-
182
+ """List files and directories in the specified directory (non-recursive).
183
+
184
184
  Args:
185
185
  path: Absolute path to directory.
186
-
186
+
187
187
  Returns:
188
- List of FileInfo-like dicts.
188
+ List of FileInfo-like dicts for files and directories directly in the directory.
189
+ Directories have a trailing / in their path and is_dir=True.
189
190
  """
190
191
  store = self._get_store()
191
192
  namespace = self._get_namespace()
192
-
193
+
193
194
  # Retrieve all items and filter by path prefix locally to avoid
194
195
  # coupling to store-specific filter semantics
195
196
  items = self._search_store_paginated(store, namespace)
196
197
  infos: list[FileInfo] = []
198
+ subdirs: set[str] = set()
199
+
200
+ # Normalize path to have trailing slash for proper prefix matching
201
+ normalized_path = path if path.endswith("/") else path + "/"
202
+
197
203
  for item in items:
198
- if not str(item.key).startswith(path):
204
+ # Check if file is in the specified directory or a subdirectory
205
+ if not str(item.key).startswith(normalized_path):
199
206
  continue
207
+
208
+ # Get the relative path after the directory
209
+ relative = str(item.key)[len(normalized_path):]
210
+
211
+ # If relative path contains '/', it's in a subdirectory
212
+ if "/" in relative:
213
+ # Extract the immediate subdirectory name
214
+ subdir_name = relative.split("/")[0]
215
+ subdirs.add(normalized_path + subdir_name + "/")
216
+ continue
217
+
218
+ # This is a file directly in the current directory
200
219
  try:
201
220
  fd = self._convert_store_item_to_file_data(item)
202
221
  except ValueError:
@@ -208,6 +227,16 @@ class StoreBackend:
208
227
  "size": int(size),
209
228
  "modified_at": fd.get("modified_at", ""),
210
229
  })
230
+
231
+ # Add directories to the results
232
+ for subdir in sorted(subdirs):
233
+ infos.append({
234
+ "path": subdir,
235
+ "is_dir": True,
236
+ "size": 0,
237
+ "modified_at": "",
238
+ })
239
+
211
240
  infos.sort(key=lambda x: x.get("path", ""))
212
241
  return infos
213
242
 
@@ -12,7 +12,7 @@ from pathlib import Path
12
12
  from typing import Any, Literal, TypedDict, List, Dict
13
13
 
14
14
  EMPTY_CONTENT_WARNING = "System reminder: File exists but has empty contents"
15
- MAX_LINE_LENGTH = 2000
15
+ MAX_LINE_LENGTH = 10000
16
16
  LINE_NUMBER_WIDTH = 6
17
17
  TOOL_RESULT_TOKEN_LIMIT = 20000 # Same threshold as eviction
18
18
  TRUNCATION_GUIDANCE = "... [results truncated, try being more specific with your parameters]"
@@ -37,18 +37,29 @@ class GrepMatch(TypedDict):
37
37
  text: str
38
38
 
39
39
 
40
+ def sanitize_tool_call_id(tool_call_id: str) -> str:
41
+ """Sanitize tool_call_id to prevent path traversal and separator issues.
42
+
43
+ Replaces dangerous characters (., /, \) with underscores.
44
+ """
45
+ sanitized = tool_call_id.replace(".", "_").replace("/", "_").replace("\\", "_")
46
+ return sanitized
47
+
48
+
40
49
  def format_content_with_line_numbers(
41
50
  content: str | list[str],
42
51
  start_line: int = 1,
43
52
  ) -> str:
44
53
  """Format file content with line numbers (cat -n style).
45
-
54
+
55
+ Chunks lines longer than MAX_LINE_LENGTH with continuation markers (e.g., 5.1, 5.2).
56
+
46
57
  Args:
47
58
  content: File content as string or list of lines
48
59
  start_line: Starting line number (default: 1)
49
-
60
+
50
61
  Returns:
51
- Formatted content with line numbers
62
+ Formatted content with line numbers and continuation markers
52
63
  """
53
64
  if isinstance(content, str):
54
65
  lines = content.split("\n")
@@ -56,11 +67,29 @@ def format_content_with_line_numbers(
56
67
  lines = lines[:-1]
57
68
  else:
58
69
  lines = content
59
-
60
- return "\n".join(
61
- f"{i + start_line:{LINE_NUMBER_WIDTH}d}\t{line[:MAX_LINE_LENGTH]}"
62
- for i, line in enumerate(lines)
63
- )
70
+
71
+ result_lines = []
72
+ for i, line in enumerate(lines):
73
+ line_num = i + start_line
74
+
75
+ if len(line) <= MAX_LINE_LENGTH:
76
+ result_lines.append(f"{line_num:{LINE_NUMBER_WIDTH}d}\t{line}")
77
+ else:
78
+ # Split long line into chunks with continuation markers
79
+ num_chunks = (len(line) + MAX_LINE_LENGTH - 1) // MAX_LINE_LENGTH
80
+ for chunk_idx in range(num_chunks):
81
+ start = chunk_idx * MAX_LINE_LENGTH
82
+ end = min(start + MAX_LINE_LENGTH, len(line))
83
+ chunk = line[start:end]
84
+ if chunk_idx == 0:
85
+ # First chunk: use normal line number
86
+ result_lines.append(f"{line_num:{LINE_NUMBER_WIDTH}d}\t{chunk}")
87
+ else:
88
+ # Continuation chunks: use decimal notation (e.g., 5.1, 5.2)
89
+ continuation_marker = f"{line_num}.{chunk_idx}"
90
+ result_lines.append(f"{continuation_marker:>{LINE_NUMBER_WIDTH}}\t{chunk}")
91
+
92
+ return "\n".join(result_lines)
64
93
 
65
94
 
66
95
  def check_empty_content(content: str) -> str | None:
@@ -91,18 +120,17 @@ def file_data_to_string(file_data: dict[str, Any]) -> str:
91
120
 
92
121
  def create_file_data(content: str, created_at: str | None = None) -> dict[str, Any]:
93
122
  """Create a FileData object with timestamps.
94
-
123
+
95
124
  Args:
96
125
  content: File content as string
97
126
  created_at: Optional creation timestamp (ISO format)
98
-
127
+
99
128
  Returns:
100
129
  FileData dict with content and timestamps
101
130
  """
102
131
  lines = content.split("\n") if isinstance(content, str) else content
103
- lines = [line[i:i+MAX_LINE_LENGTH] for line in lines for i in range(0, len(line) or 1, MAX_LINE_LENGTH)]
104
132
  now = datetime.now(UTC).isoformat()
105
-
133
+
106
134
  return {
107
135
  "content": lines,
108
136
  "created_at": created_at or now,
@@ -112,18 +140,17 @@ def create_file_data(content: str, created_at: str | None = None) -> dict[str, A
112
140
 
113
141
  def update_file_data(file_data: dict[str, Any], content: str) -> dict[str, Any]:
114
142
  """Update FileData with new content, preserving creation timestamp.
115
-
143
+
116
144
  Args:
117
145
  file_data: Existing FileData dict
118
146
  content: New content as string
119
-
147
+
120
148
  Returns:
121
149
  Updated FileData dict
122
150
  """
123
151
  lines = content.split("\n") if isinstance(content, str) else content
124
- lines = [line[i:i+MAX_LINE_LENGTH] for line in lines for i in range(0, len(line) or 1, MAX_LINE_LENGTH)]
125
152
  now = datetime.now(UTC).isoformat()
126
-
153
+
127
154
  return {
128
155
  "content": lines,
129
156
  "created_at": file_data["created_at"],
@@ -24,11 +24,11 @@ from typing_extensions import TypedDict
24
24
  from deepagents.backends.protocol import BackendProtocol, BackendFactory, WriteResult, EditResult
25
25
  from deepagents.backends import StateBackend
26
26
  from deepagents.backends.utils import (
27
- create_file_data,
28
27
  update_file_data,
29
28
  format_content_with_line_numbers,
30
29
  format_grep_matches,
31
30
  truncate_if_too_long,
31
+ sanitize_tool_call_id,
32
32
  )
33
33
 
34
34
  EMPTY_CONTENT_WARNING = "System reminder: File exists but has empty contents"
@@ -227,6 +227,15 @@ All file paths must start with a /.
227
227
 
228
228
 
229
229
  def _get_backend(backend: BACKEND_TYPES, runtime: ToolRuntime) -> BackendProtocol:
230
+ """Get the resolved backend instance from backend or factory.
231
+
232
+ Args:
233
+ backend: Backend instance or factory function.
234
+ runtime: The tool runtime context.
235
+
236
+ Returns:
237
+ Resolved backend instance.
238
+ """
230
239
  if callable(backend):
231
240
  return backend(runtime)
232
241
  return backend
@@ -532,6 +541,19 @@ class FilesystemMiddleware(AgentMiddleware):
532
541
 
533
542
  self.tools = _get_filesystem_tools(self.backend, custom_tool_descriptions)
534
543
 
544
+ def _get_backend(self, runtime: ToolRuntime) -> BackendProtocol:
545
+ """Get the resolved backend instance from backend or factory.
546
+
547
+ Args:
548
+ runtime: The tool runtime context.
549
+
550
+ Returns:
551
+ Resolved backend instance.
552
+ """
553
+ if callable(self.backend):
554
+ return self.backend(runtime)
555
+ return self.backend
556
+
535
557
  def wrap_model_call(
536
558
  self,
537
559
  request: ModelRequest,
@@ -568,54 +590,70 @@ class FilesystemMiddleware(AgentMiddleware):
568
590
  request.system_prompt = request.system_prompt + "\n\n" + self.system_prompt if request.system_prompt else self.system_prompt
569
591
  return await handler(request)
570
592
 
571
- def _intercept_large_tool_result(self, tool_result: ToolMessage | Command) -> ToolMessage | Command:
593
+ def _process_large_message(
594
+ self,
595
+ message: ToolMessage,
596
+ resolved_backend: BackendProtocol,
597
+ ) -> tuple[ToolMessage, dict[str, FileData] | None]:
598
+ content = message.content
599
+ if not isinstance(content, str) or len(content) <= 4 * self.tool_token_limit_before_evict:
600
+ return message, None
601
+
602
+ sanitized_id = sanitize_tool_call_id(message.tool_call_id)
603
+ file_path = f"/large_tool_results/{sanitized_id}"
604
+ result = resolved_backend.write(file_path, content)
605
+ if result.error:
606
+ return message, None
607
+ content_sample = format_content_with_line_numbers(content.splitlines()[:10], start_line=1)
608
+ processed_message = ToolMessage(
609
+ TOO_LARGE_TOOL_MSG.format(
610
+ tool_call_id=message.tool_call_id,
611
+ file_path=file_path,
612
+ content_sample=content_sample,
613
+ ),
614
+ tool_call_id=message.tool_call_id,
615
+ )
616
+ return processed_message, result.files_update
617
+
618
+ def _intercept_large_tool_result(self, tool_result: ToolMessage | Command, runtime: ToolRuntime) -> ToolMessage | Command:
572
619
  if isinstance(tool_result, ToolMessage) and isinstance(tool_result.content, str):
573
- content = tool_result.content
574
- if self.tool_token_limit_before_evict and len(content) > 4 * self.tool_token_limit_before_evict:
575
- file_path = f"/large_tool_results/{tool_result.tool_call_id}"
576
- file_data = create_file_data(content)
577
- state_update = {
578
- "messages": [
579
- ToolMessage(
580
- TOO_LARGE_TOOL_MSG.format(
581
- tool_call_id=tool_result.tool_call_id,
582
- file_path=file_path,
583
- content_sample=format_content_with_line_numbers(file_data["content"][:10], start_line=1),
584
- ),
585
- tool_call_id=tool_result.tool_call_id,
586
- )
587
- ],
588
- "files": {file_path: file_data},
589
- }
590
- return Command(update=state_update)
620
+ if not (self.tool_token_limit_before_evict and
621
+ len(tool_result.content) > 4 * self.tool_token_limit_before_evict):
622
+ return tool_result
623
+ resolved_backend = self._get_backend(runtime)
624
+ processed_message, files_update = self._process_large_message(
625
+ tool_result,
626
+ resolved_backend,
627
+ )
628
+ return (Command(update={
629
+ "files": files_update,
630
+ "messages": [processed_message],
631
+ }) if files_update is not None else processed_message)
632
+
591
633
  elif isinstance(tool_result, Command):
592
634
  update = tool_result.update
593
635
  if update is None:
594
636
  return tool_result
595
- message_updates = update.get("messages", [])
596
- file_updates = update.get("files", {})
597
-
598
- edited_message_updates = []
599
- for message in message_updates:
600
- if self.tool_token_limit_before_evict and isinstance(message, ToolMessage) and isinstance(message.content, str):
601
- content = message.content
602
- if len(content) > 4 * self.tool_token_limit_before_evict:
603
- file_path = f"/large_tool_results/{message.tool_call_id}"
604
- file_data = create_file_data(content)
605
- edited_message_updates.append(
606
- ToolMessage(
607
- TOO_LARGE_TOOL_MSG.format(
608
- tool_call_id=message.tool_call_id,
609
- file_path=file_path,
610
- content_sample=format_content_with_line_numbers(file_data["content"][:10], start_line=1),
611
- ),
612
- tool_call_id=message.tool_call_id,
613
- )
614
- )
615
- file_updates[file_path] = file_data
616
- continue
617
- edited_message_updates.append(message)
618
- return Command(update={**update, "messages": edited_message_updates, "files": file_updates})
637
+ command_messages = update.get("messages", [])
638
+ accumulated_file_updates = dict(update.get("files", {}))
639
+ resolved_backend = self._get_backend(runtime)
640
+ processed_messages = []
641
+ for message in command_messages:
642
+ if not (self.tool_token_limit_before_evict and
643
+ isinstance(message, ToolMessage) and
644
+ isinstance(message.content, str) and
645
+ len(message.content) > 4 * self.tool_token_limit_before_evict):
646
+ processed_messages.append(message)
647
+ continue
648
+ processed_message, files_update = self._process_large_message(
649
+ message,
650
+ resolved_backend,
651
+ )
652
+ processed_messages.append(processed_message)
653
+ if files_update is not None:
654
+ accumulated_file_updates.update(files_update)
655
+ return Command(update={**update, "messages": processed_messages, "files": accumulated_file_updates})
656
+
619
657
  return tool_result
620
658
 
621
659
  def wrap_tool_call(
@@ -636,7 +674,7 @@ class FilesystemMiddleware(AgentMiddleware):
636
674
  return handler(request)
637
675
 
638
676
  tool_result = handler(request)
639
- return self._intercept_large_tool_result(tool_result)
677
+ return self._intercept_large_tool_result(tool_result, request.runtime)
640
678
 
641
679
  async def awrap_tool_call(
642
680
  self,
@@ -656,4 +694,4 @@ class FilesystemMiddleware(AgentMiddleware):
656
694
  return await handler(request)
657
695
 
658
696
  tool_result = await handler(request)
659
- return self._intercept_large_tool_result(tool_result)
697
+ return self._intercept_large_tool_result(tool_result, request.runtime)
@@ -3,9 +3,9 @@
3
3
  from typing import Any
4
4
 
5
5
  from langchain.agents.middleware import AgentMiddleware, AgentState
6
- from langchain_core.messages import RemoveMessage, ToolMessage
7
- from langgraph.graph.message import REMOVE_ALL_MESSAGES
6
+ from langchain_core.messages import ToolMessage
8
7
  from langgraph.runtime import Runtime
8
+ from langgraph.types import Overwrite
9
9
 
10
10
 
11
11
  class PatchToolCallsMiddleware(AgentMiddleware):
@@ -41,4 +41,4 @@ class PatchToolCallsMiddleware(AgentMiddleware):
41
41
  )
42
42
  )
43
43
 
44
- return {"messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES), *patched_messages]}
44
+ return {"messages": Overwrite(patched_messages)}
@@ -273,7 +273,6 @@ def _get_subagents(
273
273
  system_prompt=agent_["system_prompt"],
274
274
  tools=_tools,
275
275
  middleware=_middleware,
276
- checkpointer=False,
277
276
  )
278
277
  return agents, subagent_descriptions
279
278
 
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepagents
3
- Version: 0.2.0
3
+ Version: 0.2.1rc2
4
4
  Summary: General purpose 'deep agent' with sub-agent spawning, todo list capabilities, and mock file system. Built on LangGraph.
5
5
  License: MIT
6
6
  Requires-Python: <4.0,>=3.11
7
7
  Description-Content-Type: text/markdown
8
8
  License-File: LICENSE
9
9
  Requires-Dist: langchain-anthropic<2.0.0,>=1.0.0
10
- Requires-Dist: langchain<2.0.0,>=1.0.0
10
+ Requires-Dist: langchain<2.0.0,>=1.0.2
11
11
  Requires-Dist: langchain-core<2.0.0,>=1.0.0
12
12
  Requires-Dist: wcmatch
13
13
  Provides-Extra: dev
@@ -1,5 +1,5 @@
1
1
  langchain-anthropic<2.0.0,>=1.0.0
2
- langchain<2.0.0,>=1.0.0
2
+ langchain<2.0.0,>=1.0.2
3
3
  langchain-core<2.0.0,>=1.0.0
4
4
  wcmatch
5
5
 
@@ -8,7 +8,7 @@ from langchain_core.messages import (
8
8
  ToolCall,
9
9
  ToolMessage,
10
10
  )
11
- from langgraph.graph.message import add_messages
11
+ from langgraph.types import Overwrite
12
12
  from langgraph.store.memory import InMemoryStore
13
13
 
14
14
  from deepagents.middleware.filesystem import (
@@ -168,8 +168,55 @@ class TestFilesystemMiddleware:
168
168
  "runtime": ToolRuntime(state=state, context=None, tool_call_id="", store=None, stream_writer=lambda _: None, config={}),
169
169
  }
170
170
  )
171
+ # ls should only return files directly in /pokemon/, not in subdirectories
171
172
  assert "/pokemon/test2.txt" in result
172
173
  assert "/pokemon/charmander.txt" in result
174
+ assert "/pokemon/water/squirtle.txt" not in result # In subdirectory, should NOT be listed
175
+ # ls should also list subdirectories with trailing /
176
+ assert "/pokemon/water/" in result
177
+
178
+ def test_ls_shortterm_lists_directories(self):
179
+ """Test that ls lists directories with trailing / for traversal."""
180
+ state = FilesystemState(
181
+ messages=[],
182
+ files={
183
+ "/test.txt": FileData(
184
+ content=["Hello world"],
185
+ modified_at="2021-01-01",
186
+ created_at="2021-01-01",
187
+ ),
188
+ "/pokemon/charmander.txt": FileData(
189
+ content=["Ember"],
190
+ modified_at="2021-01-01",
191
+ created_at="2021-01-01",
192
+ ),
193
+ "/pokemon/water/squirtle.txt": FileData(
194
+ content=["Water"],
195
+ modified_at="2021-01-01",
196
+ created_at="2021-01-01",
197
+ ),
198
+ "/docs/readme.md": FileData(
199
+ content=["Documentation"],
200
+ modified_at="2021-01-01",
201
+ created_at="2021-01-01",
202
+ ),
203
+ },
204
+ )
205
+ middleware = FilesystemMiddleware()
206
+ ls_tool = next(tool for tool in middleware.tools if tool.name == "ls")
207
+ result = ls_tool.invoke(
208
+ {
209
+ "path": "/",
210
+ "runtime": ToolRuntime(state=state, context=None, tool_call_id="", store=None, stream_writer=lambda _: None, config={}),
211
+ }
212
+ )
213
+ # ls should list both files and directories at root level
214
+ assert "/test.txt" in result
215
+ assert "/pokemon/" in result
216
+ assert "/docs/" in result
217
+ # But NOT subdirectory files
218
+ assert "/pokemon/charmander.txt" not in result
219
+ assert "/pokemon/water/squirtle.txt" not in result
173
220
 
174
221
  def test_glob_search_shortterm_simple_pattern(self):
175
222
  state = FilesystemState(
@@ -649,22 +696,21 @@ class TestFilesystemMiddleware:
649
696
  keys = {item.key for item in result}
650
697
  assert keys == {f"/file{i}.txt" for i in range(55)}
651
698
 
652
- def test_create_file_data_splits_long_lines(self):
699
+ def test_create_file_data_preserves_long_lines(self):
700
+ """Test that create_file_data stores long lines as-is without splitting."""
653
701
  long_line = "a" * 3500
654
702
  short_line = "short line"
655
703
  content = f"{short_line}\n{long_line}"
656
704
 
657
705
  file_data = create_file_data(content)
658
706
 
659
- for line in file_data["content"]:
660
- assert len(line) <= 2000
661
-
662
- assert len(file_data["content"]) == 3
707
+ assert len(file_data["content"]) == 2
663
708
  assert file_data["content"][0] == short_line
664
- assert file_data["content"][1] == "a" * 2000
665
- assert file_data["content"][2] == "a" * 1500
709
+ assert file_data["content"][1] == long_line
710
+ assert len(file_data["content"][1]) == 3500
666
711
 
667
- def test_update_file_data_splits_long_lines(self):
712
+ def test_update_file_data_preserves_long_lines(self):
713
+ """Test that update_file_data stores long lines as-is without splitting."""
668
714
  initial_file_data = create_file_data("initial content")
669
715
 
670
716
  long_line = "b" * 5000
@@ -673,17 +719,218 @@ class TestFilesystemMiddleware:
673
719
 
674
720
  updated_file_data = update_file_data(initial_file_data, new_content)
675
721
 
676
- for line in updated_file_data["content"]:
677
- assert len(line) <= 2000
678
-
679
- assert len(updated_file_data["content"]) == 4
722
+ assert len(updated_file_data["content"]) == 2
680
723
  assert updated_file_data["content"][0] == short_line
681
- assert updated_file_data["content"][1] == "b" * 2000
682
- assert updated_file_data["content"][2] == "b" * 2000
683
- assert updated_file_data["content"][3] == "b" * 1000
724
+ assert updated_file_data["content"][1] == long_line
725
+ assert len(updated_file_data["content"][1]) == 5000
684
726
 
685
727
  assert updated_file_data["created_at"] == initial_file_data["created_at"]
686
728
 
729
+ def test_format_content_with_line_numbers_short_lines(self):
730
+ """Test that short lines (<=10000 chars) are displayed normally."""
731
+ from deepagents.backends.utils import format_content_with_line_numbers
732
+
733
+ content = ["short line 1", "short line 2", "short line 3"]
734
+ result = format_content_with_line_numbers(content, start_line=1)
735
+
736
+ lines = result.split("\n")
737
+ assert len(lines) == 3
738
+ assert " 1\tshort line 1" in lines[0]
739
+ assert " 2\tshort line 2" in lines[1]
740
+ assert " 3\tshort line 3" in lines[2]
741
+
742
+ def test_format_content_with_line_numbers_long_line_with_continuation(self):
743
+ """Test that long lines (>10000 chars) are split with continuation markers."""
744
+ from deepagents.backends.utils import format_content_with_line_numbers
745
+
746
+ long_line = "a" * 25000
747
+ content = ["short line", long_line, "another short line"]
748
+ result = format_content_with_line_numbers(content, start_line=1)
749
+
750
+ lines = result.split("\n")
751
+ assert len(lines) == 5
752
+ assert " 1\tshort line" in lines[0]
753
+ assert " 2\t" in lines[1]
754
+ assert lines[1].count("a") == 10000
755
+ assert " 2.1\t" in lines[2]
756
+ assert lines[2].count("a") == 10000
757
+ assert " 2.2\t" in lines[3]
758
+ assert lines[3].count("a") == 5000
759
+ assert " 3\tanother short line" in lines[4]
760
+
761
+ def test_format_content_with_line_numbers_multiple_long_lines(self):
762
+ """Test multiple long lines in sequence with proper line numbering."""
763
+ from deepagents.backends.utils import format_content_with_line_numbers
764
+
765
+ long_line_1 = "x" * 15000
766
+ long_line_2 = "y" * 15000
767
+ content = [long_line_1, "middle", long_line_2]
768
+ result = format_content_with_line_numbers(content, start_line=5)
769
+ lines = result.split("\n")
770
+ assert len(lines) == 5
771
+ assert " 5\t" in lines[0]
772
+ assert lines[0].count("x") == 10000
773
+ assert " 5.1\t" in lines[1]
774
+ assert lines[1].count("x") == 5000
775
+ assert " 6\tmiddle" in lines[2]
776
+ assert " 7\t" in lines[3]
777
+ assert lines[3].count("y") == 10000
778
+ assert " 7.1\t" in lines[4]
779
+ assert lines[4].count("y") == 5000
780
+
781
+ def test_format_content_with_line_numbers_exact_limit(self):
782
+ """Test that a line exactly at the 10000 char limit is not split."""
783
+ from deepagents.backends.utils import format_content_with_line_numbers
784
+
785
+ exact_line = "b" * 10000
786
+ content = [exact_line]
787
+ result = format_content_with_line_numbers(content, start_line=1)
788
+
789
+ lines = result.split("\n")
790
+ assert len(lines) == 1
791
+ assert " 1\t" in lines[0]
792
+ assert lines[0].count("b") == 10000
793
+
794
+ def test_read_file_with_long_lines_shows_continuation_markers(self):
795
+ """Test that read_file displays long lines with continuation markers."""
796
+ from deepagents.backends.utils import format_read_response, create_file_data
797
+
798
+ long_line = "z" * 15000
799
+ content = f"first line\n{long_line}\nthird line"
800
+ file_data = create_file_data(content)
801
+ result = format_read_response(file_data, offset=0, limit=100)
802
+ lines = result.split("\n")
803
+ assert len(lines) == 4
804
+ assert " 1\tfirst line" in lines[0]
805
+ assert " 2\t" in lines[1]
806
+ assert lines[1].count("z") == 10000
807
+ assert " 2.1\t" in lines[2]
808
+ assert lines[2].count("z") == 5000
809
+ assert " 3\tthird line" in lines[3]
810
+
811
+ def test_read_file_with_offset_and_long_lines(self):
812
+ """Test that read_file with offset handles long lines correctly."""
813
+ from deepagents.backends.utils import format_read_response, create_file_data
814
+
815
+ long_line = "m" * 12000
816
+ content = f"line1\nline2\n{long_line}\nline4"
817
+ file_data = create_file_data(content)
818
+ result = format_read_response(file_data, offset=2, limit=10)
819
+ lines = result.split("\n")
820
+ assert len(lines) == 3
821
+ assert " 3\t" in lines[0]
822
+ assert lines[0].count("m") == 10000
823
+ assert " 3.1\t" in lines[1]
824
+ assert lines[1].count("m") == 2000
825
+ assert " 4\tline4" in lines[2]
826
+
827
+ def test_intercept_short_toolmessage(self):
828
+ """Test that small ToolMessages pass through unchanged."""
829
+ middleware = FilesystemMiddleware(tool_token_limit_before_evict=1000)
830
+ state = FilesystemState(messages=[], files={})
831
+ runtime = ToolRuntime(state=state, context=None, tool_call_id="test_123", store=None, stream_writer=lambda _: None, config={})
832
+
833
+ small_content = "x" * 1000
834
+ tool_message = ToolMessage(content=small_content, tool_call_id="test_123")
835
+ result = middleware._intercept_large_tool_result(tool_message, runtime)
836
+
837
+ assert result == tool_message
838
+
839
+ def test_intercept_long_toolmessage(self):
840
+ """Test that large ToolMessages are intercepted and saved to filesystem."""
841
+ from langgraph.types import Command
842
+
843
+ middleware = FilesystemMiddleware(tool_token_limit_before_evict=1000)
844
+ state = FilesystemState(messages=[], files={})
845
+ runtime = ToolRuntime(state=state, context=None, tool_call_id="test_123", store=None, stream_writer=lambda _: None, config={})
846
+
847
+ large_content = "x" * 5000
848
+ tool_message = ToolMessage(content=large_content, tool_call_id="test_123")
849
+ result = middleware._intercept_large_tool_result(tool_message, runtime)
850
+
851
+ assert isinstance(result, Command)
852
+ assert "/large_tool_results/test_123" in result.update["files"]
853
+ assert "Tool result too large" in result.update["messages"][0].content
854
+
855
+ def test_intercept_command_with_short_toolmessage(self):
856
+ """Test that Commands with small messages pass through unchanged."""
857
+ from langgraph.types import Command
858
+
859
+ middleware = FilesystemMiddleware(tool_token_limit_before_evict=1000)
860
+ state = FilesystemState(messages=[], files={})
861
+ runtime = ToolRuntime(state=state, context=None, tool_call_id="test_123", store=None, stream_writer=lambda _: None, config={})
862
+
863
+ small_content = "x" * 1000
864
+ tool_message = ToolMessage(content=small_content, tool_call_id="test_123")
865
+ command = Command(update={"messages": [tool_message], "files": {}})
866
+ result = middleware._intercept_large_tool_result(command, runtime)
867
+
868
+ assert isinstance(result, Command)
869
+ assert result.update["messages"][0].content == small_content
870
+
871
+ def test_intercept_command_with_long_toolmessage(self):
872
+ """Test that Commands with large messages are intercepted."""
873
+ from langgraph.types import Command
874
+
875
+ middleware = FilesystemMiddleware(tool_token_limit_before_evict=1000)
876
+ state = FilesystemState(messages=[], files={})
877
+ runtime = ToolRuntime(state=state, context=None, tool_call_id="test_123", store=None, stream_writer=lambda _: None, config={})
878
+
879
+ large_content = "y" * 5000
880
+ tool_message = ToolMessage(content=large_content, tool_call_id="test_123")
881
+ command = Command(update={"messages": [tool_message], "files": {}})
882
+ result = middleware._intercept_large_tool_result(command, runtime)
883
+
884
+ assert isinstance(result, Command)
885
+ assert "/large_tool_results/test_123" in result.update["files"]
886
+ assert "Tool result too large" in result.update["messages"][0].content
887
+
888
+ def test_intercept_command_with_files_and_long_toolmessage(self):
889
+ """Test that file updates are properly merged with existing files and other keys preserved."""
890
+ from langgraph.types import Command
891
+
892
+ middleware = FilesystemMiddleware(tool_token_limit_before_evict=1000)
893
+ state = FilesystemState(messages=[], files={})
894
+ runtime = ToolRuntime(state=state, context=None, tool_call_id="test_123", store=None, stream_writer=lambda _: None, config={})
895
+
896
+ large_content = "z" * 5000
897
+ tool_message = ToolMessage(content=large_content, tool_call_id="test_123")
898
+ existing_file = FileData(content=["existing"], created_at="2021-01-01", modified_at="2021-01-01")
899
+ command = Command(update={
900
+ "messages": [tool_message],
901
+ "files": {"/existing.txt": existing_file},
902
+ "custom_key": "custom_value"
903
+ })
904
+ result = middleware._intercept_large_tool_result(command, runtime)
905
+
906
+ assert isinstance(result, Command)
907
+ assert "/existing.txt" in result.update["files"]
908
+ assert "/large_tool_results/test_123" in result.update["files"]
909
+ assert result.update["custom_key"] == "custom_value"
910
+
911
+ def test_sanitize_tool_call_id(self):
912
+ """Test that tool_call_id is sanitized to prevent path traversal."""
913
+ from deepagents.backends.utils import sanitize_tool_call_id
914
+
915
+ assert sanitize_tool_call_id("call_123") == "call_123"
916
+ assert sanitize_tool_call_id("call/123") == "call_123"
917
+ assert sanitize_tool_call_id("test.id") == "test_id"
918
+
919
+ def test_intercept_sanitizes_tool_call_id(self):
920
+ """Test that tool_call_id with dangerous characters is sanitized in file path."""
921
+ from langgraph.types import Command
922
+
923
+ middleware = FilesystemMiddleware(tool_token_limit_before_evict=1000)
924
+ state = FilesystemState(messages=[], files={})
925
+ runtime = ToolRuntime(state=state, context=None, tool_call_id="test_123", store=None, stream_writer=lambda _: None, config={})
926
+
927
+ large_content = "x" * 5000
928
+ tool_message = ToolMessage(content=large_content, tool_call_id="test/call.id")
929
+ result = middleware._intercept_large_tool_result(tool_message, runtime)
930
+
931
+ assert isinstance(result, Command)
932
+ assert "/large_tool_results/test_call_id" in result.update["files"]
933
+
687
934
 
688
935
  @pytest.mark.requires("langchain_openai")
689
936
  class TestSubagentMiddleware:
@@ -727,13 +974,14 @@ class TestPatchToolCallsMiddleware:
727
974
  middleware = PatchToolCallsMiddleware()
728
975
  state_update = middleware.before_agent({"messages": input_messages}, None)
729
976
  assert state_update is not None
730
- assert len(state_update["messages"]) == 3
731
- assert state_update["messages"][0].type == "remove"
732
- assert state_update["messages"][1].type == "system"
733
- assert state_update["messages"][1].content == "You are a helpful assistant."
734
- assert state_update["messages"][2].type == "human"
735
- assert state_update["messages"][2].content == "Hello, how are you?"
736
- assert state_update["messages"][2].id == "2"
977
+ assert isinstance(state_update["messages"], Overwrite)
978
+ patched_messages = state_update["messages"].value
979
+ assert len(patched_messages) == 2
980
+ assert patched_messages[0].type == "system"
981
+ assert patched_messages[0].content == "You are a helpful assistant."
982
+ assert patched_messages[1].type == "human"
983
+ assert patched_messages[1].content == "Hello, how are you?"
984
+ assert patched_messages[1].id == "2"
737
985
 
738
986
  def test_missing_tool_call(self) -> None:
739
987
  input_messages = [
@@ -749,24 +997,23 @@ class TestPatchToolCallsMiddleware:
749
997
  middleware = PatchToolCallsMiddleware()
750
998
  state_update = middleware.before_agent({"messages": input_messages}, None)
751
999
  assert state_update is not None
752
- assert len(state_update["messages"]) == 6
753
- assert state_update["messages"][0].type == "remove"
754
- assert state_update["messages"][1] == input_messages[0]
755
- assert state_update["messages"][2] == input_messages[1]
756
- assert state_update["messages"][3] == input_messages[2]
757
- assert state_update["messages"][4].type == "tool"
758
- assert state_update["messages"][4].tool_call_id == "123"
759
- assert state_update["messages"][4].name == "get_events_for_days"
760
- assert state_update["messages"][5] == input_messages[3]
761
- updated_messages = add_messages(input_messages, state_update["messages"])
762
- assert len(updated_messages) == 5
763
- assert updated_messages[0] == input_messages[0]
764
- assert updated_messages[1] == input_messages[1]
765
- assert updated_messages[2] == input_messages[2]
766
- assert updated_messages[3].type == "tool"
767
- assert updated_messages[3].tool_call_id == "123"
768
- assert updated_messages[3].name == "get_events_for_days"
769
- assert updated_messages[4] == input_messages[3]
1000
+ assert isinstance(state_update["messages"], Overwrite)
1001
+ patched_messages = state_update["messages"].value
1002
+ assert len(patched_messages) == 5
1003
+ assert patched_messages[0].type == "system"
1004
+ assert patched_messages[0].content == "You are a helpful assistant."
1005
+ assert patched_messages[1].type == "human"
1006
+ assert patched_messages[1].content == "Hello, how are you?"
1007
+ assert patched_messages[2].type == "ai"
1008
+ assert len(patched_messages[2].tool_calls) == 1
1009
+ assert patched_messages[2].tool_calls[0]["id"] == "123"
1010
+ assert patched_messages[2].tool_calls[0]["name"] == "get_events_for_days"
1011
+ assert patched_messages[2].tool_calls[0]["args"] == {"date_str": "2025-01-01"}
1012
+ assert patched_messages[3].type == "tool"
1013
+ assert patched_messages[3].name == "get_events_for_days"
1014
+ assert patched_messages[3].tool_call_id == "123"
1015
+ assert patched_messages[4].type == "human"
1016
+ assert patched_messages[4].content == "What is the weather in Tokyo?"
770
1017
 
771
1018
  def test_no_missing_tool_calls(self) -> None:
772
1019
  input_messages = [
@@ -783,12 +1030,22 @@ class TestPatchToolCallsMiddleware:
783
1030
  middleware = PatchToolCallsMiddleware()
784
1031
  state_update = middleware.before_agent({"messages": input_messages}, None)
785
1032
  assert state_update is not None
786
- assert len(state_update["messages"]) == 6
787
- assert state_update["messages"][0].type == "remove"
788
- assert state_update["messages"][1:] == input_messages
789
- updated_messages = add_messages(input_messages, state_update["messages"])
790
- assert len(updated_messages) == 5
791
- assert updated_messages == input_messages
1033
+ assert isinstance(state_update["messages"], Overwrite)
1034
+ patched_messages = state_update["messages"].value
1035
+ assert len(patched_messages) == 5
1036
+ assert patched_messages[0].type == "system"
1037
+ assert patched_messages[0].content == "You are a helpful assistant."
1038
+ assert patched_messages[1].type == "human"
1039
+ assert patched_messages[1].content == "Hello, how are you?"
1040
+ assert patched_messages[2].type == "ai"
1041
+ assert len(patched_messages[2].tool_calls) == 1
1042
+ assert patched_messages[2].tool_calls[0]["id"] == "123"
1043
+ assert patched_messages[2].tool_calls[0]["name"] == "get_events_for_days"
1044
+ assert patched_messages[2].tool_calls[0]["args"] == {"date_str": "2025-01-01"}
1045
+ assert patched_messages[3].type == "tool"
1046
+ assert patched_messages[3].tool_call_id == "123"
1047
+ assert patched_messages[4].type == "human"
1048
+ assert patched_messages[4].content == "What is the weather in Tokyo?"
792
1049
 
793
1050
  def test_two_missing_tool_calls(self) -> None:
794
1051
  input_messages = [
@@ -810,34 +1067,33 @@ class TestPatchToolCallsMiddleware:
810
1067
  middleware = PatchToolCallsMiddleware()
811
1068
  state_update = middleware.before_agent({"messages": input_messages}, None)
812
1069
  assert state_update is not None
813
- assert len(state_update["messages"]) == 9
814
- assert state_update["messages"][0].type == "remove"
815
- assert state_update["messages"][1] == input_messages[0]
816
- assert state_update["messages"][2] == input_messages[1]
817
- assert state_update["messages"][3] == input_messages[2]
818
- assert state_update["messages"][4].type == "tool"
819
- assert state_update["messages"][4].tool_call_id == "123"
820
- assert state_update["messages"][4].name == "get_events_for_days"
821
- assert state_update["messages"][5] == input_messages[3]
822
- assert state_update["messages"][6] == input_messages[4]
823
- assert state_update["messages"][7].type == "tool"
824
- assert state_update["messages"][7].tool_call_id == "456"
825
- assert state_update["messages"][7].name == "get_events_for_days"
826
- assert state_update["messages"][8] == input_messages[5]
827
- updated_messages = add_messages(input_messages, state_update["messages"])
828
- assert len(updated_messages) == 8
829
- assert updated_messages[0] == input_messages[0]
830
- assert updated_messages[1] == input_messages[1]
831
- assert updated_messages[2] == input_messages[2]
832
- assert updated_messages[3].type == "tool"
833
- assert updated_messages[3].tool_call_id == "123"
834
- assert updated_messages[3].name == "get_events_for_days"
835
- assert updated_messages[4] == input_messages[3]
836
- assert updated_messages[5] == input_messages[4]
837
- assert updated_messages[6].type == "tool"
838
- assert updated_messages[6].tool_call_id == "456"
839
- assert updated_messages[6].name == "get_events_for_days"
840
- assert updated_messages[7] == input_messages[5]
1070
+ assert isinstance(state_update["messages"], Overwrite)
1071
+ patched_messages = state_update["messages"].value
1072
+ assert len(patched_messages) == 8
1073
+ assert patched_messages[0].type == "system"
1074
+ assert patched_messages[0].content == "You are a helpful assistant."
1075
+ assert patched_messages[1].type == "human"
1076
+ assert patched_messages[1].content == "Hello, how are you?"
1077
+ assert patched_messages[2].type == "ai"
1078
+ assert len(patched_messages[2].tool_calls) == 1
1079
+ assert patched_messages[2].tool_calls[0]["id"] == "123"
1080
+ assert patched_messages[2].tool_calls[0]["name"] == "get_events_for_days"
1081
+ assert patched_messages[2].tool_calls[0]["args"] == {"date_str": "2025-01-01"}
1082
+ assert patched_messages[3].type == "tool"
1083
+ assert patched_messages[3].name == "get_events_for_days"
1084
+ assert patched_messages[3].tool_call_id == "123"
1085
+ assert patched_messages[4].type == "human"
1086
+ assert patched_messages[4].content == "What is the weather in Tokyo?"
1087
+ assert patched_messages[5].type == "ai"
1088
+ assert len(patched_messages[5].tool_calls) == 1
1089
+ assert patched_messages[5].tool_calls[0]["id"] == "456"
1090
+ assert patched_messages[5].tool_calls[0]["name"] == "get_events_for_days"
1091
+ assert patched_messages[5].tool_calls[0]["args"] == {"date_str": "2025-01-01"}
1092
+ assert patched_messages[6].type == "tool"
1093
+ assert patched_messages[6].name == "get_events_for_days"
1094
+ assert patched_messages[6].tool_call_id == "456"
1095
+ assert patched_messages[7].type == "human"
1096
+ assert patched_messages[7].content == "What is the weather in Tokyo?"
841
1097
 
842
1098
 
843
1099
  class TestTruncation:
File without changes
File without changes
File without changes