deepagents 0.2.4__py3-none-any.whl → 0.2.6__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.
@@ -5,22 +5,45 @@ must follow. Backends can store files in different locations (state, filesystem,
5
5
  database, etc.) and provide a uniform interface for file operations.
6
6
  """
7
7
 
8
- from typing import TYPE_CHECKING, Optional, Protocol, runtime_checkable, Callable, TypeAlias, Any
8
+ from collections.abc import Callable
9
+ from dataclasses import dataclass
10
+ from typing import Any, Protocol, TypeAlias, TypedDict, runtime_checkable
11
+
9
12
  from langchain.tools import ToolRuntime
10
- from deepagents.backends.utils import FileInfo, GrepMatch
11
13
 
12
- from dataclasses import dataclass
14
+
15
+ class FileInfo(TypedDict, total=False):
16
+ """Structured file listing info.
17
+
18
+ Minimal contract used across backends. Only "path" is required.
19
+ Other fields are best-effort and may be absent depending on backend.
20
+ """
21
+
22
+ path: str
23
+ is_dir: bool
24
+ size: int # bytes (approx)
25
+ modified_at: str # ISO timestamp if known
26
+
27
+
28
+ class GrepMatch(TypedDict):
29
+ """Structured grep match entry."""
30
+
31
+ path: str
32
+ line: int
33
+ text: str
13
34
 
14
35
 
15
36
  @dataclass
16
37
  class WriteResult:
17
38
  """Result from backend write operations.
39
+
18
40
  Attributes:
19
41
  error: Error message on failure, None on success.
20
42
  path: Absolute path of written file, None on failure.
21
43
  files_update: State update dict for checkpoint backends, None for external storage.
22
44
  Checkpoint backends populate this with {file_path: file_data} for LangGraph state.
23
45
  External backends set None (already persisted to disk/S3/database/etc).
46
+
24
47
  Examples:
25
48
  >>> # Checkpoint storage
26
49
  >>> WriteResult(path="/f.txt", files_update={"/f.txt": {...}})
@@ -38,6 +61,7 @@ class WriteResult:
38
61
  @dataclass
39
62
  class EditResult:
40
63
  """Result from backend edit operations.
64
+
41
65
  Attributes:
42
66
  error: Error message on failure, None on success.
43
67
  path: Absolute path of edited file, None on failure.
@@ -45,6 +69,7 @@ class EditResult:
45
69
  Checkpoint backends populate this with {file_path: file_data} for LangGraph state.
46
70
  External backends set None (already persisted to disk/S3/database/etc).
47
71
  occurrences: Number of replacements made, None on failure.
72
+
48
73
  Examples:
49
74
  >>> # Checkpoint storage
50
75
  >>> EditResult(path="/f.txt", files_update={"/f.txt": {...}}, occurrences=1)
@@ -59,6 +84,7 @@ class EditResult:
59
84
  files_update: dict[str, Any] | None = None
60
85
  occurrences: int | None = None
61
86
 
87
+
62
88
  @runtime_checkable
63
89
  class BackendProtocol(Protocol):
64
90
  """Protocol for pluggable memory backends (single, unified).
@@ -90,8 +116,8 @@ class BackendProtocol(Protocol):
90
116
  def grep_raw(
91
117
  self,
92
118
  pattern: str,
93
- path: Optional[str] = None,
94
- glob: Optional[str] = None,
119
+ path: str | None = None,
120
+ glob: str | None = None,
95
121
  ) -> list["GrepMatch"] | str:
96
122
  """Structured search results or error string for invalid input."""
97
123
  ...
@@ -101,22 +127,70 @@ class BackendProtocol(Protocol):
101
127
  ...
102
128
 
103
129
  def write(
104
- self,
105
- file_path: str,
106
- content: str,
130
+ self,
131
+ file_path: str,
132
+ content: str,
107
133
  ) -> WriteResult:
108
134
  """Create a new file. Returns WriteResult; error populated on failure."""
109
135
  ...
110
136
 
111
137
  def edit(
112
- self,
113
- file_path: str,
114
- old_string: str,
115
- new_string: str,
116
- replace_all: bool = False,
138
+ self,
139
+ file_path: str,
140
+ old_string: str,
141
+ new_string: str,
142
+ replace_all: bool = False,
117
143
  ) -> EditResult:
118
144
  """Edit a file by replacing string occurrences. Returns EditResult."""
119
145
  ...
120
146
 
121
147
 
