deepagents 0.3.8__py3-none-any.whl → 0.3.10__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.
@@ -3,14 +3,21 @@
3
3
  This module provides a base class that implements all SandboxBackendProtocol
4
4
  methods using shell commands executed via execute(). Concrete implementations
5
5
  only need to implement the execute() method.
6
+
7
+ It also defines the SandboxProvider abstract base class for third-party SDK
8
+ implementations to manage sandbox lifecycle (list, create, delete).
6
9
  """
7
10
 
8
11
  from __future__ import annotations
9
12
 
13
+ import asyncio
10
14
  import base64
11
15
  import json
12
16
  import shlex
13
17
  from abc import ABC, abstractmethod
18
+ from typing import Any, Generic, NotRequired, TypeVar
19
+
20
+ from typing_extensions import TypedDict
14
21
 
15
22
  from deepagents.backends.protocol import (
16
23
  EditResult,
@@ -23,6 +30,353 @@ from deepagents.backends.protocol import (
23
30
  WriteResult,
24
31
  )
25
32
 
33
+ # Type variable for provider-specific metadata
34
+ MetadataT = TypeVar("MetadataT", covariant=True)
35
+ """Type variable for sandbox metadata.
36
+
37
+ Providers can define their own TypedDict to specify the structure of sandbox metadata,
38
+ enabling type-safe access to metadata fields.
39
+
40
+ Example:
41
+ ```python
42
+ class ProviderMetadata(TypedDict, total=False):
43
+ status: Literal["running", "stopped"]
44
+ created_at: str
45
+ template: str
46
+
47
+ class MyProvider(SandboxProvider[ProviderMetadata]):
48
+ def list(
49
+ self, *, cursor=None, **kwargs: Any
50
+ ) -> SandboxListResponse[ProviderMetadata]:
51
+ # Extract kwargs as needed
52
+ status = kwargs.get("status")
53
+ ...
54
+ ```
55
+ """
56
+
57
+
58
+ class SandboxInfo(TypedDict, Generic[MetadataT]):
59
+ """Metadata for a single sandbox instance.
60
+
61
+ This lightweight structure is returned from list operations and provides
62
+ basic information about a sandbox without requiring a full connection.
63
+
64
+ Type Parameters:
65
+ MetadataT: Type of the metadata field. Providers should define a TypedDict
66
+ for type-safe metadata access.
67
+
68
+ Attributes:
69
+ sandbox_id: Unique identifier for the sandbox instance.
70
+ metadata: Optional provider-specific metadata (e.g., creation time, status,
71
+ resource limits, template information). Structure is provider-defined.
72
+
73
+ Example:
74
+ ```python
75
+ # Using default dict[str, Any]
76
+ info: SandboxInfo = {
77
+ "sandbox_id": "sb_abc123",
78
+ "metadata": {"status": "running", "created_at": "2024-01-15T10:30:00Z", "template": "python-3.11"},
79
+ }
80
+
81
+
82
+ # Using typed metadata
83
+ class MyMetadata(TypedDict, total=False):
84
+ status: Literal["running", "stopped"]
85
+ created_at: str
86
+
87
+
88
+ typed_info: SandboxInfo[MyMetadata] = {
89
+ "sandbox_id": "sb_abc123",
90
+ "metadata": {"status": "running", "created_at": "2024-01-15T10:30:00Z"},
91
+ }
92
+ ```
93
+ """
94
+
95
+ sandbox_id: str
96
+ metadata: NotRequired[MetadataT]
97
+
98
+
99
+ class SandboxListResponse(TypedDict, Generic[MetadataT]):
100
+ """Paginated response from a sandbox list operation.
101
+
102
+ This structure supports cursor-based pagination for efficiently browsing
103
+ large collections of sandboxes.
104
+
105
+ Type Parameters:
106
+ MetadataT: Type of the metadata field in SandboxInfo items.
107
+
108
+ Attributes:
109
+ items: List of sandbox metadata objects for the current page.
110
+ cursor: Opaque continuation token for retrieving the next page.
111
+ None indicates no more pages available. Clients should treat this
112
+ as an opaque string and pass it to subsequent list() calls.
113
+
114
+ Example:
115
+ ```python
116
+ response: SandboxListResponse[MyMetadata] = {
117
+ "items": [{"sandbox_id": "sb_001", "metadata": {"status": "running"}}, {"sandbox_id": "sb_002", "metadata": {"status": "stopped"}}],
118
+ "cursor": "eyJvZmZzZXQiOjEwMH0=",
119
+ }
120
+
121
+ # Fetch next page
122
+ next_response = provider.list(cursor=response["cursor"])
123
+ ```
124
+ """
125
+
126
+ items: list[SandboxInfo[MetadataT]]
127
+ cursor: str | None
128
+
129
+
130
+ class SandboxProvider(ABC, Generic[MetadataT]):
131
+ """Abstract base class for third-party sandbox provider implementations.
132
+
133
+ Defines the lifecycle management interface for sandbox providers. Implementations
134
+ should integrate with their respective SDKs to provide standardized sandbox
135
+ lifecycle operations (list, get_or_create, delete).
136
+
137
+ Implementations can add provider-specific parameters as keyword-only arguments
138
+ with defaults, maintaining compatibility while providing type-safe APIs.
139
+
140
+ Sync/Async Convention: Following LangChain convention, providers should offer both
141
+ sync and async methods in the same namespace if possible (doesn't hurt performance)
142
+ (e.g., both `list()` and `alist()` in one class). The default async implementations
143
+ delegate to sync methods via a thread pool. Providers can override async methods to
144
+ provide optimized async implementations if needed.
145
+
146
+ Alternatively, if necessary for performance optimization, providers may split into
147
+ separate implementations (e.g., `MySyncProvider` and `MyAsyncProvider`). In this
148
+ case, unimplemented methods should raise NotImplementedError with clear guidance
149
+ (e.g., "This provider only supports async operations. Use 'await provider.alist()'
150
+ or switch to MySyncProvider for synchronous code").
151
+
152
+ Example Implementation:
153
+ ```python
154
+ class CustomMetadata(TypedDict, total=False):
155
+ status: Literal["running", "stopped"]
156
+ template: str
157
+ created_at: str
158
+
159
+
160
+ class CustomSandboxProvider(SandboxProvider[CustomMetadata]):
161
+ def list(
162
+ self, *, cursor=None, status: Literal["running", "stopped"] | None = None, template_id: str | None = None, **kwargs: Any
163
+ ) -> SandboxListResponse[CustomMetadata]:
164
+ # Type-safe parameters with IDE autocomplete
165
+ # ... query provider API
166
+ return {"items": [...], "cursor": None}
167
+
168
+ def get_or_create(
169
+ self, *, sandbox_id=None, template_id: str = "default", timeout_minutes: int | None = None, **kwargs: Any
170
+ ) -> SandboxBackendProtocol:
171
+ # Type-safe parameters with IDE autocomplete
172
+ return CustomSandbox(sandbox_id or self._create_new(), template_id)
173
+
174
+ def delete(self, *, sandbox_id: str, force: bool = False, **kwargs: Any) -> None:
175
+ # Implementation
176
+ self._client.delete(sandbox_id, force=force)
177
+ ```
178
+ """
179
+
180
+ @abstractmethod
181
+ def list(
182
+ self,
183
+ *,
184
+ cursor: str | None = None,
185
+ **kwargs: Any,
186
+ ) -> SandboxListResponse[MetadataT]:
187
+ """List available sandboxes with optional filtering and pagination.
188
+
189
+ Args:
190
+ cursor: Optional continuation token from a previous list() call.
191
+ Pass None to start from the beginning. The cursor is opaque
192
+ and provider-specific; clients should not parse or modify it.
193
+ **kwargs: Provider-specific filter parameters. Implementations should
194
+ expose these as named keyword-only parameters with defaults for
195
+ type safety. Common examples include status filters, creation time
196
+ ranges, template filters, or owner filters.
197
+
198
+ Returns:
199
+ SandboxListResponse containing:
200
+ - items: List of sandbox metadata for the current page
201
+ - cursor: Token for next page, or None if this is the last page
202
+
203
+ Example:
204
+ ```python
205
+ # First page
206
+ response = provider.list()
207
+ for sandbox in response["items"]:
208
+ print(sandbox["sandbox_id"])
209
+
210
+ # Next page if available
211
+ if response["cursor"]:
212
+ next_response = provider.list(cursor=response["cursor"])
213
+
214
+ # With filters (if provider supports them)
215
+ running = provider.list(status="running")
216
+ ```
217
+ """
218
+
219
+ @abstractmethod
220
+ def get_or_create(
221
+ self,
222
+ *,
223
+ sandbox_id: str | None = None,
224
+ **kwargs: Any,
225
+ ) -> SandboxBackendProtocol:
226
+ """Get an existing sandbox or create a new one.
227
+
228
+ This method retrieves a connection to an existing sandbox if sandbox_id
229
+ is provided, or creates a new sandbox instance if sandbox_id is None.
230
+ The returned object implements SandboxBackendProtocol and can be used
231
+ for all sandbox operations (execute, read, write, etc.).
232
+
233
+ Important: If a sandbox_id is provided but does not exist, this method
234
+ should raise an error rather than creating a new sandbox. Only when
235
+ sandbox_id is explicitly None should a new sandbox be created.
236
+
237
+ Args:
238
+ sandbox_id: Unique identifier of an existing sandbox to retrieve.
239
+ If None, creates a new sandbox instance. The new sandbox's ID
240
+ can be accessed via the returned object's .id property.
241
+ If a non-None value is provided but the sandbox doesn't exist,
242
+ an error will be raised.
243
+ **kwargs: Provider-specific creation/connection parameters. Implementations
244
+ should expose these as named keyword-only parameters with defaults
245
+ for type safety. Common examples include template_id, resource limits,
246
+ environment variables, or timeout settings.
247
+
248
+ Returns:
249
+ An object implementing SandboxBackendProtocol that can execute
250
+ commands, read/write files, and perform other sandbox operations.
251
+
252
+ Raises:
253
+ Implementation-specific exceptions for errors such as:
254
+ - Sandbox not found (if sandbox_id provided but doesn't exist)
255
+ - Insufficient permissions
256
+ - Resource limits exceeded
257
+ - Invalid template or configuration
258
+
259
+ Example:
260
+ ```python
261
+ # Create a new sandbox
262
+ sandbox = provider.get_or_create(sandbox_id=None, template_id="python-3.11", timeout_minutes=60)
263
+ print(sandbox.id) # "sb_new123"
264
+
265
+ # Reconnect to existing sandbox
266
+ existing = provider.get_or_create(sandbox_id="sb_new123")
267
+
268
+ # Use the sandbox
269
+ result = sandbox.execute("python --version")
270
+ print(result.output)
271
+ ```
272
+ """
273
+
274
+ @abstractmethod
275
+ def delete(
276
+ self,
277
+ *,
278
+ sandbox_id: str,
279
+ **kwargs: Any,
280
+ ) -> None:
281
+ """Delete a sandbox instance.
282
+
283
+ This permanently destroys the sandbox and all its associated data.
284
+ The operation is typically irreversible.
285
+
286
+ Idempotency: This method should be idempotent - calling delete on a
287
+ non-existent sandbox should succeed without raising an error. This makes
288
+ cleanup code simpler and safe to retry.
289
+
290
+ Args:
291
+ sandbox_id: Unique identifier of the sandbox to delete.
292
+ **kwargs: Provider-specific deletion options. Implementations should
293
+ expose these as named keyword-only parameters with defaults for
294
+ type safety. Common examples include force flags, grace periods,
295
+ or cleanup options.
296
+
297
+ Raises:
298
+ Implementation-specific exceptions for errors such as:
299
+ - Insufficient permissions
300
+ - Sandbox is locked or in use
301
+ - Network or API errors
302
+
303
+ Example:
304
+ ```python
305
+ # Simple deletion
306
+ provider.delete(sandbox_id="sb_123")
307
+
308
+ # Safe to call multiple times (idempotent)
309
+ provider.delete(sandbox_id="sb_123") # No error even if already deleted
310
+
311
+ # With options (if provider supports them)
312
+ provider.delete(sandbox_id="sb_456", force=True)
313
+ ```
314
+ """
315
+
316
+ async def alist(
317
+ self,
318
+ *,
319
+ cursor: str | None = None,
320
+ **kwargs: Any,
321
+ ) -> SandboxListResponse[MetadataT]:
322
+ """Async version of list().
323
+
324
+ By default, runs the synchronous list() method in a thread pool.
325
+ Providers can override this for native async implementations.
326
+
327
+ Args:
328
+ cursor: Optional continuation token from a previous list() call.
329
+ **kwargs: Provider-specific filter parameters.
330
+
331
+ Returns:
332
+ SandboxListResponse containing items and cursor for pagination.
333
+ """
334
+ return await asyncio.to_thread(self.list, cursor=cursor, **kwargs)
335
+
336
+ async def aget_or_create(
337
+ self,
338
+ *,
339
+ sandbox_id: str | None = None,
340
+ **kwargs: Any,
341
+ ) -> SandboxBackendProtocol:
342
+ """Async version of get_or_create().
343
+
344
+ By default, runs the synchronous get_or_create() method in a thread pool.
345
+ Providers can override this for native async implementations.
346
+
347
+ Important: If a sandbox_id is provided but does not exist, this method
348
+ should raise an error rather than creating a new sandbox. Only when
349
+ sandbox_id is explicitly None should a new sandbox be created.
350
+
351
+ Args:
352
+ sandbox_id: Unique identifier of an existing sandbox to retrieve.
353
+ If None, creates a new sandbox instance. If a non-None value
354
+ is provided but the sandbox doesn't exist, an error will be raised.
355
+ **kwargs: Provider-specific creation/connection parameters.
356
+
357
+ Returns:
358
+ An object implementing SandboxBackendProtocol.
359
+ """
360
+ return await asyncio.to_thread(self.get_or_create, sandbox_id=sandbox_id, **kwargs)
361
+
362
+ async def adelete(
363
+ self,
364
+ *,
365
+ sandbox_id: str,
366
+ **kwargs: Any,
367
+ ) -> None:
368
+ """Async version of delete().
369
+
370
+ By default, runs the synchronous delete() method in a thread pool.
371
+ Providers can override this for native async implementations.
372
+
373
+ Args:
374
+ sandbox_id: Unique identifier of the sandbox to delete.
375
+ **kwargs: Provider-specific deletion options.
376
+ """
377
+ await asyncio.to_thread(self.delete, sandbox_id=sandbox_id, **kwargs)
378
+
379
+
26
380
  _GLOB_COMMAND_TEMPLATE = """python3 -c "
27
381
  import glob
28
382
  import os
@@ -46,40 +400,82 @@ for m in matches:
46
400
  print(json.dumps(result))
47
401
  " 2>/dev/null"""
48
402
 
403
+ # Use heredoc to pass content via stdin to avoid ARG_MAX limits on large files.
404
+ # ARG_MAX limits the total size of command-line arguments.
405
+ # Previously, base64-encoded content was interpolated directly into the command
406
+ # string, which would fail for files larger than ~100KB after base64 expansion.
407
+ # Heredocs bypass this by passing data through stdin rather than as arguments.
408
+ # Stdin format: first line is base64-encoded file path, second line is base64-encoded content.
49
409
  _WRITE_COMMAND_TEMPLATE = """python3 -c "
50
410
  import os
51
411
  import sys
52
412
  import base64
413
+ import json
53
414
 
54
- file_path = '{file_path}'
415
+ # Read JSON payload from stdin containing file_path and content (both base64-encoded)
416
+ payload_b64 = sys.stdin.read().strip()
417
+ if not payload_b64:
418
+ print('Error: No payload received for write operation', file=sys.stderr)
419
+ sys.exit(1)
420
+
421
+ try:
422
+ payload = base64.b64decode(payload_b64).decode('utf-8')
423
+ data = json.loads(payload)
424
+ file_path = data['path']
425
+ content = base64.b64decode(data['content']).decode('utf-8')
426
+ except Exception as e:
427
+ print(f'Error: Failed to decode write payload: {{e}}', file=sys.stderr)
428
+ sys.exit(1)
55
429
 
56
430
  # Check if file already exists (atomic with write)
57
431
  if os.path.exists(file_path):
58
- print(f'Error: File \\'{file_path}\\' already exists', file=sys.stderr)
432
+ print(f'Error: File \\'{{file_path}}\\' already exists', file=sys.stderr)
59
433
  sys.exit(1)
60
434
 
61
435
  # Create parent directory if needed
62
436
  parent_dir = os.path.dirname(file_path) or '.'
63
437
  os.makedirs(parent_dir, exist_ok=True)
64
438
 
65
- # Decode and write content
66
- content = base64.b64decode('{content_b64}').decode('utf-8')
67
439
  with open(file_path, 'w') as f:
68
440
  f.write(content)
69
- " 2>&1"""
70
-
441
+ " <<'__DEEPAGENTS_EOF__'
442
+ {payload_b64}
443
+ __DEEPAGENTS_EOF__"""
444
+
445
+ # Use heredoc to pass edit parameters via stdin to avoid ARG_MAX limits.
446
+ # Stdin format: base64-encoded JSON with {"path": str, "old": str, "new": str}.
447
+ # JSON bundles all parameters; base64 ensures safe transport of arbitrary content
448
+ # (special chars, newlines, etc.) through the heredoc without escaping issues.
71
449
  _EDIT_COMMAND_TEMPLATE = """python3 -c "
72
450
  import sys
73
451
  import base64
452
+ import json
453
+ import os
454
+
455
+ # Read and decode JSON payload from stdin
456
+ payload_b64 = sys.stdin.read().strip()
457
+ if not payload_b64:
458
+ print('Error: No payload received for edit operation', file=sys.stderr)
459
+ sys.exit(4)
460
+
461
+ try:
462
+ payload = base64.b64decode(payload_b64).decode('utf-8')
463
+ data = json.loads(payload)
464
+ file_path = data['path']
465
+ old = data['old']
466
+ new = data['new']
467
+ except Exception as e:
468
+ print(f'Error: Failed to decode edit payload: {{e}}', file=sys.stderr)
469
+ sys.exit(4)
470
+
471
+ # Check if file exists
472
+ if not os.path.isfile(file_path):
473
+ sys.exit(3) # File not found
74
474
 
75
475
  # Read file content
76
- with open('{file_path}', 'r') as f:
476
+ with open(file_path, 'r') as f:
77
477
  text = f.read()
78
478
 
79
- # Decode base64-encoded strings
80
- old = base64.b64decode('{old_b64}').decode('utf-8')
81
- new = base64.b64decode('{new_b64}').decode('utf-8')
82
-
83
479
  # Count occurrences
84
480
  count = text.count(old)
85
481
 
@@ -96,11 +492,13 @@ else:
96
492
  result = text.replace(old, new, 1)
97
493
 
98
494
  # Write back to file
99
- with open('{file_path}', 'w') as f:
495
+ with open(file_path, 'w') as f:
100
496
  f.write(result)
101
497
 
102
498
  print(count)
103
- " 2>&1"""
499
+ " <<'__DEEPAGENTS_EOF__'
500
+ {payload_b64}
501
+ __DEEPAGENTS_EOF__"""
104
502
 
105
503
  _READ_COMMAND_TEMPLATE = """python3 -c "
106
504
  import os
@@ -221,11 +619,14 @@ except PermissionError:
221
619
  content: str,
222
620
  ) -> WriteResult:
223
621
  """Create a new file. Returns WriteResult; error populated on failure."""
224
- # Encode content as base64 to avoid any escaping issues
622
+ # Create JSON payload with file path and base64-encoded content
623
+ # This avoids shell injection via file_path and ARG_MAX limits on content
225
624
  content_b64 = base64.b64encode(content.encode("utf-8")).decode("ascii")
625
+ payload = json.dumps({"path": file_path, "content": content_b64})
626
+ payload_b64 = base64.b64encode(payload.encode("utf-8")).decode("ascii")
226
627
 
227
628
  # Single atomic check + write command
228
- cmd = _WRITE_COMMAND_TEMPLATE.format(file_path=file_path, content_b64=content_b64)
629
+ cmd = _WRITE_COMMAND_TEMPLATE.format(payload_b64=payload_b64)
229
630
  result = self.execute(cmd)
230
631
 
231
632
  # Check for errors (exit code or error message in output)
@@ -244,23 +645,29 @@ except PermissionError:
244
645
  replace_all: bool = False,
245
646
  ) -> EditResult:
246
647
  """Edit a file by replacing string occurrences. Returns EditResult."""
247
- # Encode strings as base64 to avoid any escaping issues
248
- old_b64 = base64.b64encode(old_string.encode("utf-8")).decode("ascii")
249
- new_b64 = base64.b64encode(new_string.encode("utf-8")).decode("ascii")
648
+ # Create JSON payload with file path, old string, and new string
649
+ # This avoids shell injection via file_path and ARG_MAX limits on strings
650
+ payload = json.dumps({"path": file_path, "old": old_string, "new": new_string})
651
+ payload_b64 = base64.b64encode(payload.encode("utf-8")).decode("ascii")
250
652
 
251
653
  # Use template for string replacement
252
- cmd = _EDIT_COMMAND_TEMPLATE.format(file_path=file_path, old_b64=old_b64, new_b64=new_b64, replace_all=replace_all)
654
+ cmd = _EDIT_COMMAND_TEMPLATE.format(payload_b64=payload_b64, replace_all=replace_all)
253
655
  result = self.execute(cmd)
254
656
 
255
657
  exit_code = result.exit_code
256
658
  output = result.output.strip()
257
659
 
258
- if exit_code == 1:
259
- return EditResult(error=f"Error: String not found in file: '{old_string}'")
260
- if exit_code == 2:
261
- return EditResult(error=f"Error: String '{old_string}' appears multiple times. Use replace_all=True to replace all occurrences.")
660
+ # Map exit codes to error messages
661
+ error_messages = {
662
+ 1: f"Error: String not found in file: '{old_string}'",
663
+ 2: f"Error: String '{old_string}' appears multiple times. Use replace_all=True to replace all occurrences.",
664
+ 3: f"Error: File '{file_path}' not found",
665
+ 4: f"Error: Failed to decode edit payload: {output}",
666
+ }
667
+ if exit_code in error_messages:
668
+ return EditResult(error=error_messages[exit_code])
262
669
  if exit_code != 0:
263
- return EditResult(error=f"Error: File '{file_path}' not found")
670
+ return EditResult(error=f"Error editing file (exit code {exit_code}): {output or 'Unknown error'}")
264
671
 
265
672
  count = int(output)
266
673
  # External storage - no files_update needed