deepanalysts 0.2.4__tar.gz → 0.3.0__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.
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/PKG-INFO +2 -2
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/__init__.py +13 -0
- deepanalysts-0.3.0/deepanalysts/_messages_reducer.py +101 -0
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/backends/composite.py +87 -60
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/backends/filesystem.py +20 -17
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/backends/protocol.py +88 -54
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/backends/sandbox.py +12 -9
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/backends/state.py +19 -14
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/backends/store.py +32 -32
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/backends/supabase_storage.py +21 -16
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/middleware/filesystem.py +69 -29
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/middleware/skills.py +10 -4
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/middleware/tool_errors.py +15 -2
- deepanalysts-0.3.0/deepanalysts/state.py +48 -0
- deepanalysts-0.3.0/deepanalysts/streaming.py +358 -0
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts.egg-info/PKG-INFO +2 -2
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts.egg-info/SOURCES.txt +10 -0
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts.egg-info/requires.txt +1 -1
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/pyproject.toml +13 -2
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/tests/test_composite_backend.py +32 -26
- deepanalysts-0.3.0/tests/test_messages_reducer.py +107 -0
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/tests/test_prompt_sections.py +1 -3
- deepanalysts-0.3.0/tests/test_protocol_bridge.py +54 -0
- deepanalysts-0.3.0/tests/test_regression_files_delta.py +128 -0
- deepanalysts-0.3.0/tests/test_regression_middleware_tools.py +157 -0
- deepanalysts-0.3.0/tests/test_regression_new_api.py +242 -0
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/tests/test_sandbox_backend.py +7 -7
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/tests/test_store_backend.py +20 -16
- deepanalysts-0.3.0/tests/test_streaming_transformer.py +172 -0
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/tests/test_supabase_storage_backend.py +32 -26
- deepanalysts-0.3.0/tests/test_tool_errors.py +105 -0
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/README.md +0 -0
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/backends/__init__.py +0 -0
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/backends/basement.py +0 -0
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/backends/utils.py +0 -0
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/clients/__init__.py +0 -0
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/clients/basement.py +0 -0
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/middleware/__init__.py +0 -0
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/middleware/_utils.py +0 -0
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/middleware/memory.py +0 -0
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/middleware/patch_tool_calls.py +0 -0
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/middleware/subagents.py +0 -0
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/middleware/summarization.py +0 -0
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/utils/__init__.py +0 -0
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/utils/retry.py +0 -0
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts.egg-info/dependency_links.txt +0 -0
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts.egg-info/top_level.txt +0 -0
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/setup.cfg +0 -0
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/tests/test_basement.py +0 -0
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/tests/test_filesystem_middleware.py +0 -0
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/tests/test_skills_middleware.py +0 -0
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/tests/test_summarization_middleware.py +0 -0
- {deepanalysts-0.2.4 → deepanalysts-0.3.0}/tests/test_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: deepanalysts
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: LangChain/LangGraph middleware for building AI agents with memory, skills, and filesystem support
|
|
5
5
|
Author-email: Ganchuluun Narantsatsralt <tsatsralt@swifttech.cloud>
|
|
6
6
|
License: MIT
|
|
@@ -19,7 +19,7 @@ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
|
19
19
|
Requires-Python: <4.0,>=3.11
|
|
20
20
|
Description-Content-Type: text/markdown
|
|
21
21
|
Requires-Dist: langchain<2.0.0,>=1.2.3
|
|
22
|
-
Requires-Dist: langgraph>=
|
|
22
|
+
Requires-Dist: langgraph>=1.2.0
|
|
23
23
|
Requires-Dist: httpx>=0.28.0
|
|
24
24
|
Requires-Dist: pyyaml>=6.0
|
|
25
25
|
Requires-Dist: tenacity>=8.2.0
|
|
@@ -39,6 +39,12 @@ from deepanalysts.middleware import (
|
|
|
39
39
|
ToolErrorHandlingMiddleware,
|
|
40
40
|
TruncateArgsSettings,
|
|
41
41
|
)
|
|
42
|
+
from deepanalysts.state import DEFAULT_SNAPSHOT_FREQUENCY, DeepAnalystsState
|
|
43
|
+
from deepanalysts.streaming import (
|
|
44
|
+
AsyncSubagentRunStream,
|
|
45
|
+
SubagentRunStream,
|
|
46
|
+
SubagentTransformer,
|
|
47
|
+
)
|
|
42
48
|
|
|
43
49
|
__all__ = [
|
|
44
50
|
# Middleware classes
|
|
@@ -49,6 +55,13 @@ __all__ = [
|
|
|
49
55
|
"SubAgentMiddleware",
|
|
50
56
|
"SummarizationMiddleware",
|
|
51
57
|
"ToolErrorHandlingMiddleware",
|
|
58
|
+
# State
|
|
59
|
+
"DeepAnalystsState",
|
|
60
|
+
"DEFAULT_SNAPSHOT_FREQUENCY",
|
|
61
|
+
# Streaming
|
|
62
|
+
"AsyncSubagentRunStream",
|
|
63
|
+
"SubagentRunStream",
|
|
64
|
+
"SubagentTransformer",
|
|
52
65
|
# TypedDicts and types
|
|
53
66
|
"CompiledSubAgent",
|
|
54
67
|
"SkillMetadata",
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Local `DeltaChannel` reducer for the messages key.
|
|
2
|
+
|
|
3
|
+
Adapted from langgraph's `_messages_delta_reducer` (PR #7729) and ported from
|
|
4
|
+
upstream `deepagents` (`deepagents/_messages_reducer.py`).
|
|
5
|
+
|
|
6
|
+
Why a local copy instead of delegating to `langgraph.graph.message._messages_delta_reducer`:
|
|
7
|
+
|
|
8
|
+
- Upstream is flagged **Experimental** in its docstring.
|
|
9
|
+
- Upstream does NOT handle `REMOVE_ALL_MESSAGES`, missing-id UUID assignment,
|
|
10
|
+
or `BaseMessageChunk` conversion. We need the first two for parity with
|
|
11
|
+
`add_messages` behaviour that callers depend on.
|
|
12
|
+
- Upstream always calls `convert_to_messages(flat)` — we skip that on the
|
|
13
|
+
steady-state path when writes are already typed `BaseMessage` instances.
|
|
14
|
+
- Upstream always rebuilds the final list via a filter comprehension. We
|
|
15
|
+
short-circuit when no `RemoveMessage` was applied (the append-only common
|
|
16
|
+
case for `create_agent`).
|
|
17
|
+
|
|
18
|
+
Pair this with `langgraph.channels.delta.DeltaChannel(snapshot_frequency=N)`
|
|
19
|
+
on a typed-dict messages key (see `deepanalysts.state.DeepAnalystsState`).
|
|
20
|
+
That cuts checkpoint write growth from O(N²) to O(N) per turn, which matters
|
|
21
|
+
for long Park threads persisted to Supabase.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import uuid
|
|
27
|
+
from typing import Any, cast
|
|
28
|
+
|
|
29
|
+
from langchain_core.messages import (
|
|
30
|
+
AnyMessage,
|
|
31
|
+
BaseMessage,
|
|
32
|
+
RemoveMessage,
|
|
33
|
+
convert_to_messages,
|
|
34
|
+
)
|
|
35
|
+
from langgraph.graph.message import REMOVE_ALL_MESSAGES
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _messages_delta_reducer(
|
|
39
|
+
state: list[AnyMessage], writes: list[list[AnyMessage]]
|
|
40
|
+
) -> list[AnyMessage]:
|
|
41
|
+
"""Batch reducer for use with `DeltaChannel` on the messages key.
|
|
42
|
+
|
|
43
|
+
Dedups by ID, tombstones via `RemoveMessage`, resets on
|
|
44
|
+
`REMOVE_ALL_MESSAGES`. ID-less messages are assigned a UUID before being
|
|
45
|
+
appended, matching the behaviour of `add_messages`.
|
|
46
|
+
|
|
47
|
+
Raw dict / string / tuple inputs are coerced to typed `BaseMessage` so
|
|
48
|
+
HTTP-driven graphs work without a separate coercion step.
|
|
49
|
+
"""
|
|
50
|
+
flat: list[Any] = []
|
|
51
|
+
for w in writes:
|
|
52
|
+
if isinstance(w, list):
|
|
53
|
+
flat.extend(w)
|
|
54
|
+
else:
|
|
55
|
+
flat.append(w)
|
|
56
|
+
# Steady-state fast path: every reducer output is already a typed
|
|
57
|
+
# `BaseMessage`. We probe only `state[0]` / `flat[0]` (all-or-nothing
|
|
58
|
+
# invariant); callers that seed graph state with a mix of types pay
|
|
59
|
+
# the slow path on first reduction and stay typed thereafter.
|
|
60
|
+
state_msgs = state if state and isinstance(state[0], BaseMessage) else cast("list[AnyMessage]", convert_to_messages(state))
|
|
61
|
+
msgs = flat if flat and isinstance(flat[0], BaseMessage) else cast("list[AnyMessage]", convert_to_messages(flat))
|
|
62
|
+
|
|
63
|
+
remove_all_idx = None
|
|
64
|
+
for idx, m in enumerate(msgs):
|
|
65
|
+
if isinstance(m, RemoveMessage) and m.id == REMOVE_ALL_MESSAGES:
|
|
66
|
+
remove_all_idx = idx
|
|
67
|
+
if remove_all_idx is not None:
|
|
68
|
+
state_msgs = []
|
|
69
|
+
msgs = msgs[remove_all_idx + 1 :]
|
|
70
|
+
|
|
71
|
+
result: list[AnyMessage | None] = []
|
|
72
|
+
index: dict[str, int] = {}
|
|
73
|
+
for m in state_msgs:
|
|
74
|
+
if m.id is None:
|
|
75
|
+
m.id = str(uuid.uuid4())
|
|
76
|
+
index[m.id] = len(result)
|
|
77
|
+
result.append(m)
|
|
78
|
+
had_removal = False
|
|
79
|
+
for msg in msgs:
|
|
80
|
+
mid = msg.id
|
|
81
|
+
if mid is None:
|
|
82
|
+
msg.id = str(uuid.uuid4())
|
|
83
|
+
mid = msg.id
|
|
84
|
+
index[mid] = len(result)
|
|
85
|
+
result.append(msg)
|
|
86
|
+
elif isinstance(msg, RemoveMessage):
|
|
87
|
+
if mid in index:
|
|
88
|
+
result[index[mid]] = None
|
|
89
|
+
del index[mid]
|
|
90
|
+
had_removal = True
|
|
91
|
+
elif mid in index:
|
|
92
|
+
result[index[mid]] = msg
|
|
93
|
+
else:
|
|
94
|
+
index[mid] = len(result)
|
|
95
|
+
result.append(msg)
|
|
96
|
+
# Append-only is the common case — skip the filter rebuild when nothing
|
|
97
|
+
# was tombstoned. cast() is safe: `result` only holds `None` after a
|
|
98
|
+
# `RemoveMessage` was applied, which we just verified did not happen.
|
|
99
|
+
if not had_removal:
|
|
100
|
+
return cast("list[AnyMessage]", result)
|
|
101
|
+
return [m for m in result if m is not None]
|
|
@@ -18,7 +18,10 @@ from deepanalysts.backends.protocol import (
|
|
|
18
18
|
FileDownloadResponse,
|
|
19
19
|
FileInfo,
|
|
20
20
|
FileUploadResponse,
|
|
21
|
+
GlobResult,
|
|
21
22
|
GrepMatch,
|
|
23
|
+
GrepResult,
|
|
24
|
+
LsResult,
|
|
22
25
|
SandboxBackendProtocol,
|
|
23
26
|
WriteResult,
|
|
24
27
|
)
|
|
@@ -130,22 +133,28 @@ class CompositeBackend(SandboxBackendProtocol):
|
|
|
130
133
|
# Sync protocol methods
|
|
131
134
|
# ------------------------------------------------------------------
|
|
132
135
|
|
|
133
|
-
def
|
|
136
|
+
def ls(self, path: str) -> LsResult:
|
|
134
137
|
backend, backend_path, route_prefix = self._route(path)
|
|
135
138
|
|
|
136
139
|
if route_prefix is not None:
|
|
137
|
-
|
|
138
|
-
|
|
140
|
+
child = backend.ls(backend_path)
|
|
141
|
+
if child.error is not None:
|
|
142
|
+
return child
|
|
143
|
+
entries = [_remap_file_info_path(fi, route_prefix) for fi in (child.entries or [])]
|
|
144
|
+
return LsResult(entries=entries)
|
|
139
145
|
|
|
140
146
|
# At root, aggregate default and virtual route directories
|
|
141
147
|
if path == "/":
|
|
142
|
-
|
|
148
|
+
default_result = self.default.ls(path)
|
|
149
|
+
if default_result.error is not None:
|
|
150
|
+
return default_result
|
|
151
|
+
results: list[FileInfo] = list(default_result.entries or [])
|
|
143
152
|
for rp, _b in self.sorted_routes:
|
|
144
153
|
results.append({"path": rp, "is_dir": True, "size": 0, "modified_at": ""})
|
|
145
154
|
results.sort(key=lambda x: x.get("path", ""))
|
|
146
|
-
return results
|
|
155
|
+
return LsResult(entries=results)
|
|
147
156
|
|
|
148
|
-
return self.default.
|
|
157
|
+
return self.default.ls(path)
|
|
149
158
|
|
|
150
159
|
def read(self, file_path: str, offset: int = 0, limit: int = 2000) -> str:
|
|
151
160
|
backend, backend_path, _rp = self._route(file_path)
|
|
@@ -165,57 +174,63 @@ class CompositeBackend(SandboxBackendProtocol):
|
|
|
165
174
|
backend, backend_path, _rp = self._route(file_path)
|
|
166
175
|
return backend.edit(backend_path, old_string, new_string, replace_all=replace_all)
|
|
167
176
|
|
|
168
|
-
def
|
|
177
|
+
def grep(
|
|
169
178
|
self,
|
|
170
179
|
pattern: str,
|
|
171
180
|
path: str | None = None,
|
|
172
181
|
glob: str | None = None,
|
|
173
|
-
) ->
|
|
182
|
+
) -> GrepResult:
|
|
174
183
|
# If path targets a specific route, search only that backend
|
|
175
184
|
if path is not None:
|
|
176
185
|
for route_prefix, backend in self.sorted_routes:
|
|
177
186
|
if path.startswith(route_prefix.rstrip("/")):
|
|
178
187
|
search_path = path[len(route_prefix) - 1 :] or "/"
|
|
179
|
-
|
|
180
|
-
if
|
|
181
|
-
return
|
|
182
|
-
return [_remap_grep_path(m, route_prefix) for m in
|
|
188
|
+
child = backend.grep(pattern, search_path, glob)
|
|
189
|
+
if child.error is not None:
|
|
190
|
+
return child
|
|
191
|
+
return GrepResult(matches=[_remap_grep_path(m, route_prefix) for m in (child.matches or [])])
|
|
183
192
|
|
|
184
193
|
# Search default + all routed backends
|
|
185
194
|
if path is None or path == "/":
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
all_matches.extend(raw_default)
|
|
195
|
+
default_result = self.default.grep(pattern, path, glob)
|
|
196
|
+
if default_result.error is not None:
|
|
197
|
+
return default_result
|
|
198
|
+
all_matches: list[GrepMatch] = list(default_result.matches or [])
|
|
191
199
|
|
|
192
200
|
for route_prefix, backend in self.routes.items():
|
|
193
|
-
|
|
194
|
-
if
|
|
195
|
-
return
|
|
196
|
-
all_matches.extend(_remap_grep_path(m, route_prefix) for m in
|
|
201
|
+
child = backend.grep(pattern, "/", glob)
|
|
202
|
+
if child.error is not None:
|
|
203
|
+
return child
|
|
204
|
+
all_matches.extend(_remap_grep_path(m, route_prefix) for m in (child.matches or []))
|
|
197
205
|
|
|
198
|
-
return all_matches
|
|
206
|
+
return GrepResult(matches=all_matches)
|
|
199
207
|
|
|
200
|
-
return self.default.
|
|
208
|
+
return self.default.grep(pattern, path, glob)
|
|
201
209
|
|
|
202
|
-
def
|
|
210
|
+
def glob(self, pattern: str, path: str = "/") -> GlobResult:
|
|
203
211
|
for route_prefix, backend in self.sorted_routes:
|
|
204
212
|
if path.startswith(route_prefix.rstrip("/")):
|
|
205
213
|
search_path = path[len(route_prefix) - 1 :] or "/"
|
|
206
214
|
stripped = _strip_route_from_pattern(pattern, route_prefix)
|
|
207
|
-
|
|
208
|
-
|
|
215
|
+
child = backend.glob(stripped, search_path)
|
|
216
|
+
if child.error is not None:
|
|
217
|
+
return child
|
|
218
|
+
return GlobResult(matches=[_remap_file_info_path(fi, route_prefix) for fi in (child.matches or [])])
|
|
209
219
|
|
|
210
|
-
|
|
220
|
+
default_result = self.default.glob(pattern, path)
|
|
221
|
+
if default_result.error is not None:
|
|
222
|
+
return default_result
|
|
223
|
+
results: list[FileInfo] = list(default_result.matches or [])
|
|
211
224
|
|
|
212
225
|
for route_prefix, backend in self.routes.items():
|
|
213
226
|
stripped = _strip_route_from_pattern(pattern, route_prefix)
|
|
214
|
-
|
|
215
|
-
|
|
227
|
+
child = backend.glob(stripped, "/")
|
|
228
|
+
if child.error is not None:
|
|
229
|
+
return child
|
|
230
|
+
results.extend(_remap_file_info_path(fi, route_prefix) for fi in (child.matches or []))
|
|
216
231
|
|
|
217
232
|
results.sort(key=lambda x: x.get("path", ""))
|
|
218
|
-
return results
|
|
233
|
+
return GlobResult(matches=results)
|
|
219
234
|
|
|
220
235
|
def execute(self, command: str) -> ExecuteResponse:
|
|
221
236
|
if isinstance(self.default, SandboxBackendProtocol):
|
|
@@ -247,21 +262,27 @@ class CompositeBackend(SandboxBackendProtocol):
|
|
|
247
262
|
# which fails on async-only backends like SupabaseStorageBackend.
|
|
248
263
|
# ------------------------------------------------------------------
|
|
249
264
|
|
|
250
|
-
async def
|
|
265
|
+
async def als(self, path: str) -> LsResult:
|
|
251
266
|
backend, backend_path, route_prefix = self._route(path)
|
|
252
267
|
|
|
253
268
|
if route_prefix is not None:
|
|
254
|
-
|
|
255
|
-
|
|
269
|
+
child = await backend.als(backend_path)
|
|
270
|
+
if child.error is not None:
|
|
271
|
+
return child
|
|
272
|
+
entries = [_remap_file_info_path(fi, route_prefix) for fi in (child.entries or [])]
|
|
273
|
+
return LsResult(entries=entries)
|
|
256
274
|
|
|
257
275
|
if path == "/":
|
|
258
|
-
|
|
276
|
+
default_result = await self.default.als(path)
|
|
277
|
+
if default_result.error is not None:
|
|
278
|
+
return default_result
|
|
279
|
+
results: list[FileInfo] = list(default_result.entries or [])
|
|
259
280
|
for rp, _b in self.sorted_routes:
|
|
260
281
|
results.append({"path": rp, "is_dir": True, "size": 0, "modified_at": ""})
|
|
261
282
|
results.sort(key=lambda x: x.get("path", ""))
|
|
262
|
-
return results
|
|
283
|
+
return LsResult(entries=results)
|
|
263
284
|
|
|
264
|
-
return await self.default.
|
|
285
|
+
return await self.default.als(path)
|
|
265
286
|
|
|
266
287
|
async def aread(self, file_path: str, offset: int = 0, limit: int = 2000) -> str:
|
|
267
288
|
backend, backend_path, _rp = self._route(file_path)
|
|
@@ -281,55 +302,61 @@ class CompositeBackend(SandboxBackendProtocol):
|
|
|
281
302
|
backend, backend_path, _rp = self._route(file_path)
|
|
282
303
|
return await backend.aedit(backend_path, old_string, new_string, replace_all=replace_all)
|
|
283
304
|
|
|
284
|
-
async def
|
|
305
|
+
async def agrep(
|
|
285
306
|
self,
|
|
286
307
|
pattern: str,
|
|
287
308
|
path: str | None = None,
|
|
288
309
|
glob: str | None = None,
|
|
289
|
-
) ->
|
|
310
|
+
) -> GrepResult:
|
|
290
311
|
if path is not None:
|
|
291
312
|
for route_prefix, backend in self.sorted_routes:
|
|
292
313
|
if path.startswith(route_prefix.rstrip("/")):
|
|
293
314
|
search_path = path[len(route_prefix) - 1 :] or "/"
|
|
294
|
-
|
|
295
|
-
if
|
|
296
|
-
return
|
|
297
|
-
return [_remap_grep_path(m, route_prefix) for m in
|
|
315
|
+
child = await backend.agrep(pattern, search_path, glob)
|
|
316
|
+
if child.error is not None:
|
|
317
|
+
return child
|
|
318
|
+
return GrepResult(matches=[_remap_grep_path(m, route_prefix) for m in (child.matches or [])])
|
|
298
319
|
|
|
299
320
|
if path is None or path == "/":
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
all_matches.extend(raw_default)
|
|
321
|
+
default_result = await self.default.agrep(pattern, path, glob)
|
|
322
|
+
if default_result.error is not None:
|
|
323
|
+
return default_result
|
|
324
|
+
all_matches: list[GrepMatch] = list(default_result.matches or [])
|
|
305
325
|
|
|
306
326
|
for route_prefix, backend in self.routes.items():
|
|
307
|
-
|
|
308
|
-
if
|
|
309
|
-
return
|
|
310
|
-
all_matches.extend(_remap_grep_path(m, route_prefix) for m in
|
|
327
|
+
child = await backend.agrep(pattern, "/", glob)
|
|
328
|
+
if child.error is not None:
|
|
329
|
+
return child
|
|
330
|
+
all_matches.extend(_remap_grep_path(m, route_prefix) for m in (child.matches or []))
|
|
311
331
|
|
|
312
|
-
return all_matches
|
|
332
|
+
return GrepResult(matches=all_matches)
|
|
313
333
|
|
|
314
|
-
return await self.default.
|
|
334
|
+
return await self.default.agrep(pattern, path, glob)
|
|
315
335
|
|
|
316
|
-
async def
|
|
336
|
+
async def aglob(self, pattern: str, path: str = "/") -> GlobResult:
|
|
317
337
|
for route_prefix, backend in self.sorted_routes:
|
|
318
338
|
if path.startswith(route_prefix.rstrip("/")):
|
|
319
339
|
search_path = path[len(route_prefix) - 1 :] or "/"
|
|
320
340
|
stripped = _strip_route_from_pattern(pattern, route_prefix)
|
|
321
|
-
|
|
322
|
-
|
|
341
|
+
child = await backend.aglob(stripped, search_path)
|
|
342
|
+
if child.error is not None:
|
|
343
|
+
return child
|
|
344
|
+
return GlobResult(matches=[_remap_file_info_path(fi, route_prefix) for fi in (child.matches or [])])
|
|
323
345
|
|
|
324
|
-
|
|
346
|
+
default_result = await self.default.aglob(pattern, path)
|
|
347
|
+
if default_result.error is not None:
|
|
348
|
+
return default_result
|
|
349
|
+
results: list[FileInfo] = list(default_result.matches or [])
|
|
325
350
|
|
|
326
351
|
for route_prefix, backend in self.routes.items():
|
|
327
352
|
stripped = _strip_route_from_pattern(pattern, route_prefix)
|
|
328
|
-
|
|
329
|
-
|
|
353
|
+
child = await backend.aglob(stripped, "/")
|
|
354
|
+
if child.error is not None:
|
|
355
|
+
return child
|
|
356
|
+
results.extend(_remap_file_info_path(fi, route_prefix) for fi in (child.matches or []))
|
|
330
357
|
|
|
331
358
|
results.sort(key=lambda x: x.get("path", ""))
|
|
332
|
-
return results
|
|
359
|
+
return GlobResult(matches=results)
|
|
333
360
|
|
|
334
361
|
async def aupload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
|
|
335
362
|
results: list[FileUploadResponse | None] = [None] * len(files)
|
|
@@ -31,7 +31,10 @@ from deepanalysts.backends.protocol import (
|
|
|
31
31
|
FileInfo,
|
|
32
32
|
FileOperationError,
|
|
33
33
|
FileUploadResponse,
|
|
34
|
+
GlobResult,
|
|
34
35
|
GrepMatch,
|
|
36
|
+
GrepResult,
|
|
37
|
+
LsResult,
|
|
35
38
|
WriteResult,
|
|
36
39
|
)
|
|
37
40
|
from deepanalysts.backends.utils import check_empty_content, perform_string_replacement
|
|
@@ -137,18 +140,18 @@ class LocalFilesystemBackend(BackendProtocol):
|
|
|
137
140
|
root = self._root or Path.cwd()
|
|
138
141
|
return "/" + path.resolve().relative_to(root).as_posix()
|
|
139
142
|
|
|
140
|
-
def
|
|
143
|
+
def ls(self, path: str) -> LsResult:
|
|
141
144
|
"""List files in a directory.
|
|
142
145
|
|
|
143
146
|
Args:
|
|
144
147
|
path: Directory path to list.
|
|
145
148
|
|
|
146
149
|
Returns:
|
|
147
|
-
|
|
150
|
+
`LsResult` with entries (path, is_dir, size, modified_at).
|
|
148
151
|
"""
|
|
149
152
|
resolved = self._resolve_path(path)
|
|
150
153
|
if not resolved.exists() or not resolved.is_dir():
|
|
151
|
-
return []
|
|
154
|
+
return LsResult(entries=[])
|
|
152
155
|
|
|
153
156
|
results: list[FileInfo] = []
|
|
154
157
|
try:
|
|
@@ -182,7 +185,7 @@ class LocalFilesystemBackend(BackendProtocol):
|
|
|
182
185
|
pass
|
|
183
186
|
|
|
184
187
|
results.sort(key=lambda x: x.get("path", ""))
|
|
185
|
-
return results
|
|
188
|
+
return LsResult(entries=results)
|
|
186
189
|
|
|
187
190
|
def read(self, file_path: str, offset: int = 0, limit: int = 2000) -> str:
|
|
188
191
|
"""Read file content with line numbers.
|
|
@@ -328,12 +331,12 @@ class LocalFilesystemBackend(BackendProtocol):
|
|
|
328
331
|
|
|
329
332
|
return EditResult(path=str(resolved), occurrences=int(occurrences))
|
|
330
333
|
|
|
331
|
-
def
|
|
334
|
+
def grep(
|
|
332
335
|
self,
|
|
333
336
|
pattern: str,
|
|
334
337
|
path: str | None = None,
|
|
335
338
|
glob: str | None = None,
|
|
336
|
-
) ->
|
|
339
|
+
) -> GrepResult:
|
|
337
340
|
"""Search for literal text pattern in files.
|
|
338
341
|
|
|
339
342
|
Tries ripgrep first (``rg -F`` for literal mode), falls back to
|
|
@@ -345,24 +348,24 @@ class LocalFilesystemBackend(BackendProtocol):
|
|
|
345
348
|
glob: File pattern to match.
|
|
346
349
|
|
|
347
350
|
Returns:
|
|
348
|
-
|
|
351
|
+
`GrepResult` with matches or error.
|
|
349
352
|
"""
|
|
350
353
|
try:
|
|
351
354
|
search_path = self._resolve_path(path or ".")
|
|
352
355
|
except ValueError:
|
|
353
|
-
return []
|
|
356
|
+
return GrepResult(matches=[])
|
|
354
357
|
|
|
355
358
|
if not search_path.exists():
|
|
356
|
-
return []
|
|
359
|
+
return GrepResult(matches=[])
|
|
357
360
|
|
|
358
361
|
# Try ripgrep first
|
|
359
362
|
rg_results = self._ripgrep_search(pattern, search_path, glob)
|
|
360
363
|
if rg_results is not None:
|
|
361
|
-
return self._results_to_matches(rg_results)
|
|
364
|
+
return GrepResult(matches=self._results_to_matches(rg_results))
|
|
362
365
|
|
|
363
366
|
# Python fallback with max_file_size protection
|
|
364
367
|
py_results = self._python_search(re.escape(pattern), search_path, glob)
|
|
365
|
-
return self._results_to_matches(py_results)
|
|
368
|
+
return GrepResult(matches=self._results_to_matches(py_results))
|
|
366
369
|
|
|
367
370
|
def _ripgrep_search(
|
|
368
371
|
self, pattern: str, base: Path, include_glob: str | None
|
|
@@ -470,7 +473,7 @@ class LocalFilesystemBackend(BackendProtocol):
|
|
|
470
473
|
matches.append({"path": fpath, "line": int(line_num), "text": line_text})
|
|
471
474
|
return matches
|
|
472
475
|
|
|
473
|
-
def
|
|
476
|
+
def glob(self, pattern: str, path: str = "/") -> GlobResult:
|
|
474
477
|
"""Find files matching glob pattern.
|
|
475
478
|
|
|
476
479
|
Args:
|
|
@@ -478,21 +481,21 @@ class LocalFilesystemBackend(BackendProtocol):
|
|
|
478
481
|
path: Base path.
|
|
479
482
|
|
|
480
483
|
Returns:
|
|
481
|
-
|
|
484
|
+
`GlobResult` with matching FileInfo entries.
|
|
482
485
|
"""
|
|
483
486
|
if pattern.startswith("/"):
|
|
484
487
|
pattern = pattern.lstrip("/")
|
|
485
488
|
|
|
486
489
|
if self._virtual_mode and ".." in Path(pattern).parts:
|
|
487
|
-
return []
|
|
490
|
+
return GlobResult(matches=[])
|
|
488
491
|
|
|
489
492
|
try:
|
|
490
493
|
search_path = self._resolve_path(path)
|
|
491
494
|
except ValueError:
|
|
492
|
-
return []
|
|
495
|
+
return GlobResult(matches=[])
|
|
493
496
|
|
|
494
497
|
if not search_path.exists():
|
|
495
|
-
return []
|
|
498
|
+
return GlobResult(matches=[])
|
|
496
499
|
|
|
497
500
|
results: list[FileInfo] = []
|
|
498
501
|
try:
|
|
@@ -523,7 +526,7 @@ class LocalFilesystemBackend(BackendProtocol):
|
|
|
523
526
|
pass
|
|
524
527
|
|
|
525
528
|
results.sort(key=lambda x: x.get("path", ""))
|
|
526
|
-
return results
|
|
529
|
+
return GlobResult(matches=results)
|
|
527
530
|
|
|
528
531
|
def execute(self, command: str, timeout: int = 120) -> ExecuteResponse:
|
|
529
532
|
"""Execute a shell command.
|