148
+ @dataclass
149
+ class ExecuteResponse:
150
+ """Result of code execution.
151
+
152
+ Simplified schema optimized for LLM consumption.
153
+ """
154
+
155
+ output: str
156
+ """Combined stdout and stderr output of the executed command."""
157
+
158
+ exit_code: int | None = None
159
+ """The process exit code. 0 indicates success, non-zero indicates failure."""
160
+
161
+ truncated: bool = False
162
+ """Whether the output was truncated due to backend limitations."""
163
+
164
+
165
+ @runtime_checkable
166
+ class SandboxBackendProtocol(BackendProtocol, Protocol):
167
+ """Protocol for sandboxed backends with isolated runtime.
168
+
169
+ Sandboxed backends run in isolated environments (e.g., separate processes,
170
+ containers) and communicate via defined interfaces.
171
+ """
172
+
173
+ def execute(
174
+ self,
175
+ command: str,
176
+ ) -> ExecuteResponse:
177
+ """Execute a command in the process.
178
+
179
+ Simplified interface optimized for LLM consumption.
180
+
181
+ Args:
182
+ command: Full shell command string to execute.
183
+
184
+ Returns:
185
+ ExecuteResponse with combined output, exit code, optional signal, and truncation flag.
186
+ """
187
+ ...
188
+
189
+ @property
190
+ def id(self) -> str:
191
+ """Unique identifier for the sandbox backend."""
192
+ ...
193
+
194
+
122
195
  BackendFactory: TypeAlias = Callable[[ToolRuntime], BackendProtocol]
