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.
Files changed (53) hide show
  1. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/PKG-INFO +2 -2
  2. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/__init__.py +13 -0
  3. deepanalysts-0.3.0/deepanalysts/_messages_reducer.py +101 -0
  4. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/backends/composite.py +87 -60
  5. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/backends/filesystem.py +20 -17
  6. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/backends/protocol.py +88 -54
  7. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/backends/sandbox.py +12 -9
  8. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/backends/state.py +19 -14
  9. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/backends/store.py +32 -32
  10. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/backends/supabase_storage.py +21 -16
  11. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/middleware/filesystem.py +69 -29
  12. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/middleware/skills.py +10 -4
  13. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/middleware/tool_errors.py +15 -2
  14. deepanalysts-0.3.0/deepanalysts/state.py +48 -0
  15. deepanalysts-0.3.0/deepanalysts/streaming.py +358 -0
  16. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts.egg-info/PKG-INFO +2 -2
  17. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts.egg-info/SOURCES.txt +10 -0
  18. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts.egg-info/requires.txt +1 -1
  19. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/pyproject.toml +13 -2
  20. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/tests/test_composite_backend.py +32 -26
  21. deepanalysts-0.3.0/tests/test_messages_reducer.py +107 -0
  22. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/tests/test_prompt_sections.py +1 -3
  23. deepanalysts-0.3.0/tests/test_protocol_bridge.py +54 -0
  24. deepanalysts-0.3.0/tests/test_regression_files_delta.py +128 -0
  25. deepanalysts-0.3.0/tests/test_regression_middleware_tools.py +157 -0
  26. deepanalysts-0.3.0/tests/test_regression_new_api.py +242 -0
  27. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/tests/test_sandbox_backend.py +7 -7
  28. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/tests/test_store_backend.py +20 -16
  29. deepanalysts-0.3.0/tests/test_streaming_transformer.py +172 -0
  30. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/tests/test_supabase_storage_backend.py +32 -26
  31. deepanalysts-0.3.0/tests/test_tool_errors.py +105 -0
  32. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/README.md +0 -0
  33. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/backends/__init__.py +0 -0
  34. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/backends/basement.py +0 -0
  35. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/backends/utils.py +0 -0
  36. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/clients/__init__.py +0 -0
  37. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/clients/basement.py +0 -0
  38. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/middleware/__init__.py +0 -0
  39. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/middleware/_utils.py +0 -0
  40. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/middleware/memory.py +0 -0
  41. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/middleware/patch_tool_calls.py +0 -0
  42. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/middleware/subagents.py +0 -0
  43. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/middleware/summarization.py +0 -0
  44. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/utils/__init__.py +0 -0
  45. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts/utils/retry.py +0 -0
  46. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts.egg-info/dependency_links.txt +0 -0
  47. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/deepanalysts.egg-info/top_level.txt +0 -0
  48. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/setup.cfg +0 -0
  49. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/tests/test_basement.py +0 -0
  50. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/tests/test_filesystem_middleware.py +0 -0
  51. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/tests/test_skills_middleware.py +0 -0
  52. {deepanalysts-0.2.4 → deepanalysts-0.3.0}/tests/test_summarization_middleware.py +0 -0
  53. {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.2.4
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>=0.2.0
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 ls_info(self, path: str) -> list[FileInfo]:
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
- infos = backend.ls_info(backend_path)
138
- return [_remap_file_info_path(fi, route_prefix) for fi in infos]
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
- results: list[FileInfo] = list(self.default.ls_info(path))
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.ls_info(path)
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 grep_raw(
177
+ def grep(
169
178
  self,
170
179
  pattern: str,
171
180
  path: str | None = None,
172
181
  glob: str | None = None,
173
- ) -> list[GrepMatch] | str:
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
- raw = backend.grep_raw(pattern, search_path, glob)
180
- if isinstance(raw, str):
181
- return raw
182
- return [_remap_grep_path(m, route_prefix) for m in raw]
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
- all_matches: list[GrepMatch] = []
187
- raw_default = self.default.grep_raw(pattern, path, glob)
188
- if isinstance(raw_default, str):
189
- return raw_default
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
- raw = backend.grep_raw(pattern, "/", glob)
194
- if isinstance(raw, str):
195
- return raw
196
- all_matches.extend(_remap_grep_path(m, route_prefix) for m in raw)
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.grep_raw(pattern, path, glob)
208
+ return self.default.grep(pattern, path, glob)
201
209
 
202
- def glob_info(self, pattern: str, path: str = "/") -> list[FileInfo]:
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
- infos = backend.glob_info(stripped, search_path)
208
- return [_remap_file_info_path(fi, route_prefix) for fi in infos]
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
- results: list[FileInfo] = list(self.default.glob_info(pattern, path))
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
- infos = backend.glob_info(stripped, "/")
215
- results.extend(_remap_file_info_path(fi, route_prefix) for fi in infos)
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 als_info(self, path: str) -> list[FileInfo]:
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
- infos = await backend.als_info(backend_path)
255
- return [_remap_file_info_path(fi, route_prefix) for fi in infos]
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
- results: list[FileInfo] = list(await self.default.als_info(path))
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.als_info(path)
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 agrep_raw(
305
+ async def agrep(
285
306
  self,
286
307
  pattern: str,
287
308
  path: str | None = None,
288
309
  glob: str | None = None,
289
- ) -> list[GrepMatch] | str:
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
- raw = await backend.agrep_raw(pattern, search_path, glob)
295
- if isinstance(raw, str):
296
- return raw
297
- return [_remap_grep_path(m, route_prefix) for m in raw]
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
- all_matches: list[GrepMatch] = []
301
- raw_default = await self.default.agrep_raw(pattern, path, glob)
302
- if isinstance(raw_default, str):
303
- return raw_default
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
- raw = await backend.agrep_raw(pattern, "/", glob)
308
- if isinstance(raw, str):
309
- return raw
310
- all_matches.extend(_remap_grep_path(m, route_prefix) for m in raw)
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.agrep_raw(pattern, path, glob)
334
+ return await self.default.agrep(pattern, path, glob)
315
335
 
316
- async def aglob_info(self, pattern: str, path: str = "/") -> list[FileInfo]:
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
- infos = await backend.aglob_info(stripped, search_path)
322
- return [_remap_file_info_path(fi, route_prefix) for fi in infos]
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
- results: list[FileInfo] = list(await self.default.aglob_info(pattern, path))
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
- infos = await backend.aglob_info(stripped, "/")
329
- results.extend(_remap_file_info_path(fi, route_prefix) for fi in infos)
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 ls_info(self, path: str) -> list[FileInfo]:
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
- List of FileInfo dicts with path, is_dir, size, modified_at.
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 grep_raw(
334
+ def grep(
332
335
  self,
333
336
  pattern: str,
334
337
  path: str | None = None,
335
338
  glob: str | None = None,
336
- ) -> list[GrepMatch] | str:
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
- List of matches or error string.
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 glob_info(self, pattern: str, path: str = "/") -> list[FileInfo]:
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
- List of matching FileInfo.
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.