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.
- {deepagents-0.2.0/src/deepagents.egg-info → deepagents-0.2.1rc2}/PKG-INFO +2 -2
- {deepagents-0.2.0 → deepagents-0.2.1rc2}/pyproject.toml +2 -2
- {deepagents-0.2.0 → deepagents-0.2.1rc2}/src/deepagents/backends/composite.py +15 -14
- {deepagents-0.2.0 → deepagents-0.2.1rc2}/src/deepagents/backends/filesystem.py +54 -29
- {deepagents-0.2.0 → deepagents-0.2.1rc2}/src/deepagents/backends/state.py +34 -5
- {deepagents-0.2.0 → deepagents-0.2.1rc2}/src/deepagents/backends/store.py +35 -6
- {deepagents-0.2.0 → deepagents-0.2.1rc2}/src/deepagents/backends/utils.py +44 -17
- {deepagents-0.2.0 → deepagents-0.2.1rc2}/src/deepagents/middleware/filesystem.py +84 -46
- {deepagents-0.2.0 → deepagents-0.2.1rc2}/src/deepagents/middleware/patch_tool_calls.py +3 -3
- {deepagents-0.2.0 → deepagents-0.2.1rc2}/src/deepagents/middleware/subagents.py +0 -1
- {deepagents-0.2.0 → deepagents-0.2.1rc2/src/deepagents.egg-info}/PKG-INFO +2 -2
- {deepagents-0.2.0 → deepagents-0.2.1rc2}/src/deepagents.egg-info/requires.txt +1 -1
- {deepagents-0.2.0 → deepagents-0.2.1rc2}/tests/test_middleware.py +331 -75
- {deepagents-0.2.0 → deepagents-0.2.1rc2}/LICENSE +0 -0
- {deepagents-0.2.0 → deepagents-0.2.1rc2}/README.md +0 -0
- {deepagents-0.2.0 → deepagents-0.2.1rc2}/setup.cfg +0 -0
- {deepagents-0.2.0 → deepagents-0.2.1rc2}/src/deepagents/__init__.py +0 -0
- {deepagents-0.2.0 → deepagents-0.2.1rc2}/src/deepagents/backends/__init__.py +0 -0
- {deepagents-0.2.0 → deepagents-0.2.1rc2}/src/deepagents/backends/protocol.py +0 -0
- {deepagents-0.2.0 → deepagents-0.2.1rc2}/src/deepagents/graph.py +0 -0
- {deepagents-0.2.0 → deepagents-0.2.1rc2}/src/deepagents/middleware/__init__.py +0 -0
- {deepagents-0.2.0 → deepagents-0.2.1rc2}/src/deepagents.egg-info/SOURCES.txt +0 -0
- {deepagents-0.2.0 → deepagents-0.2.1rc2}/src/deepagents.egg-info/dependency_links.txt +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
3
|
+
from typing import Optional
|
|
4
4
|
|
|
5
|
-
from
|
|
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
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
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: {
|
|
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
|
|
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
|
-
#
|
|
105
|
+
# List only direct children (non-recursive)
|
|
109
106
|
try:
|
|
110
|
-
for
|
|
107
|
+
for child_path in dir_path.iterdir():
|
|
111
108
|
try:
|
|
112
|
-
is_file =
|
|
109
|
+
is_file = child_path.is_file()
|
|
110
|
+
is_dir = child_path.is_dir()
|
|
113
111
|
except OSError:
|
|
114
112
|
continue
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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 =
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
for message in
|
|
600
|
-
if self.tool_token_limit_before_evict and
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
|
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":
|
|
44
|
+
return {"messages": Overwrite(patched_messages)}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: deepagents
|
|
3
|
-
Version: 0.2.
|
|
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.
|
|
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
|
|
@@ -8,7 +8,7 @@ from langchain_core.messages import (
|
|
|
8
8
|
ToolCall,
|
|
9
9
|
ToolMessage,
|
|
10
10
|
)
|
|
11
|
-
from langgraph.
|
|
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
|
|
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
|
-
|
|
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] ==
|
|
665
|
-
assert file_data["content"][
|
|
709
|
+
assert file_data["content"][1] == long_line
|
|
710
|
+
assert len(file_data["content"][1]) == 3500
|
|
666
711
|
|
|
667
|
-
def
|
|
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
|
-
|
|
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] ==
|
|
682
|
-
assert updated_file_data["content"][
|
|
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
|
|
731
|
-
|
|
732
|
-
assert
|
|
733
|
-
assert
|
|
734
|
-
assert
|
|
735
|
-
assert
|
|
736
|
-
assert
|
|
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
|
|
753
|
-
|
|
754
|
-
assert
|
|
755
|
-
assert
|
|
756
|
-
assert
|
|
757
|
-
assert
|
|
758
|
-
assert
|
|
759
|
-
assert
|
|
760
|
-
assert
|
|
761
|
-
|
|
762
|
-
assert
|
|
763
|
-
assert
|
|
764
|
-
assert
|
|
765
|
-
assert
|
|
766
|
-
assert
|
|
767
|
-
assert
|
|
768
|
-
assert
|
|
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
|
|
787
|
-
|
|
788
|
-
assert
|
|
789
|
-
|
|
790
|
-
assert
|
|
791
|
-
assert
|
|
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
|
|
814
|
-
|
|
815
|
-
assert
|
|
816
|
-
assert
|
|
817
|
-
assert
|
|
818
|
-
assert
|
|
819
|
-
assert
|
|
820
|
-
assert
|
|
821
|
-
assert
|
|
822
|
-
assert
|
|
823
|
-
assert
|
|
824
|
-
assert
|
|
825
|
-
assert
|
|
826
|
-
assert
|
|
827
|
-
|
|
828
|
-
assert
|
|
829
|
-
assert
|
|
830
|
-
assert
|
|
831
|
-
assert
|
|
832
|
-
assert
|
|
833
|
-
assert
|
|
834
|
-
assert
|
|
835
|
-
assert
|
|
836
|
-
assert
|
|
837
|
-
assert
|
|
838
|
-
assert
|
|
839
|
-
assert
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|