196
+ BACKEND_TYPES = BackendProtocol | BackendFactory
@@ -0,0 +1,341 @@
1
+ """Base sandbox implementation with execute() as the only abstract method.
2
+
3
+ This module provides a base class that implements all SandboxBackendProtocol
4
+ methods using shell commands executed via execute(). Concrete implementations
5
+ only need to implement the execute() method.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import json
12
+ from abc import ABC, abstractmethod
13
+
14
+ from deepagents.backends.protocol import (
15
+ EditResult,
16
+ ExecuteResponse,
17
+ FileInfo,
18
+ GrepMatch,
19
+ SandboxBackendProtocol,
20
+ WriteResult,
21
+ )
22
+
23
+ _GLOB_COMMAND_TEMPLATE = """python3 -c "
24
+ import glob
25
+ import os
26
+ import json
27
+ import base64
28
+
29
+ # Decode base64-encoded parameters
30
+ path = base64.b64decode('{path_b64}').decode('utf-8')
31
+ pattern = base64.b64decode('{pattern_b64}').decode('utf-8')
32
+
33
+ os.chdir(path)
34
+ matches = sorted(glob.glob(pattern, recursive=True))
35
+ for m in matches:
36
+ stat = os.stat(m)
37
+ result = {{
38
+ 'path': m,
39
+ 'size': stat.st_size,
40
+ 'mtime': stat.st_mtime,
41
+ 'is_dir': os.path.isdir(m)
42
+ }}
43
+ print(json.dumps(result))
44
+ " 2>/dev/null"""
45
+
46
+ _WRITE_COMMAND_TEMPLATE = """python3 -c "
47
+ import os
48
+ import sys
49
+ import base64
50
+
51
+ file_path = '{file_path}'
52
+
53
+ # Check if file already exists (atomic with write)
54
+ if os.path.exists(file_path):
55
+ print(f'Error: File \\'{file_path}\\' already exists', file=sys.stderr)
56
+ sys.exit(1)
57
+
58
+ # Create parent directory if needed
59
+ parent_dir = os.path.dirname(file_path) or '.'
60
+ os.makedirs(parent_dir, exist_ok=True)
61
+
62
+ # Decode and write content
63
+ content = base64.b64decode('{content_b64}').decode('utf-8')
64
+ with open(file_path, 'w') as f:
65
+ f.write(content)
66
+ " 2>&1"""
67
+
68
+ _EDIT_COMMAND_TEMPLATE = """python3 -c "
69
+ import sys
70
+ import base64
71
+
72
+ # Read file content
73
+ with open('{file_path}', 'r') as f:
74
+ text = f.read()
75
+
76
+ # Decode base64-encoded strings
77
+ old = base64.b64decode('{old_b64}').decode('utf-8')
78
+ new = base64.b64decode('{new_b64}').decode('utf-8')
79
+
80
+ # Count occurrences
81
+ count = text.count(old)
82
+
83
+ # Exit with error codes if issues found
84
+ if count == 0:
85
+ sys.exit(1) # String not found
86
+ elif count > 1 and not {replace_all}:
87
+ sys.exit(2) # Multiple occurrences without replace_all
88
+
89
+ # Perform replacement
90
+ if {replace_all}:
91
+ result = text.replace(old, new)
92
+ else:
93
+ result = text.replace(old, new, 1)
94
+
95
+ # Write back to file
96
+ with open('{file_path}', 'w') as f:
97
+ f.write(result)
98
+
99
+ print(count)
100
+ " 2>&1"""
101
+
102
+ _READ_COMMAND_TEMPLATE = """python3 -c "
103
+ import os
104
+ import sys
105
+
106
+ file_path = '{file_path}'
107
+ offset = {offset}
108
+ limit = {limit}
109
+
110
+ # Check if file exists
111
+ if not os.path.isfile(file_path):
112
+ print('Error: File not found')
113
+ sys.exit(1)
114
+
115
+ # Check if file is empty
116
+ if os.path.getsize(file_path) == 0:
117
+ print('System reminder: File exists but has empty contents')
118
+ sys.exit(0)
119
+
120
+ # Read file with offset and limit
121
+ with open(file_path, 'r') as f:
122
+ lines = f.readlines()
123
+
124
+ # Apply offset and limit
125
+ start_idx = offset
126
+ end_idx = offset + limit
127
+ selected_lines = lines[start_idx:end_idx]
128
+
129
+ # Format with line numbers (1-indexed, starting from offset + 1)
130
+ for i, line in enumerate(selected_lines):
131
+ line_num = offset + i + 1
132
+ # Remove trailing newline for formatting, then add it back
133
+ line_content = line.rstrip('\\n')
134
+ print(f'{{line_num:6d}}\\t{{line_content}}')
135
+ " 2>&1"""
136
+
137
+
138
+ class BaseSandbox(SandboxBackendProtocol, ABC):
139
+ """Base sandbox implementation with execute() as abstract method.
140
+
141
+ This class provides default implementations for all protocol methods
142
+ using shell commands. Subclasses only need to implement execute().
143
+ """
144
+
145
+ @abstractmethod
146
+ def execute(
147
+ self,
148
+ command: str,
149
+ ) -> ExecuteResponse:
150
+ """Execute a command in the sandbox and return ExecuteResponse.
151
+
152
+ Args:
153
+ command: Full shell command string to execute.
154
+
155
+ Returns:
156
+ ExecuteResponse with combined output, exit code, optional signal, and truncation flag.
157
+ """
158
+ ...
159
+
160
+ def ls_info(self, path: str) -> list[FileInfo]:
161
+ """Structured listing with file metadata using os.scandir."""
162
+ cmd = f"""python3 -c "
163
+ import os
164
+ import json
165
+
166
+ path = '{path}'
167
+
168
+ try:
169
+ with os.scandir(path) as it:
170
+ for entry in it:
171
+ result = {{
172
+ 'path': entry.name,
173
+ 'is_dir': entry.is_dir(follow_symlinks=False)
174
+ }}
175
+ print(json.dumps(result))
176
+ except FileNotFoundError:
177
+ pass
178
+ except PermissionError:
179
+ pass
180
+ " 2>/dev/null"""
181
+
182
+ result = self.execute(cmd)
183
+
184
+ file_infos: list[FileInfo] = []
185
+ for line in result.output.strip().split("\n"):
186
+ if not line:
187
+ continue
188
+ try:
189
+ data = json.loads(line)
190
+ file_infos.append({"path": data["path"], "is_dir": data["is_dir"]})
191
+ except json.JSONDecodeError:
192
+ continue
193
+
194
+ return file_infos
195
+
196
+ def read(
197
+ self,
198
+ file_path: str,
199
+ offset: int = 0,
200
+ limit: int = 2000,
201
+ ) -> str:
202
+ """Read file content with line numbers using a single shell command."""
203
+ # Use template for reading file with offset and limit
204
+ cmd = _READ_COMMAND_TEMPLATE.format(file_path=file_path, offset=offset, limit=limit)
205
+ result = self.execute(cmd)
206
+
207
+ output = result.output.rstrip()
208
+ exit_code = result.exit_code
209
+
210
+ if exit_code != 0 or "Error: File not found" in output:
211
+ return f"Error: File '{file_path}' not found"
212
+
213
+ return output
214
+
215
+ def write(
216
+ self,
217
+ file_path: str,
218
+ content: str,
219
+ ) -> WriteResult:
220
+ """Create a new file. Returns WriteResult; error populated on failure."""
221
+ # Encode content as base64 to avoid any escaping issues
222
+ content_b64 = base64.b64encode(content.encode("utf-8")).decode("ascii")
223
+
224
+ # Single atomic check + write command
225
+ cmd = _WRITE_COMMAND_TEMPLATE.format(file_path=file_path, content_b64=content_b64)
226
+ result = self.execute(cmd)
227
+
228
+ # Check for errors (exit code or error message in output)
229
+ if result.exit_code != 0 or "Error:" in result.output:
230
+ error_msg = result.output.strip() or f"Failed to write file '{file_path}'"
231
+ return WriteResult(error=error_msg)
232
+
233
+ # External storage - no files_update needed
234
+ return WriteResult(path=file_path, files_update=None)
235
+
236
+ def edit(
237
+ self,
238
+ file_path: str,
239
+ old_string: str,
240
+ new_string: str,
241
+ replace_all: bool = False,
242
+ ) -> EditResult:
243
+ """Edit a file by replacing string occurrences. Returns EditResult."""
244
+ # Encode strings as base64 to avoid any escaping issues
245
+ old_b64 = base64.b64encode(old_string.encode("utf-8")).decode("ascii")
246
+ new_b64 = base64.b64encode(new_string.encode("utf-8")).decode("ascii")
247
+
248
+ # Use template for string replacement
249
+ cmd = _EDIT_COMMAND_TEMPLATE.format(file_path=file_path, old_b64=old_b64, new_b64=new_b64, replace_all=replace_all)
250
+ result = self.execute(cmd)
251
+
252
+ exit_code = result.exit_code
253
+ output = result.output.strip()
254
+
255
+ if exit_code == 1:
256
+ return EditResult(error=f"Error: String not found in file: '{old_string}'")
257
+ if exit_code == 2:
258
+ return EditResult(error=f"Error: String '{old_string}' appears multiple times. Use replace_all=True to replace all occurrences.")
259
+ if exit_code != 0:
260
+ return EditResult(error=f"Error: File '{file_path}' not found")
261
+
262
+ count = int(output)
263
+ # External storage - no files_update needed
264
+ return EditResult(path=file_path, files_update=None, occurrences=count)
265
+
266
+ def grep_raw(
267
+ self,
268
+ pattern: str,
269
+ path: str | None = None,
270
+ glob: str | None = None,
271
+ ) -> list[GrepMatch] | str:
272
+ """Structured search results or error string for invalid input."""
273
+ search_path = path or "."
274
+
275
+ # Build grep command to get structured output
276
+ grep_opts = "-rHn" # recursive, with filename, with line number
277
+
278
+ # Add glob pattern if specified
279
+ glob_pattern = ""
280
+ if glob:
281
+ glob_pattern = f"--include='{glob}'"
282
+
283
+ # Escape pattern for shell
284
+ pattern_escaped = pattern.replace("'", "'\\\\''")
285
+
286
+ cmd = f"grep {grep_opts} {glob_pattern} -e '{pattern_escaped}' '{search_path}' 2>/dev/null || true"
287
+ result = self.execute(cmd)
288
+
289
+ output = result.output.rstrip()
290
+ if not output:
291
+ return []
292
+
293
+ # Parse grep output into GrepMatch objects
294
+ matches: list[GrepMatch] = []
295
+ for line in output.split("\n"):
296
+ # Format is: path:line_number:text
297
+ parts = line.split(":", 2)
298
+ if len(parts) >= 3:
299
+ matches.append(
300
+ {
301
+ "path": parts[0],
302
+ "line": int(parts[1]),
303
+ "text": parts[2],
304
+ }
305
+ )
306
+
307
+ return matches
308
+
309
+ def glob_info(self, pattern: str, path: str = "/") -> list[FileInfo]:
310
+ """Structured glob matching returning FileInfo dicts."""
311
+ # Encode pattern and path as base64 to avoid escaping issues
312
+ pattern_b64 = base64.b64encode(pattern.encode("utf-8")).decode("ascii")
313
+ path_b64 = base64.b64encode(path.encode("utf-8")).decode("ascii")
314
+
315
+ cmd = _GLOB_COMMAND_TEMPLATE.format(path_b64=path_b64, pattern_b64=pattern_b64)
316
+ result = self.execute(cmd)
317
+
318
+ output = result.output.strip()
319
+ if not output:
320
+ return []
321
+
322
+ # Parse JSON output into FileInfo dicts
323
+ file_infos: list[FileInfo] = []
324
+ for line in output.split("\n"):
325
+ try:
326
+ data = json.loads(line)
327
+ file_infos.append(
328
+ {
329
+ "path": data["path"],
330
+ "is_dir": data["is_dir"],
331
+ }
332
+ )
333
+ except json.JSONDecodeError:
334
+ continue
335
+
336
+ return file_infos
337
+
338
+ @property
339
+ @abstractmethod
340
+ def id(self) -> str:
341
+ """Unique identifier for this backend instance."""