deepagents 0.2.4__tar.gz → 0.2.6__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.4/src/deepagents.egg-info → deepagents-0.2.6}/PKG-INFO +5 -10
- {deepagents-0.2.4 → deepagents-0.2.6}/README.md +1 -1
- {deepagents-0.2.4/src → deepagents-0.2.6}/deepagents/backends/composite.py +66 -41
- {deepagents-0.2.4/src → deepagents-0.2.6}/deepagents/backends/filesystem.py +92 -86
- {deepagents-0.2.4/src → deepagents-0.2.6}/deepagents/backends/protocol.py +87 -13
- deepagents-0.2.6/deepagents/backends/sandbox.py +341 -0
- {deepagents-0.2.4/src → deepagents-0.2.6}/deepagents/backends/state.py +59 -58
- {deepagents-0.2.4/src → deepagents-0.2.6}/deepagents/backends/store.py +73 -74
- {deepagents-0.2.4/src → deepagents-0.2.6}/deepagents/backends/utils.py +7 -21
- {deepagents-0.2.4/src → deepagents-0.2.6}/deepagents/graph.py +8 -4
- {deepagents-0.2.4/src → deepagents-0.2.6}/deepagents/middleware/filesystem.py +271 -66
- {deepagents-0.2.4/src → deepagents-0.2.6}/deepagents/middleware/resumable_shell.py +5 -4
- {deepagents-0.2.4/src → deepagents-0.2.6}/deepagents/middleware/subagents.py +8 -6
- {deepagents-0.2.4 → deepagents-0.2.6/deepagents.egg-info}/PKG-INFO +5 -10
- deepagents-0.2.6/deepagents.egg-info/SOURCES.txt +22 -0
- {deepagents-0.2.4/src → deepagents-0.2.6}/deepagents.egg-info/requires.txt +3 -7
- {deepagents-0.2.4 → deepagents-0.2.6}/pyproject.toml +16 -27
- deepagents-0.2.4/LICENSE +0 -21
- deepagents-0.2.4/src/deepagents.egg-info/SOURCES.txt +0 -23
- deepagents-0.2.4/tests/test_middleware.py +0 -1134
- {deepagents-0.2.4/src → deepagents-0.2.6}/deepagents/__init__.py +0 -0
- {deepagents-0.2.4/src → deepagents-0.2.6}/deepagents/backends/__init__.py +1 -1
- {deepagents-0.2.4/src → deepagents-0.2.6}/deepagents/middleware/__init__.py +0 -0
- {deepagents-0.2.4/src → deepagents-0.2.6}/deepagents/middleware/patch_tool_calls.py +0 -0
- {deepagents-0.2.4/src → deepagents-0.2.6}/deepagents.egg-info/dependency_links.txt +0 -0
- {deepagents-0.2.4/src → deepagents-0.2.6}/deepagents.egg-info/top_level.txt +0 -0
- {deepagents-0.2.4 → deepagents-0.2.6}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: deepagents
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.6
|
|
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
|
Project-URL: Homepage, https://docs.langchain.com/oss/python/deepagents/overview
|
|
@@ -11,18 +11,13 @@ Project-URL: Slack, https://www.langchain.com/join-community
|
|
|
11
11
|
Project-URL: Reddit, https://www.reddit.com/r/LangChain/
|
|
12
12
|
Requires-Python: <4.0,>=3.11
|
|
13
13
|
Description-Content-Type: text/markdown
|
|
14
|
-
License-File: LICENSE
|
|
15
14
|
Requires-Dist: langchain-anthropic<2.0.0,>=1.0.0
|
|
16
15
|
Requires-Dist: langchain<2.0.0,>=1.0.2
|
|
17
16
|
Requires-Dist: langchain-core<2.0.0,>=1.0.0
|
|
18
17
|
Requires-Dist: wcmatch
|
|
19
|
-
|
|
20
|
-
Requires-Dist:
|
|
21
|
-
Requires-Dist:
|
|
22
|
-
Requires-Dist: build; extra == "dev"
|
|
23
|
-
Requires-Dist: twine; extra == "dev"
|
|
24
|
-
Requires-Dist: langchain-openai; extra == "dev"
|
|
25
|
-
Dynamic: license-file
|
|
18
|
+
Requires-Dist: daytona>=0.113.0
|
|
19
|
+
Requires-Dist: runloop-api-client>=0.66.1
|
|
20
|
+
Requires-Dist: tavily>=1.1.0
|
|
26
21
|
|
|
27
22
|
# 🧠🤖Deep Agents
|
|
28
23
|
|
|
@@ -353,7 +348,7 @@ Deep Agents are built with a modular middleware architecture. As a reminder, Dee
|
|
|
353
348
|
- A filesystem for storing context and long-term memories
|
|
354
349
|
- The ability to spawn subagents
|
|
355
350
|
|
|
356
|
-
Each of these features is implemented as separate middleware. When you create a deep agent with `create_deep_agent`, we automatically attach **
|
|
351
|
+
Each of these features is implemented as separate middleware. When you create a deep agent with `create_deep_agent`, we automatically attach **TodoListMiddleware**, **FilesystemMiddleware** and **SubAgentMiddleware** to your agent.
|
|
357
352
|
|
|
358
353
|
Middleware is a composable concept, and you can choose to add as many or as few middleware to an agent depending on your use case. That means that you can also use any of the aforementioned middleware independently!
|
|
359
354
|
|
|
@@ -327,7 +327,7 @@ Deep Agents are built with a modular middleware architecture. As a reminder, Dee
|
|
|
327
327
|
- A filesystem for storing context and long-term memories
|
|
328
328
|
- The ability to spawn subagents
|
|
329
329
|
|
|
330
|
-
Each of these features is implemented as separate middleware. When you create a deep agent with `create_deep_agent`, we automatically attach **
|
|
330
|
+
Each of these features is implemented as separate middleware. When you create a deep agent with `create_deep_agent`, we automatically attach **TodoListMiddleware**, **FilesystemMiddleware** and **SubAgentMiddleware** to your agent.
|
|
331
331
|
|
|
332
332
|
Middleware is a composable concept, and you can choose to add as many or as few middleware to an agent depending on your use case. That means that you can also use any of the aforementioned middleware independently!
|
|
333
333
|
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
"""CompositeBackend: Route operations to different backends based on path prefix."""
|
|
2
2
|
|
|
3
|
-
from
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
from deepagents.backends.protocol import (
|
|
4
|
+
BackendProtocol,
|
|
5
|
+
EditResult,
|
|
6
|
+
ExecuteResponse,
|
|
7
|
+
FileInfo,
|
|
8
|
+
GrepMatch,
|
|
9
|
+
SandboxBackendProtocol,
|
|
10
|
+
WriteResult,
|
|
11
|
+
)
|
|
6
12
|
from deepagents.backends.state import StateBackend
|
|
7
|
-
from deepagents.backends.utils import FileInfo, GrepMatch
|
|
8
13
|
|
|
9
14
|
|
|
10
15
|
class CompositeBackend:
|
|
11
|
-
|
|
12
16
|
def __init__(
|
|
13
17
|
self,
|
|
14
18
|
default: BackendProtocol | StateBackend,
|
|
@@ -19,16 +23,16 @@ class CompositeBackend:
|
|
|
19
23
|
|
|
20
24
|
# Virtual routes
|
|
21
25
|
self.routes = routes
|
|
22
|
-
|
|
26
|
+
|
|
23
27
|
# Sort routes by length (longest first) for correct prefix matching
|
|
24
28
|
self.sorted_routes = sorted(routes.items(), key=lambda x: len(x[0]), reverse=True)
|
|
25
29
|
|
|
26
30
|
def _get_backend_and_key(self, key: str) -> tuple[BackendProtocol, str]:
|
|
27
31
|
"""Determine which backend handles this key and strip prefix.
|
|
28
|
-
|
|
32
|
+
|
|
29
33
|
Args:
|
|
30
34
|
key: Original file path
|
|
31
|
-
|
|
35
|
+
|
|
32
36
|
Returns:
|
|
33
37
|
Tuple of (backend, stripped_key) where stripped_key has the route
|
|
34
38
|
prefix removed (but keeps leading slash).
|
|
@@ -38,12 +42,12 @@ class CompositeBackend:
|
|
|
38
42
|
if key.startswith(prefix):
|
|
39
43
|
# Strip full prefix and ensure a leading slash remains
|
|
40
44
|
# e.g., "/memories/notes.txt" → "/notes.txt"; "/memories/" → "/"
|
|
41
|
-
suffix = key[len(prefix):]
|
|
45
|
+
suffix = key[len(prefix) :]
|
|
42
46
|
stripped_key = f"/{suffix}" if suffix else "/"
|
|
43
47
|
return backend, stripped_key
|
|
44
|
-
|
|
48
|
+
|
|
45
49
|
return self.default, key
|
|
46
|
-
|
|
50
|
+
|
|
47
51
|
def ls_info(self, path: str) -> list[FileInfo]:
|
|
48
52
|
"""List files and directories in the specified directory (non-recursive).
|
|
49
53
|
|
|
@@ -58,7 +62,7 @@ class CompositeBackend:
|
|
|
58
62
|
for route_prefix, backend in self.sorted_routes:
|
|
59
63
|
if path.startswith(route_prefix.rstrip("/")):
|
|
60
64
|
# Query only the matching routed backend
|
|
61
|
-
suffix = path[len(route_prefix):]
|
|
65
|
+
suffix = path[len(route_prefix) :]
|
|
62
66
|
search_path = f"/{suffix}" if suffix else "/"
|
|
63
67
|
infos = backend.ls_info(search_path)
|
|
64
68
|
prefixed: list[FileInfo] = []
|
|
@@ -74,12 +78,14 @@ class CompositeBackend:
|
|
|
74
78
|
results.extend(self.default.ls_info(path))
|
|
75
79
|
for route_prefix, backend in self.sorted_routes:
|
|
76
80
|
# Add the route itself as a directory (e.g., /memories/)
|
|
77
|
-
results.append(
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
results.append(
|
|
82
|
+
{
|
|
83
|
+
"path": route_prefix,
|
|
84
|
+
"is_dir": True,
|
|
85
|
+
"size": 0,
|
|
86
|
+
"modified_at": "",
|
|
87
|
+
}
|
|
88
|
+
)
|
|
83
89
|
|
|
84
90
|
results.sort(key=lambda x: x.get("path", ""))
|
|
85
91
|
return results
|
|
@@ -87,15 +93,14 @@ class CompositeBackend:
|
|
|
87
93
|
# Path doesn't match a route: query only default backend
|
|
88
94
|
return self.default.ls_info(path)
|
|
89
95
|
|
|
90
|
-
|
|
91
96
|
def read(
|
|
92
|
-
self,
|
|
97
|
+
self,
|
|
93
98
|
file_path: str,
|
|
94
99
|
offset: int = 0,
|
|
95
100
|
limit: int = 2000,
|
|
96
101
|
) -> str:
|
|
97
102
|
"""Read file content, routing to appropriate backend.
|
|
98
|
-
|
|
103
|
+
|
|
99
104
|
Args:
|
|
100
105
|
file_path: Absolute file path
|
|
101
106
|
offset: Line offset to start reading from (0-indexed)
|
|
@@ -105,17 +110,16 @@ class CompositeBackend:
|
|
|
105
110
|
backend, stripped_key = self._get_backend_and_key(file_path)
|
|
106
111
|
return backend.read(stripped_key, offset=offset, limit=limit)
|
|
107
112
|
|
|
108
|
-
|
|
109
113
|
def grep_raw(
|
|
110
114
|
self,
|
|
111
115
|
pattern: str,
|
|
112
|
-
path:
|
|
113
|
-
glob:
|
|
116
|
+
path: str | None = None,
|
|
117
|
+
glob: str | None = None,
|
|
114
118
|
) -> list[GrepMatch] | str:
|
|
115
119
|
# If path targets a specific route, search only that backend
|
|
116
120
|
for route_prefix, backend in self.sorted_routes:
|
|
117
121
|
if path is not None and path.startswith(route_prefix.rstrip("/")):
|
|
118
|
-
search_path = path[len(route_prefix) - 1:]
|
|
122
|
+
search_path = path[len(route_prefix) - 1 :]
|
|
119
123
|
raw = backend.grep_raw(pattern, search_path if search_path else "/", glob)
|
|
120
124
|
if isinstance(raw, str):
|
|
121
125
|
return raw
|
|
@@ -137,19 +141,16 @@ class CompositeBackend:
|
|
|
137
141
|
all_matches.extend({**m, "path": f"{route_prefix[:-1]}{m['path']}"} for m in raw)
|
|
138
142
|
|
|
139
143
|
return all_matches
|
|
140
|
-
|
|
144
|
+
|
|
141
145
|
def glob_info(self, pattern: str, path: str = "/") -> list[FileInfo]:
|
|
142
146
|
results: list[FileInfo] = []
|
|
143
147
|
|
|
144
148
|
# Route based on path, not pattern
|
|
145
149
|
for route_prefix, backend in self.sorted_routes:
|
|
146
150
|
if path.startswith(route_prefix.rstrip("/")):
|
|
147
|
-
search_path = path[len(route_prefix) - 1:]
|
|
151
|
+
search_path = path[len(route_prefix) - 1 :]
|
|
148
152
|
infos = backend.glob_info(pattern, search_path if search_path else "/")
|
|
149
|
-
return [
|
|
150
|
-
{**fi, "path": f"{route_prefix[:-1]}{fi['path']}"}
|
|
151
|
-
for fi in infos
|
|
152
|
-
]
|
|
153
|
+
return [{**fi, "path": f"{route_prefix[:-1]}{fi['path']}"} for fi in infos]
|
|
153
154
|
|
|
154
155
|
# Path doesn't match any specific route - search default backend AND all routed backends
|
|
155
156
|
results.extend(self.default.glob_info(pattern, path))
|
|
@@ -162,11 +163,10 @@ class CompositeBackend:
|
|
|
162
163
|
results.sort(key=lambda x: x.get("path", ""))
|
|
163
164
|
return results
|
|
164
165
|
|
|
165
|
-
|
|
166
166
|
def write(
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
167
|
+
self,
|
|
168
|
+
file_path: str,
|
|
169
|
+
content: str,
|
|
170
170
|
) -> WriteResult:
|
|
171
171
|
"""Create a new file, routing to appropriate backend.
|
|
172
172
|
|
|
@@ -191,11 +191,11 @@ class CompositeBackend:
|
|
|
191
191
|
return res
|
|
192
192
|
|
|
193
193
|
def edit(
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
194
|
+
self,
|
|
195
|
+
file_path: str,
|
|
196
|
+
old_string: str,
|
|
197
|
+
new_string: str,
|
|
198
|
+
replace_all: bool = False,
|
|
199
199
|
) -> EditResult:
|
|
200
200
|
"""Edit a file, routing to appropriate backend.
|
|
201
201
|
|
|
@@ -220,5 +220,30 @@ class CompositeBackend:
|
|
|
220
220
|
pass
|
|
221
221
|
return res
|
|
222
222
|
|
|
223
|
+
def execute(
|
|
224
|
+
self,
|
|
225
|
+
command: str,
|
|
226
|
+
) -> ExecuteResponse:
|
|
227
|
+
"""Execute a command via the default backend.
|
|
228
|
+
|
|
229
|
+
Execution is not path-specific, so it always delegates to the default backend.
|
|
230
|
+
The default backend must implement SandboxBackendProtocol for this to work.
|
|
223
231
|
|
|
224
|
-
|
|
232
|
+
Args:
|
|
233
|
+
command: Full shell command string to execute.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
ExecuteResponse with combined output, exit code, and truncation flag.
|
|
237
|
+
|
|
238
|
+
Raises:
|
|
239
|
+
NotImplementedError: If default backend doesn't support execution.
|
|
240
|
+
"""
|
|
241
|
+
if isinstance(self.default, SandboxBackendProtocol):
|
|
242
|
+
return self.default.execute(command)
|
|
243
|
+
|
|
244
|
+
# This shouldn't be reached if the runtime check in the execute tool works correctly,
|
|
245
|
+
# but we include it as a safety fallback.
|
|
246
|
+
raise NotImplementedError(
|
|
247
|
+
"Default backend doesn't support command execution (SandboxBackendProtocol). "
|
|
248
|
+
"To enable execution, provide a default backend that implements SandboxBackendProtocol."
|
|
249
|
+
)
|
|
@@ -7,26 +7,24 @@ Security and search upgrades:
|
|
|
7
7
|
and optional glob include filtering, while preserving virtual path behavior
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
+
import json
|
|
10
11
|
import os
|
|
11
12
|
import re
|
|
12
|
-
import json
|
|
13
13
|
import subprocess
|
|
14
14
|
from datetime import datetime
|
|
15
15
|
from pathlib import Path
|
|
16
|
-
from typing import Optional
|
|
17
16
|
|
|
18
|
-
|
|
17
|
+
import wcmatch.glob as wcglob
|
|
18
|
+
|
|
19
|
+
from deepagents.backends.protocol import BackendProtocol, EditResult, FileInfo, GrepMatch, WriteResult
|
|
20
|
+
from deepagents.backends.utils import (
|
|
19
21
|
check_empty_content,
|
|
20
22
|
format_content_with_line_numbers,
|
|
21
23
|
perform_string_replacement,
|
|
22
24
|
)
|
|
23
|
-
import wcmatch.glob as wcglob
|
|
24
|
-
from deepagents.backends.utils import FileInfo, GrepMatch
|
|
25
|
-
from deepagents.backends.protocol import WriteResult, EditResult
|
|
26
|
-
|
|
27
25
|
|
|
28
26
|
|
|
29
|
-
class FilesystemBackend:
|
|
27
|
+
class FilesystemBackend(BackendProtocol):
|
|
30
28
|
"""Backend that reads and writes files directly from the filesystem.
|
|
31
29
|
|
|
32
30
|
Files are accessed using their actual filesystem paths. Relative paths are
|
|
@@ -35,13 +33,13 @@ class FilesystemBackend:
|
|
|
35
33
|
"""
|
|
36
34
|
|
|
37
35
|
def __init__(
|
|
38
|
-
self,
|
|
39
|
-
root_dir:
|
|
36
|
+
self,
|
|
37
|
+
root_dir: str | Path | None = None,
|
|
40
38
|
virtual_mode: bool = False,
|
|
41
39
|
max_file_size_mb: int = 10,
|
|
42
40
|
) -> None:
|
|
43
41
|
"""Initialize filesystem backend.
|
|
44
|
-
|
|
42
|
+
|
|
45
43
|
Args:
|
|
46
44
|
root_dir: Optional root directory for file operations. If provided,
|
|
47
45
|
all file paths will be resolved relative to this directory.
|
|
@@ -118,32 +116,36 @@ class FilesystemBackend:
|
|
|
118
116
|
if is_file:
|
|
119
117
|
try:
|
|
120
118
|
st = child_path.stat()
|
|
121
|
-
results.append(
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
119
|
+
results.append(
|
|
120
|
+
{
|
|
121
|
+
"path": abs_path,
|
|
122
|
+
"is_dir": False,
|
|
123
|
+
"size": int(st.st_size),
|
|
124
|
+
"modified_at": datetime.fromtimestamp(st.st_mtime).isoformat(),
|
|
125
|
+
}
|
|
126
|
+
)
|
|
127
127
|
except OSError:
|
|
128
128
|
results.append({"path": abs_path, "is_dir": False})
|
|
129
129
|
elif is_dir:
|
|
130
130
|
try:
|
|
131
131
|
st = child_path.stat()
|
|
132
|
-
results.append(
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
132
|
+
results.append(
|
|
133
|
+
{
|
|
134
|
+
"path": abs_path + "/",
|
|
135
|
+
"is_dir": True,
|
|
136
|
+
"size": 0,
|
|
137
|
+
"modified_at": datetime.fromtimestamp(st.st_mtime).isoformat(),
|
|
138
|
+
}
|
|
139
|
+
)
|
|
138
140
|
except OSError:
|
|
139
141
|
results.append({"path": abs_path + "/", "is_dir": True})
|
|
140
142
|
else:
|
|
141
143
|
# Virtual mode: strip cwd prefix
|
|
142
144
|
if abs_path.startswith(cwd_str):
|
|
143
|
-
relative_path = abs_path[len(cwd_str):]
|
|
145
|
+
relative_path = abs_path[len(cwd_str) :]
|
|
144
146
|
elif abs_path.startswith(str(self.cwd)):
|
|
145
147
|
# Handle case where cwd doesn't end with /
|
|
146
|
-
relative_path = abs_path[len(str(self.cwd)):].lstrip("/")
|
|
148
|
+
relative_path = abs_path[len(str(self.cwd)) :].lstrip("/")
|
|
147
149
|
else:
|
|
148
150
|
# Path is outside cwd, return as-is or skip
|
|
149
151
|
relative_path = abs_path
|
|
@@ -153,23 +155,27 @@ class FilesystemBackend:
|
|
|
153
155
|
if is_file:
|
|
154
156
|
try:
|
|
155
157
|
st = child_path.stat()
|
|
156
|
-
results.append(
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
158
|
+
results.append(
|
|
159
|
+
{
|
|
160
|
+
"path": virt_path,
|
|
161
|
+
"is_dir": False,
|
|
162
|
+
"size": int(st.st_size),
|
|
163
|
+
"modified_at": datetime.fromtimestamp(st.st_mtime).isoformat(),
|
|
164
|
+
}
|
|
165
|
+
)
|
|
162
166
|
except OSError:
|
|
163
167
|
results.append({"path": virt_path, "is_dir": False})
|
|
164
168
|
elif is_dir:
|
|
165
169
|
try:
|
|
166
170
|
st = child_path.stat()
|
|
167
|
-
results.append(
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
171
|
+
results.append(
|
|
172
|
+
{
|
|
173
|
+
"path": virt_path + "/",
|
|
174
|
+
"is_dir": True,
|
|
175
|
+
"size": 0,
|
|
176
|
+
"modified_at": datetime.fromtimestamp(st.st_mtime).isoformat(),
|
|
177
|
+
}
|
|
178
|
+
)
|
|
173
179
|
except OSError:
|
|
174
180
|
results.append({"path": virt_path + "/", "is_dir": True})
|
|
175
181
|
except (OSError, PermissionError):
|
|
@@ -180,15 +186,15 @@ class FilesystemBackend:
|
|
|
180
186
|
return results
|
|
181
187
|
|
|
182
188
|
# Removed legacy ls() convenience to keep lean surface
|
|
183
|
-
|
|
189
|
+
|
|
184
190
|
def read(
|
|
185
|
-
self,
|
|
191
|
+
self,
|
|
186
192
|
file_path: str,
|
|
187
193
|
offset: int = 0,
|
|
188
194
|
limit: int = 2000,
|
|
189
195
|
) -> str:
|
|
190
196
|
"""Read file content with line numbers.
|
|
191
|
-
|
|
197
|
+
|
|
192
198
|
Args:
|
|
193
199
|
file_path: Absolute or relative file path
|
|
194
200
|
offset: Line offset to start reading from (0-indexed)
|
|
@@ -196,10 +202,10 @@ class FilesystemBackend:
|
|
|
196
202
|
Formatted file content with line numbers, or error message.
|
|
197
203
|
"""
|
|
198
204
|
resolved_path = self._resolve_path(file_path)
|
|
199
|
-
|
|
205
|
+
|
|
200
206
|
if not resolved_path.exists() or not resolved_path.is_file():
|
|
201
207
|
return f"Error: File '{file_path}' not found"
|
|
202
|
-
|
|
208
|
+
|
|
203
209
|
try:
|
|
204
210
|
# Open with O_NOFOLLOW where available to avoid symlink traversal
|
|
205
211
|
try:
|
|
@@ -208,27 +214,27 @@ class FilesystemBackend:
|
|
|
208
214
|
content = f.read()
|
|
209
215
|
except OSError:
|
|
210
216
|
# Fallback to normal open if O_NOFOLLOW unsupported or fails
|
|
211
|
-
with open(resolved_path,
|
|
217
|
+
with open(resolved_path, encoding="utf-8") as f:
|
|
212
218
|
content = f.read()
|
|
213
|
-
|
|
219
|
+
|
|
214
220
|
empty_msg = check_empty_content(content)
|
|
215
221
|
if empty_msg:
|
|
216
222
|
return empty_msg
|
|
217
|
-
|
|
223
|
+
|
|
218
224
|
lines = content.splitlines()
|
|
219
225
|
start_idx = offset
|
|
220
226
|
end_idx = min(start_idx + limit, len(lines))
|
|
221
|
-
|
|
227
|
+
|
|
222
228
|
if start_idx >= len(lines):
|
|
223
229
|
return f"Error: Line offset {offset} exceeds file length ({len(lines)} lines)"
|
|
224
|
-
|
|
230
|
+
|
|
225
231
|
selected_lines = lines[start_idx:end_idx]
|
|
226
232
|
return format_content_with_line_numbers(selected_lines, start_line=start_idx + 1)
|
|
227
233
|
except (OSError, UnicodeDecodeError) as e:
|
|
228
234
|
return f"Error reading file '{file_path}': {e}"
|
|
229
|
-
|
|
235
|
+
|
|
230
236
|
def write(
|
|
231
|
-
self,
|
|
237
|
+
self,
|
|
232
238
|
file_path: str,
|
|
233
239
|
content: str,
|
|
234
240
|
) -> WriteResult:
|
|
@@ -236,10 +242,10 @@ class FilesystemBackend:
|
|
|
236
242
|
Returns WriteResult. External storage sets files_update=None.
|
|
237
243
|
"""
|
|
238
244
|
resolved_path = self._resolve_path(file_path)
|
|
239
|
-
|
|
245
|
+
|
|
240
246
|
if resolved_path.exists():
|
|
241
247
|
return WriteResult(error=f"Cannot write to {file_path} because it already exists. Read and then make an edit, or write to a new path.")
|
|
242
|
-
|
|
248
|
+
|
|
243
249
|
try:
|
|
244
250
|
# Create parent directories if needed
|
|
245
251
|
resolved_path.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -251,13 +257,13 @@ class FilesystemBackend:
|
|
|
251
257
|
fd = os.open(resolved_path, flags, 0o644)
|
|
252
258
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
253
259
|
f.write(content)
|
|
254
|
-
|
|
260
|
+
|
|
255
261
|
return WriteResult(path=file_path, files_update=None)
|
|
256
262
|
except (OSError, UnicodeEncodeError) as e:
|
|
257
263
|
return WriteResult(error=f"Error writing file '{file_path}': {e}")
|
|
258
|
-
|
|
264
|
+
|
|
259
265
|
def edit(
|
|
260
|
-
self,
|
|
266
|
+
self,
|
|
261
267
|
file_path: str,
|
|
262
268
|
old_string: str,
|
|
263
269
|
new_string: str,
|
|
@@ -267,10 +273,10 @@ class FilesystemBackend:
|
|
|
267
273
|
Returns EditResult. External storage sets files_update=None.
|
|
268
274
|
"""
|
|
269
275
|
resolved_path = self._resolve_path(file_path)
|
|
270
|
-
|
|
276
|
+
|
|
271
277
|
if not resolved_path.exists() or not resolved_path.is_file():
|
|
272
278
|
return EditResult(error=f"Error: File '{file_path}' not found")
|
|
273
|
-
|
|
279
|
+
|
|
274
280
|
try:
|
|
275
281
|
# Read securely
|
|
276
282
|
try:
|
|
@@ -278,16 +284,16 @@ class FilesystemBackend:
|
|
|
278
284
|
with os.fdopen(fd, "r", encoding="utf-8") as f:
|
|
279
285
|
content = f.read()
|
|
280
286
|
except OSError:
|
|
281
|
-
with open(resolved_path,
|
|
287
|
+
with open(resolved_path, encoding="utf-8") as f:
|
|
282
288
|
content = f.read()
|
|
283
|
-
|
|
289
|
+
|
|
284
290
|
result = perform_string_replacement(content, old_string, new_string, replace_all)
|
|
285
|
-
|
|
291
|
+
|
|
286
292
|
if isinstance(result, str):
|
|
287
293
|
return EditResult(error=result)
|
|
288
|
-
|
|
294
|
+
|
|
289
295
|
new_content, occurrences = result
|
|
290
|
-
|
|
296
|
+
|
|
291
297
|
# Write securely
|
|
292
298
|
flags = os.O_WRONLY | os.O_TRUNC
|
|
293
299
|
if hasattr(os, "O_NOFOLLOW"):
|
|
@@ -295,18 +301,18 @@ class FilesystemBackend:
|
|
|
295
301
|
fd = os.open(resolved_path, flags)
|
|
296
302
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
297
303
|
f.write(new_content)
|
|
298
|
-
|
|
304
|
+
|
|
299
305
|
return EditResult(path=file_path, files_update=None, occurrences=int(occurrences))
|
|
300
306
|
except (OSError, UnicodeDecodeError, UnicodeEncodeError) as e:
|
|
301
307
|
return EditResult(error=f"Error editing file '{file_path}': {e}")
|
|
302
|
-
|
|
308
|
+
|
|
303
309
|
# Removed legacy grep() convenience to keep lean surface
|
|
304
310
|
|
|
305
311
|
def grep_raw(
|
|
306
312
|
self,
|
|
307
313
|
pattern: str,
|
|
308
|
-
path:
|
|
309
|
-
glob:
|
|
314
|
+
path: str | None = None,
|
|
315
|
+
glob: str | None = None,
|
|
310
316
|
) -> list[GrepMatch] | str:
|
|
311
317
|
# Validate regex
|
|
312
318
|
try:
|
|
@@ -334,9 +340,7 @@ class FilesystemBackend:
|
|
|
334
340
|
matches.append({"path": fpath, "line": int(line_num), "text": line_text})
|
|
335
341
|
return matches
|
|
336
342
|
|
|
337
|
-
def _ripgrep_search(
|
|
338
|
-
self, pattern: str, base_full: Path, include_glob: Optional[str]
|
|
339
|
-
) -> Optional[dict[str, list[tuple[int, str]]]]:
|
|
343
|
+
def _ripgrep_search(self, pattern: str, base_full: Path, include_glob: str | None) -> dict[str, list[tuple[int, str]]] | None:
|
|
340
344
|
cmd = ["rg", "--json"]
|
|
341
345
|
if include_glob:
|
|
342
346
|
cmd.extend(["--glob", include_glob])
|
|
@@ -381,9 +385,7 @@ class FilesystemBackend:
|
|
|
381
385
|
|
|
382
386
|
return results
|
|
383
387
|
|
|
384
|
-
def _python_search(
|
|
385
|
-
self, pattern: str, base_full: Path, include_glob: Optional[str]
|
|
386
|
-
) -> dict[str, list[tuple[int, str]]]:
|
|
388
|
+
def _python_search(self, pattern: str, base_full: Path, include_glob: str | None) -> dict[str, list[tuple[int, str]]]:
|
|
387
389
|
try:
|
|
388
390
|
regex = re.compile(pattern)
|
|
389
391
|
except re.error:
|
|
@@ -418,7 +420,7 @@ class FilesystemBackend:
|
|
|
418
420
|
results.setdefault(virt_path, []).append((line_num, line))
|
|
419
421
|
|
|
420
422
|
return results
|
|
421
|
-
|
|
423
|
+
|
|
422
424
|
def glob_info(self, pattern: str, path: str = "/") -> list[FileInfo]:
|
|
423
425
|
if pattern.startswith("/"):
|
|
424
426
|
pattern = pattern.lstrip("/")
|
|
@@ -441,12 +443,14 @@ class FilesystemBackend:
|
|
|
441
443
|
if not self.virtual_mode:
|
|
442
444
|
try:
|
|
443
445
|
st = matched_path.stat()
|
|
444
|
-
results.append(
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
446
|
+
results.append(
|
|
447
|
+
{
|
|
448
|
+
"path": abs_path,
|
|
449
|
+
"is_dir": False,
|
|
450
|
+
"size": int(st.st_size),
|
|
451
|
+
"modified_at": datetime.fromtimestamp(st.st_mtime).isoformat(),
|
|
452
|
+
}
|
|
453
|
+
)
|
|
450
454
|
except OSError:
|
|
451
455
|
results.append({"path": abs_path, "is_dir": False})
|
|
452
456
|
else:
|
|
@@ -454,20 +458,22 @@ class FilesystemBackend:
|
|
|
454
458
|
if not cwd_str.endswith("/"):
|
|
455
459
|
cwd_str += "/"
|
|
456
460
|
if abs_path.startswith(cwd_str):
|
|
457
|
-
relative_path = abs_path[len(cwd_str):]
|
|
461
|
+
relative_path = abs_path[len(cwd_str) :]
|
|
458
462
|
elif abs_path.startswith(str(self.cwd)):
|
|
459
|
-
relative_path = abs_path[len(str(self.cwd)):].lstrip("/")
|
|
463
|
+
relative_path = abs_path[len(str(self.cwd)) :].lstrip("/")
|
|
460
464
|
else:
|
|
461
465
|
relative_path = abs_path
|
|
462
466
|
virt = "/" + relative_path
|
|
463
467
|
try:
|
|
464
468
|
st = matched_path.stat()
|
|
465
|
-
results.append(
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
469
|
+
results.append(
|
|
470
|
+
{
|
|
471
|
+
"path": virt,
|
|
472
|
+
"is_dir": False,
|
|
473
|
+
"size": int(st.st_size),
|
|
474
|
+
"modified_at": datetime.fromtimestamp(st.st_mtime).isoformat(),
|
|
475
|
+
}
|
|
476
|
+
)
|
|
471
477
|
except OSError:
|
|
472
478
|
results.append({"path": virt, "is_dir": False})
|
|
473
479
|
except (OSError, ValueError):
|