deepagents 0.2.7__py3-none-any.whl → 0.3.0__py3-none-any.whl

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.
@@ -1,10 +1,14 @@
1
1
  """CompositeBackend: Route operations to different backends based on path prefix."""
2
2
 
3
+ from collections import defaultdict
4
+
3
5
  from deepagents.backends.protocol import (
4
6
  BackendProtocol,
5
7
  EditResult,
6
8
  ExecuteResponse,
9
+ FileDownloadResponse,
7
10
  FileInfo,
11
+ FileUploadResponse,
8
12
  GrepMatch,
9
13
  SandboxBackendProtocol,
10
14
  WriteResult,
@@ -93,6 +97,43 @@ class CompositeBackend:
93
97
  # Path doesn't match a route: query only default backend
94
98
  return self.default.ls_info(path)
95
99
 
100
+ async def als_info(self, path: str) -> list[FileInfo]:
101
+ """Async version of ls_info."""
102
+ # Check if path matches a specific route
103
+ for route_prefix, backend in self.sorted_routes:
104
+ if path.startswith(route_prefix.rstrip("/")):
105
+ # Query only the matching routed backend
106
+ suffix = path[len(route_prefix) :]
107
+ search_path = f"/{suffix}" if suffix else "/"
108
+ infos = await backend.als_info(search_path)
109
+ prefixed: list[FileInfo] = []
110
+ for fi in infos:
111
+ fi = dict(fi)
112
+ fi["path"] = f"{route_prefix[:-1]}{fi['path']}"
113
+ prefixed.append(fi)
114
+ return prefixed
115
+
116
+ # At root, aggregate default and all routed backends
117
+ if path == "/":
118
+ results: list[FileInfo] = []
119
+ results.extend(await self.default.als_info(path))
120
+ for route_prefix, backend in self.sorted_routes:
121
+ # Add the route itself as a directory (e.g., /memories/)
122
+ results.append(
123
+ {
124
+ "path": route_prefix,
125
+ "is_dir": True,
126
+ "size": 0,
127
+ "modified_at": "",
128
+ }
129
+ )
130
+
131
+ results.sort(key=lambda x: x.get("path", ""))
132
+ return results
133
+
134
+ # Path doesn't match a route: query only default backend
135
+ return await self.default.als_info(path)
136
+
96
137
  def read(
97
138
  self,
98
139
  file_path: str,
@@ -102,14 +143,26 @@ class CompositeBackend:
102
143
  """Read file content, routing to appropriate backend.
103
144
 
104
145
  Args:
105
- file_path: Absolute file path
106
- offset: Line offset to start reading from (0-indexed)
107
- limit: Maximum number of lines to readReturns:
146
+ file_path: Absolute file path.
147
+ offset: Line offset to start reading from (0-indexed).
148
+ limit: Maximum number of lines to read.
149
+
150
+ Returns:
108
151
  Formatted file content with line numbers, or error message.
109
152
  """
110
153
  backend, stripped_key = self._get_backend_and_key(file_path)
111
154
  return backend.read(stripped_key, offset=offset, limit=limit)
112
155
 
156
+ async def aread(
157
+ self,
158
+ file_path: str,
159
+ offset: int = 0,
160
+ limit: int = 2000,
161
+ ) -> str:
162
+ """Async version of read."""
163
+ backend, stripped_key = self._get_backend_and_key(file_path)
164
+ return await backend.aread(stripped_key, offset=offset, limit=limit)
165
+
113
166
  def grep_raw(
114
167
  self,
115
168
  pattern: str,
@@ -142,6 +195,39 @@ class CompositeBackend:
142
195
 
143
196
  return all_matches
144
197
 
198
+ async def agrep_raw(
199
+ self,
200
+ pattern: str,
201
+ path: str | None = None,
202
+ glob: str | None = None,
203
+ ) -> list[GrepMatch] | str:
204
+ """Async version of grep_raw."""
205
+ # If path targets a specific route, search only that backend
206
+ for route_prefix, backend in self.sorted_routes:
207
+ if path is not None and path.startswith(route_prefix.rstrip("/")):
208
+ search_path = path[len(route_prefix) - 1 :]
209
+ raw = await backend.agrep_raw(pattern, search_path if search_path else "/", glob)
210
+ if isinstance(raw, str):
211
+ return raw
212
+ return [{**m, "path": f"{route_prefix[:-1]}{m['path']}"} for m in raw]
213
+
214
+ # Otherwise, search default and all routed backends and merge
215
+ all_matches: list[GrepMatch] = []
216
+ raw_default = await self.default.agrep_raw(pattern, path, glob) # type: ignore[attr-defined]
217
+ if isinstance(raw_default, str):
218
+ # This happens if error occurs
219
+ return raw_default
220
+ all_matches.extend(raw_default)
221
+
222
+ for route_prefix, backend in self.routes.items():
223
+ raw = await backend.agrep_raw(pattern, "/", glob)
224
+ if isinstance(raw, str):
225
+ # This happens if error occurs
226
+ return raw
227
+ all_matches.extend({**m, "path": f"{route_prefix[:-1]}{m['path']}"} for m in raw)
228
+
229
+ return all_matches
230
+
145
231
  def glob_info(self, pattern: str, path: str = "/") -> list[FileInfo]:
146
232
  results: list[FileInfo] = []
147
233
 
@@ -163,6 +249,28 @@ class CompositeBackend:
163
249
  results.sort(key=lambda x: x.get("path", ""))
164
250
  return results
165
251
 
252
+ async def aglob_info(self, pattern: str, path: str = "/") -> list[FileInfo]:
253
+ """Async version of glob_info."""
254
+ results: list[FileInfo] = []
255
+
256
+ # Route based on path, not pattern
257
+ for route_prefix, backend in self.sorted_routes:
258
+ if path.startswith(route_prefix.rstrip("/")):
259
+ search_path = path[len(route_prefix) - 1 :]
260
+ infos = await backend.aglob_info(pattern, search_path if search_path else "/")
261
+ return [{**fi, "path": f"{route_prefix[:-1]}{fi['path']}"} for fi in infos]
262
+
263
+ # Path doesn't match any specific route - search default backend AND all routed backends
264
+ results.extend(await self.default.aglob_info(pattern, path))
265
+
266
+ for route_prefix, backend in self.routes.items():
267
+ infos = await backend.aglob_info(pattern, "/")
268
+ results.extend({**fi, "path": f"{route_prefix[:-1]}{fi['path']}"} for fi in infos)
269
+
270
+ # Deterministic ordering
271
+ results.sort(key=lambda x: x.get("path", ""))
272
+ return results
273
+
166
274
  def write(
167
275
  self,
168
276
  file_path: str,
@@ -171,8 +279,10 @@ class CompositeBackend:
171
279
  """Create a new file, routing to appropriate backend.
172
280
 
173
281
  Args:
174
- file_path: Absolute file path
175
- content: File content as a stringReturns:
282
+ file_path: Absolute file path.
283
+ content: File content as a string.
284
+
285
+ Returns:
176
286
  Success message or Command object, or error if file already exists.
177
287
  """
178
288
  backend, stripped_key = self._get_backend_and_key(file_path)
@@ -190,6 +300,27 @@ class CompositeBackend:
190
300
  pass
191
301
  return res
192
302
 
303
+ async def awrite(
304
+ self,
305
+ file_path: str,
306
+ content: str,
307
+ ) -> WriteResult:
308
+ """Async version of write."""
309
+ backend, stripped_key = self._get_backend_and_key(file_path)
310
+ res = await backend.awrite(stripped_key, content)
311
+ # If this is a state-backed update and default has state, merge so listings reflect changes
312
+ if res.files_update:
313
+ try:
314
+ runtime = getattr(self.default, "runtime", None)
315
+ if runtime is not None:
316
+ state = runtime.state
317
+ files = state.get("files", {})
318
+ files.update(res.files_update)
319
+ state["files"] = files
320
+ except Exception:
321
+ pass
322
+ return res
323
+
193
324
  def edit(
194
325
  self,
195
326
  file_path: str,
@@ -200,10 +331,12 @@ class CompositeBackend:
200
331
  """Edit a file, routing to appropriate backend.
201
332
 
202
333
  Args:
203
- file_path: Absolute file path
204
- old_string: String to find and replace
205
- new_string: Replacement string
206
- replace_all: If True, replace all occurrencesReturns:
334
+ file_path: Absolute file path.
335
+ old_string: String to find and replace.
336
+ new_string: Replacement string.
337
+ replace_all: If True, replace all occurrences.
338
+
339
+ Returns:
207
340
  Success message or Command object, or error message on failure.
208
341
  """
209
342
  backend, stripped_key = self._get_backend_and_key(file_path)
@@ -220,6 +353,28 @@ class CompositeBackend:
220
353
  pass
221
354
  return res
222
355
 
356
+ async def aedit(
357
+ self,
358
+ file_path: str,
359
+ old_string: str,
360
+ new_string: str,
361
+ replace_all: bool = False,
362
+ ) -> EditResult:
363
+ """Async version of edit."""
364
+ backend, stripped_key = self._get_backend_and_key(file_path)
365
+ res = await backend.aedit(stripped_key, old_string, new_string, replace_all=replace_all)
366
+ if res.files_update:
367
+ try:
368
+ runtime = getattr(self.default, "runtime", None)
369
+ if runtime is not None:
370
+ state = runtime.state
371
+ files = state.get("files", {})
372
+ files.update(res.files_update)
373
+ state["files"] = files
374
+ except Exception:
375
+ pass
376
+ return res
377
+
223
378
  def execute(
224
379
  self,
225
380
  command: str,
@@ -247,3 +402,160 @@ class CompositeBackend:
247
402
  "Default backend doesn't support command execution (SandboxBackendProtocol). "
248
403
  "To enable execution, provide a default backend that implements SandboxBackendProtocol."
249
404
  )
405
+
406
+ async def aexecute(
407
+ self,
408
+ command: str,
409
+ ) -> ExecuteResponse:
410
+ """Async version of execute."""
411
+ if isinstance(self.default, SandboxBackendProtocol):
412
+ return await self.default.aexecute(command)
413
+
414
+ # This shouldn't be reached if the runtime check in the execute tool works correctly,
415
+ # but we include it as a safety fallback.
416
+ raise NotImplementedError(
417
+ "Default backend doesn't support command execution (SandboxBackendProtocol). "
418
+ "To enable execution, provide a default backend that implements SandboxBackendProtocol."
419
+ )
420
+
421
+ def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
422
+ """Upload multiple files, batching by backend for efficiency.
423
+
424
+ Groups files by their target backend, calls each backend's upload_files
425
+ once with all files for that backend, then merges results in original order.
426
+
427
+ Args:
428
+ files: List of (path, content) tuples to upload.
429
+
430
+ Returns:
431
+ List of FileUploadResponse objects, one per input file.
432
+ Response order matches input order.
433
+ """
434
+ # Pre-allocate result list
435
+ results: list[FileUploadResponse | None] = [None] * len(files)
436
+
437
+ # Group files by backend, tracking original indices
438
+ from collections import defaultdict
439
+
440
+ backend_batches: dict[BackendProtocol, list[tuple[int, str, bytes]]] = defaultdict(list)
441
+
442
+ for idx, (path, content) in enumerate(files):
443
+ backend, stripped_path = self._get_backend_and_key(path)
444
+ backend_batches[backend].append((idx, stripped_path, content))
445
+
446
+ # Process each backend's batch
447
+ for backend, batch in backend_batches.items():
448
+ # Extract data for backend call
449
+ indices, stripped_paths, contents = zip(*batch, strict=False)
450
+ batch_files = list(zip(stripped_paths, contents, strict=False))
451
+
452
+ # Call backend once with all its files
453
+ batch_responses = backend.upload_files(batch_files)
454
+
455
+ # Place responses at original indices with original paths
456
+ for i, orig_idx in enumerate(indices):
457
+ results[orig_idx] = FileUploadResponse(
458
+ path=files[orig_idx][0], # Original path
459
+ error=batch_responses[i].error if i < len(batch_responses) else None,
460
+ )
461
+
462
+ return results # type: ignore[return-value]
463
+
464
+ async def aupload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
465
+ """Async version of upload_files."""
466
+ # Pre-allocate result list
467
+ results: list[FileUploadResponse | None] = [None] * len(files)
468
+
469
+ # Group files by backend, tracking original indices
470
+ backend_batches: dict[BackendProtocol, list[tuple[int, str, bytes]]] = defaultdict(list)
471
+
472
+ for idx, (path, content) in enumerate(files):
473
+ backend, stripped_path = self._get_backend_and_key(path)
474
+ backend_batches[backend].append((idx, stripped_path, content))
475
+
476
+ # Process each backend's batch
477
+ for backend, batch in backend_batches.items():
478
+ # Extract data for backend call
479
+ indices, stripped_paths, contents = zip(*batch, strict=False)
480
+ batch_files = list(zip(stripped_paths, contents, strict=False))
481
+
482
+ # Call backend once with all its files
483
+ batch_responses = await backend.aupload_files(batch_files)
484
+
485
+ # Place responses at original indices with original paths
486
+ for i, orig_idx in enumerate(indices):
487
+ results[orig_idx] = FileUploadResponse(
488
+ path=files[orig_idx][0], # Original path
489
+ error=batch_responses[i].error if i < len(batch_responses) else None,
490
+ )
491
+
492
+ return results # type: ignore[return-value]
493
+
494
+ def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
495
+ """Download multiple files, batching by backend for efficiency.
496
+
497
+ Groups paths by their target backend, calls each backend's download_files
498
+ once with all paths for that backend, then merges results in original order.
499
+
500
+ Args:
501
+ paths: List of file paths to download.
502
+
503
+ Returns:
504
+ List of FileDownloadResponse objects, one per input path.
505
+ Response order matches input order.
506
+ """
507
+ # Pre-allocate result list
508
+ results: list[FileDownloadResponse | None] = [None] * len(paths)
509
+
510
+ backend_batches: dict[BackendProtocol, list[tuple[int, str]]] = defaultdict(list)
511
+
512
+ for idx, path in enumerate(paths):
513
+ backend, stripped_path = self._get_backend_and_key(path)
514
+ backend_batches[backend].append((idx, stripped_path))
515
+
516
+ # Process each backend's batch
517
+ for backend, batch in backend_batches.items():
518
+ # Extract data for backend call
519
+ indices, stripped_paths = zip(*batch, strict=False)
520
+
521
+ # Call backend once with all its paths
522
+ batch_responses = backend.download_files(list(stripped_paths))
523
+
524
+ # Place responses at original indices with original paths
525
+ for i, orig_idx in enumerate(indices):
526
+ results[orig_idx] = FileDownloadResponse(
527
+ path=paths[orig_idx], # Original path
528
+ content=batch_responses[i].content if i < len(batch_responses) else None,
529
+ error=batch_responses[i].error if i < len(batch_responses) else None,
530
+ )
531
+
532
+ return results # type: ignore[return-value]
533
+
534
+ async def adownload_files(self, paths: list[str]) -> list[FileDownloadResponse]:
535
+ """Async version of download_files."""
536
+ # Pre-allocate result list
537
+ results: list[FileDownloadResponse | None] = [None] * len(paths)
538
+
539
+ backend_batches: dict[BackendProtocol, list[tuple[int, str]]] = defaultdict(list)
540
+
541
+ for idx, path in enumerate(paths):
542
+ backend, stripped_path = self._get_backend_and_key(path)
543
+ backend_batches[backend].append((idx, stripped_path))
544
+
545
+ # Process each backend's batch
546
+ for backend, batch in backend_batches.items():
547
+ # Extract data for backend call
548
+ indices, stripped_paths = zip(*batch, strict=False)
549
+
550
+ # Call backend once with all its paths
551
+ batch_responses = await backend.adownload_files(list(stripped_paths))
552
+
553
+ # Place responses at original indices with original paths
554
+ for i, orig_idx in enumerate(indices):
555
+ results[orig_idx] = FileDownloadResponse(
556
+ path=paths[orig_idx], # Original path
557
+ content=batch_responses[i].content if i < len(batch_responses) else None,
558
+ error=batch_responses[i].error if i < len(batch_responses) else None,
559
+ )
560
+
561
+ return results # type: ignore[return-value]
@@ -16,7 +16,15 @@ from pathlib import Path
16
16
 
17
17
  import wcmatch.glob as wcglob
18
18
 
19
- from deepagents.backends.protocol import BackendProtocol, EditResult, FileInfo, GrepMatch, WriteResult
19
+ from deepagents.backends.protocol import (
20
+ BackendProtocol,
21
+ EditResult,
22
+ FileDownloadResponse,
23
+ FileInfo,
24
+ FileUploadResponse,
25
+ GrepMatch,
26
+ WriteResult,
27
+ )
20
28
  from deepagents.backends.utils import (
21
29
  check_empty_content,
22
30
  format_content_with_line_numbers,
@@ -185,8 +193,6 @@ class FilesystemBackend(BackendProtocol):
185
193
  results.sort(key=lambda x: x.get("path", ""))
186
194
  return results
187
195
 
188
- # Removed legacy ls() convenience to keep lean surface
189
-
190
196
  def read(
191
197
  self,
192
198
  file_path: str,
@@ -196,9 +202,11 @@ class FilesystemBackend(BackendProtocol):
196
202
  """Read file content with line numbers.
197
203
 
198
204
  Args:
199
- file_path: Absolute or relative file path
200
- offset: Line offset to start reading from (0-indexed)
201
- limit: Maximum number of lines to readReturns:
205
+ file_path: Absolute or relative file path.
206
+ offset: Line offset to start reading from (0-indexed).
207
+ limit: Maximum number of lines to read.
208
+
209
+ Returns:
202
210
  Formatted file content with line numbers, or error message.
203
211
  """
204
212
  resolved_path = self._resolve_path(file_path)
@@ -208,14 +216,9 @@ class FilesystemBackend(BackendProtocol):
208
216
 
209
217
  try:
210
218
  # Open with O_NOFOLLOW where available to avoid symlink traversal
211
- try:
212
- fd = os.open(resolved_path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
213
- with os.fdopen(fd, "r", encoding="utf-8") as f:
214
- content = f.read()
215
- except OSError:
216
- # Fallback to normal open if O_NOFOLLOW unsupported or fails
217
- with open(resolved_path, encoding="utf-8") as f:
218
- content = f.read()
219
+ fd = os.open(resolved_path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
220
+ with os.fdopen(fd, "r", encoding="utf-8") as f:
221
+ content = f.read()
219
222
 
220
223
  empty_msg = check_empty_content(content)
221
224
  if empty_msg:
@@ -279,13 +282,9 @@ class FilesystemBackend(BackendProtocol):
279
282
 
280
283
  try:
281
284
  # Read securely
282
- try:
283
- fd = os.open(resolved_path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
284
- with os.fdopen(fd, "r", encoding="utf-8") as f:
285
- content = f.read()
286
- except OSError:
287
- with open(resolved_path, encoding="utf-8") as f:
288
- content = f.read()
285
+ fd = os.open(resolved_path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
286
+ with os.fdopen(fd, "r", encoding="utf-8") as f:
287
+ content = f.read()
289
288
 
290
289
  result = perform_string_replacement(content, old_string, new_string, replace_all)
291
290
 
@@ -306,8 +305,6 @@ class FilesystemBackend(BackendProtocol):
306
305
  except (OSError, UnicodeDecodeError, UnicodeEncodeError) as e:
307
306
  return EditResult(error=f"Error editing file '{file_path}': {e}")
308
307
 
309
- # Removed legacy grep() convenience to keep lean surface
310
-
311
308
  def grep_raw(
312
309
  self,
313
310
  pattern: str,
@@ -481,3 +478,73 @@ class FilesystemBackend(BackendProtocol):
481
478
 
482
479
  results.sort(key=lambda x: x.get("path", ""))
483
480
  return results
481
+
482
+ def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
483
+ """Upload multiple files to the filesystem.
484
+
485
+ Args:
486
+ files: List of (path, content) tuples where content is bytes.
487
+
488
+ Returns:
489
+ List of FileUploadResponse objects, one per input file.
490
+ Response order matches input order.
491
+ """
492
+ responses: list[FileUploadResponse] = []
493
+ for path, content in files:
494
+ try:
495
+ resolved_path = self._resolve_path(path)
496
+
497
+ # Create parent directories if needed
498
+ resolved_path.parent.mkdir(parents=True, exist_ok=True)
499
+
500
+ flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
501
+ if hasattr(os, "O_NOFOLLOW"):
502
+ flags |= os.O_NOFOLLOW
503
+ fd = os.open(resolved_path, flags, 0o644)
504
+ with os.fdopen(fd, "wb") as f:
505
+ f.write(content)
506
+
507
+ responses.append(FileUploadResponse(path=path, error=None))
508
+ except FileNotFoundError:
509
+ responses.append(FileUploadResponse(path=path, error="file_not_found"))
510
+ except PermissionError:
511
+ responses.append(FileUploadResponse(path=path, error="permission_denied"))
512
+ except (ValueError, OSError) as e:
513
+ # ValueError from _resolve_path for path traversal, OSError for other file errors
514
+ if isinstance(e, ValueError) or "invalid" in str(e).lower():
515
+ responses.append(FileUploadResponse(path=path, error="invalid_path"))
516
+ else:
517
+ # Generic error fallback
518
+ responses.append(FileUploadResponse(path=path, error="invalid_path"))
519
+
520
+ return responses
521
+
522
+ def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
523
+ """Download multiple files from the filesystem.
524
+
525
+ Args:
526
+ paths: List of file paths to download.
527
+
528
+ Returns:
529
+ List of FileDownloadResponse objects, one per input path.
530
+ """
531
+ responses: list[FileDownloadResponse] = []
532
+ for path in paths:
533
+ try:
534
+ resolved_path = self._resolve_path(path)
535
+ # Use flags to optionally prevent symlink following if
536
+ # supported by the OS
537
+ fd = os.open(resolved_path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
538
+ with os.fdopen(fd, "rb") as f:
539
+ content = f.read()
540
+ responses.append(FileDownloadResponse(path=path, content=content, error=None))
541
+ except FileNotFoundError:
542
+ responses.append(FileDownloadResponse(path=path, content=None, error="file_not_found"))
543
+ except PermissionError:
544
+ responses.append(FileDownloadResponse(path=path, content=None, error="permission_denied"))
545
+ except IsADirectoryError:
546
+ responses.append(FileDownloadResponse(path=path, content=None, error="is_directory"))
547
+ except ValueError:
548
+ responses.append(FileDownloadResponse(path=path, content=None, error="invalid_path"))
549
+ # Let other errors propagate
550
+ return responses