deepagents 0.3.9__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.
- deepagents/__init__.py +3 -1
- deepagents/_version.py +3 -0
- deepagents/backends/__init__.py +2 -0
- deepagents/backends/composite.py +2 -2
- deepagents/backends/filesystem.py +13 -21
- deepagents/backends/local_shell.py +305 -0
- deepagents/backends/sandbox.py +357 -3
- deepagents/backends/utils.py +69 -24
- deepagents/middleware/filesystem.py +35 -9
- deepagents/middleware/skills.py +1 -1
- deepagents/middleware/subagents.py +23 -9
- deepagents/py.typed +0 -0
- deepagents-0.3.10.dist-info/METADATA +76 -0
- deepagents-0.3.10.dist-info/RECORD +25 -0
- deepagents-0.3.9.dist-info/METADATA +0 -527
- deepagents-0.3.9.dist-info/RECORD +0 -22
- {deepagents-0.3.9.dist-info → deepagents-0.3.10.dist-info}/WHEEL +0 -0
- {deepagents-0.3.9.dist-info → deepagents-0.3.10.dist-info}/top_level.txt +0 -0
deepagents/backends/sandbox.py
CHANGED
|
@@ -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
|
|
@@ -70,12 +424,12 @@ try:
|
|
|
70
424
|
file_path = data['path']
|
|
71
425
|
content = base64.b64decode(data['content']).decode('utf-8')
|
|
72
426
|
except Exception as e:
|
|
73
|
-
print(f'Error: Failed to decode write payload: {e}', file=sys.stderr)
|
|
427
|
+
print(f'Error: Failed to decode write payload: {{e}}', file=sys.stderr)
|
|
74
428
|
sys.exit(1)
|
|
75
429
|
|
|
76
430
|
# Check if file already exists (atomic with write)
|
|
77
431
|
if os.path.exists(file_path):
|
|
78
|
-
print(f'Error: File \\'{file_path}\\' already exists', file=sys.stderr)
|
|
432
|
+
print(f'Error: File \\'{{file_path}}\\' already exists', file=sys.stderr)
|
|
79
433
|
sys.exit(1)
|
|
80
434
|
|
|
81
435
|
# Create parent directory if needed
|
|
@@ -111,7 +465,7 @@ try:
|
|
|
111
465
|
old = data['old']
|
|
112
466
|
new = data['new']
|
|
113
467
|
except Exception as e:
|
|
114
|
-
print(f'Error: Failed to decode edit payload: {e}', file=sys.stderr)
|
|
468
|
+
print(f'Error: Failed to decode edit payload: {{e}}', file=sys.stderr)
|
|
115
469
|
sys.exit(4)
|
|
116
470
|
|
|
117
471
|
# Check if file exists
|
deepagents/backends/utils.py
CHANGED
|
@@ -219,17 +219,26 @@ def truncate_if_too_long(result: list[str] | str) -> list[str] | str:
|
|
|
219
219
|
return result
|
|
220
220
|
|
|
221
221
|
|
|
222
|
-
def
|
|
223
|
-
"""
|
|
222
|
+
def _normalize_path(path: str | None) -> str:
|
|
223
|
+
"""Normalize a path to canonical form.
|
|
224
|
+
|
|
225
|
+
Converts path to absolute form starting with /, removes trailing slashes
|
|
226
|
+
(except for root), and validates that the path is not empty.
|
|
224
227
|
|
|
225
228
|
Args:
|
|
226
|
-
path: Path to
|
|
229
|
+
path: Path to normalize (None defaults to "/")
|
|
227
230
|
|
|
228
231
|
Returns:
|
|
229
|
-
Normalized path starting with /
|
|
232
|
+
Normalized path starting with / (without trailing slash unless it's root)
|
|
230
233
|
|
|
231
234
|
Raises:
|
|
232
|
-
ValueError: If path is invalid
|
|
235
|
+
ValueError: If path is invalid (empty string after strip)
|
|
236
|
+
|
|
237
|
+
Example:
|
|
238
|
+
_normalize_path(None) -> "/"
|
|
239
|
+
_normalize_path("/dir/") -> "/dir"
|
|
240
|
+
_normalize_path("dir") -> "/dir"
|
|
241
|
+
_normalize_path("/") -> "/"
|
|
233
242
|
"""
|
|
234
243
|
path = path or "/"
|
|
235
244
|
if not path or path.strip() == "":
|
|
@@ -237,12 +246,43 @@ def _validate_path(path: str | None) -> str:
|
|
|
237
246
|
|
|
238
247
|
normalized = path if path.startswith("/") else "/" + path
|
|
239
248
|
|
|
240
|
-
|
|
241
|
-
|
|
249
|
+
# Only root should have trailing slash
|
|
250
|
+
if normalized != "/" and normalized.endswith("/"):
|
|
251
|
+
normalized = normalized.rstrip("/")
|
|
242
252
|
|
|
243
253
|
return normalized
|
|
244
254
|
|
|
245
255
|
|
|
256
|
+
def _filter_files_by_path(files: dict[str, Any], normalized_path: str) -> dict[str, Any]:
|
|
257
|
+
"""Filter files dict by normalized path, handling exact file matches and directory prefixes.
|
|
258
|
+
|
|
259
|
+
Expects a normalized path from _normalize_path (no trailing slash except root).
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
files: Dictionary mapping file paths to file data
|
|
263
|
+
normalized_path: Normalized path from _normalize_path (e.g., "/", "/dir", "/dir/file")
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
Filtered dictionary of files matching the path
|
|
267
|
+
|
|
268
|
+
Example:
|
|
269
|
+
files = {"/dir/file": {...}, "/dir/other": {...}}
|
|
270
|
+
_filter_files_by_path(files, "/dir/file") # Returns {"/dir/file": {...}}
|
|
271
|
+
_filter_files_by_path(files, "/dir") # Returns both files
|
|
272
|
+
"""
|
|
273
|
+
# Check if path matches an exact file
|
|
274
|
+
if normalized_path in files:
|
|
275
|
+
return {normalized_path: files[normalized_path]}
|
|
276
|
+
|
|
277
|
+
# Otherwise treat as directory prefix
|
|
278
|
+
if normalized_path == "/":
|
|
279
|
+
# Root directory - match all files starting with /
|
|
280
|
+
return {fp: fd for fp, fd in files.items() if fp.startswith("/")}
|
|
281
|
+
# Non-root directory - add trailing slash for prefix matching
|
|
282
|
+
dir_prefix = normalized_path + "/"
|
|
283
|
+
return {fp: fd for fp, fd in files.items() if fp.startswith(dir_prefix)}
|
|
284
|
+
|
|
285
|
+
|
|
246
286
|
def _glob_search_files(
|
|
247
287
|
files: dict[str, Any],
|
|
248
288
|
pattern: str,
|
|
@@ -267,11 +307,11 @@ def _glob_search_files(
|
|
|
267
307
|
```
|
|
268
308
|
"""
|
|
269
309
|
try:
|
|
270
|
-
normalized_path =
|
|
310
|
+
normalized_path = _normalize_path(path)
|
|
271
311
|
except ValueError:
|
|
272
312
|
return "No files found"
|
|
273
313
|
|
|
274
|
-
filtered =
|
|
314
|
+
filtered = _filter_files_by_path(files, normalized_path)
|
|
275
315
|
|
|
276
316
|
# Respect standard glob semantics:
|
|
277
317
|
# - Patterns without path separators (e.g., "*.py") match only in the current
|
|
@@ -281,9 +321,17 @@ def _glob_search_files(
|
|
|
281
321
|
|
|
282
322
|
matches = []
|
|
283
323
|
for file_path, file_data in filtered.items():
|
|
284
|
-
relative
|
|
285
|
-
|
|
324
|
+
# Compute relative path for glob matching
|
|
325
|
+
# If normalized_path is "/dir", we want "/dir/file.txt" -> "file.txt"
|
|
326
|
+
# If normalized_path is "/dir/file.txt" (exact file), we want "file.txt"
|
|
327
|
+
if normalized_path == "/":
|
|
328
|
+
relative = file_path[1:] # Remove leading slash
|
|
329
|
+
elif file_path == normalized_path:
|
|
330
|
+
# Exact file match - use just the filename
|
|
286
331
|
relative = file_path.split("/")[-1]
|
|
332
|
+
else:
|
|
333
|
+
# Directory prefix - strip the directory path
|
|
334
|
+
relative = file_path[len(normalized_path) + 1 :] # +1 for the slash
|
|
287
335
|
|
|
288
336
|
if wcglob.globmatch(relative, effective_pattern, flags=wcglob.BRACE | wcglob.GLOBSTAR):
|
|
289
337
|
matches.append((file_path, file_data["modified_at"]))
|
|
@@ -357,11 +405,11 @@ def _grep_search_files(
|
|
|
357
405
|
return f"Invalid regex pattern: {e}"
|
|
358
406
|
|
|
359
407
|
try:
|
|
360
|
-
normalized_path =
|
|
408
|
+
normalized_path = _normalize_path(path)
|
|
361
409
|
except ValueError:
|
|
362
410
|
return "No matches found"
|
|
363
411
|
|
|
364
|
-
filtered =
|
|
412
|
+
filtered = _filter_files_by_path(files, normalized_path)
|
|
365
413
|
|
|
366
414
|
if glob:
|
|
367
415
|
filtered = {fp: fd for fp, fd in filtered.items() if wcglob.globmatch(Path(fp).name, glob, flags=wcglob.BRACE)}
|
|
@@ -390,21 +438,18 @@ def grep_matches_from_files(
|
|
|
390
438
|
) -> list[GrepMatch] | str:
|
|
391
439
|
"""Return structured grep matches from an in-memory files mapping.
|
|
392
440
|
|
|
393
|
-
|
|
394
|
-
(e.g., invalid regex). We deliberately do not raise here to keep backends
|
|
395
|
-
non-throwing in tool contexts and preserve user-facing error messages.
|
|
396
|
-
"""
|
|
397
|
-
try:
|
|
398
|
-
regex = re.compile(pattern)
|
|
399
|
-
except re.error as e:
|
|
400
|
-
return f"Invalid regex pattern: {e}"
|
|
441
|
+
Performs literal text search (not regex).
|
|
401
442
|
|
|
443
|
+
Returns a list of GrepMatch on success, or a string for invalid inputs.
|
|
444
|
+
We deliberately do not raise here to keep backends non-throwing in tool
|
|
445
|
+
contexts and preserve user-facing error messages.
|
|
446
|
+
"""
|
|
402
447
|
try:
|
|
403
|
-
normalized_path =
|
|
448
|
+
normalized_path = _normalize_path(path)
|
|
404
449
|
except ValueError:
|
|
405
450
|
return []
|
|
406
451
|
|
|
407
|
-
filtered =
|
|
452
|
+
filtered = _filter_files_by_path(files, normalized_path)
|
|
408
453
|
|
|
409
454
|
if glob:
|
|
410
455
|
filtered = {fp: fd for fp, fd in filtered.items() if wcglob.globmatch(Path(fp).name, glob, flags=wcglob.BRACE)}
|
|
@@ -412,7 +457,7 @@ def grep_matches_from_files(
|
|
|
412
457
|
matches: list[GrepMatch] = []
|
|
413
458
|
for file_path, file_data in filtered.items():
|
|
414
459
|
for line_num, line in enumerate(file_data["content"], 1):
|
|
415
|
-
if
|
|
460
|
+
if pattern in line: # Simple substring search for literal matching
|
|
416
461
|
matches.append({"path": file_path, "line": int(line_num), "text": line})
|
|
417
462
|
return matches
|
|
418
463
|
|
|
@@ -221,11 +221,13 @@ Examples:
|
|
|
221
221
|
GREP_TOOL_DESCRIPTION = """Search for a text pattern across files.
|
|
222
222
|
|
|
223
223
|
Searches for literal text (not regex) and returns matching files or content based on output_mode.
|
|
224
|
+
Special characters like parentheses, brackets, pipes, etc. are treated as literal characters, not regex operators.
|
|
224
225
|
|
|
225
226
|
Examples:
|
|
226
227
|
- Search all files: `grep(pattern="TODO")`
|
|
227
228
|
- Search Python files only: `grep(pattern="import", glob="*.py")`
|
|
228
|
-
- Show matching lines: `grep(pattern="error", output_mode="content")`
|
|
229
|
+
- Show matching lines: `grep(pattern="error", output_mode="content")`
|
|
230
|
+
- Search for code with special chars: `grep(pattern="def __init__(self):")`"""
|
|
229
231
|
|
|
230
232
|
EXECUTE_TOOL_DESCRIPTION = """Executes a shell command in an isolated sandbox environment.
|
|
231
233
|
|
|
@@ -493,7 +495,10 @@ class FilesystemMiddleware(AgentMiddleware):
|
|
|
493
495
|
) -> str:
|
|
494
496
|
"""Synchronous wrapper for ls tool."""
|
|
495
497
|
resolved_backend = self._get_backend(runtime)
|
|
496
|
-
|
|
498
|
+
try:
|
|
499
|
+
validated_path = _validate_path(path)
|
|
500
|
+
except ValueError as e:
|
|
501
|
+
return f"Error: {e}"
|
|
497
502
|
infos = resolved_backend.ls_info(validated_path)
|
|
498
503
|
paths = [fi.get("path", "") for fi in infos]
|
|
499
504
|
result = truncate_if_too_long(paths)
|
|
@@ -505,7 +510,10 @@ class FilesystemMiddleware(AgentMiddleware):
|
|
|
505
510
|
) -> str:
|
|
506
511
|
"""Asynchronous wrapper for ls tool."""
|
|
507
512
|
resolved_backend = self._get_backend(runtime)
|
|
508
|
-
|
|
513
|
+
try:
|
|
514
|
+
validated_path = _validate_path(path)
|
|
515
|
+
except ValueError as e:
|
|
516
|
+
return f"Error: {e}"
|
|
509
517
|
infos = await resolved_backend.als_info(validated_path)
|
|
510
518
|
paths = [fi.get("path", "") for fi in infos]
|
|
511
519
|
result = truncate_if_too_long(paths)
|
|
@@ -531,7 +539,10 @@ class FilesystemMiddleware(AgentMiddleware):
|
|
|
531
539
|
) -> str:
|
|
532
540
|
"""Synchronous wrapper for read_file tool."""
|
|
533
541
|
resolved_backend = self._get_backend(runtime)
|
|
534
|
-
|
|
542
|
+
try:
|
|
543
|
+
validated_path = _validate_path(file_path)
|
|
544
|
+
except ValueError as e:
|
|
545
|
+
return f"Error: {e}"
|
|
535
546
|
result = resolved_backend.read(validated_path, offset=offset, limit=limit)
|
|
536
547
|
|
|
537
548
|
lines = result.splitlines(keepends=True)
|
|
@@ -557,7 +568,10 @@ class FilesystemMiddleware(AgentMiddleware):
|
|
|
557
568
|
) -> str:
|
|
558
569
|
"""Asynchronous wrapper for read_file tool."""
|
|
559
570
|
resolved_backend = self._get_backend(runtime)
|
|
560
|
-
|
|
571
|
+
try:
|
|
572
|
+
validated_path = _validate_path(file_path)
|
|
573
|
+
except ValueError as e:
|
|
574
|
+
return f"Error: {e}"
|
|
561
575
|
result = await resolved_backend.aread(validated_path, offset=offset, limit=limit)
|
|
562
576
|
|
|
563
577
|
lines = result.splitlines(keepends=True)
|
|
@@ -593,7 +607,10 @@ class FilesystemMiddleware(AgentMiddleware):
|
|
|
593
607
|
) -> Command | str:
|
|
594
608
|
"""Synchronous wrapper for write_file tool."""
|
|
595
609
|
resolved_backend = self._get_backend(runtime)
|
|
596
|
-
|
|
610
|
+
try:
|
|
611
|
+
validated_path = _validate_path(file_path)
|
|
612
|
+
except ValueError as e:
|
|
613
|
+
return f"Error: {e}"
|
|
597
614
|
res: WriteResult = resolved_backend.write(validated_path, content)
|
|
598
615
|
if res.error:
|
|
599
616
|
return res.error
|
|
@@ -619,7 +636,10 @@ class FilesystemMiddleware(AgentMiddleware):
|
|
|
619
636
|
) -> Command | str:
|
|
620
637
|
"""Asynchronous wrapper for write_file tool."""
|
|
621
638
|
resolved_backend = self._get_backend(runtime)
|
|
622
|
-
|
|
639
|
+
try:
|
|
640
|
+
validated_path = _validate_path(file_path)
|
|
641
|
+
except ValueError as e:
|
|
642
|
+
return f"Error: {e}"
|
|
623
643
|
res: WriteResult = await resolved_backend.awrite(validated_path, content)
|
|
624
644
|
if res.error:
|
|
625
645
|
return res.error
|
|
@@ -659,7 +679,10 @@ class FilesystemMiddleware(AgentMiddleware):
|
|
|
659
679
|
) -> Command | str:
|
|
660
680
|
"""Synchronous wrapper for edit_file tool."""
|
|
661
681
|
resolved_backend = self._get_backend(runtime)
|
|
662
|
-
|
|
682
|
+
try:
|
|
683
|
+
validated_path = _validate_path(file_path)
|
|
684
|
+
except ValueError as e:
|
|
685
|
+
return f"Error: {e}"
|
|
663
686
|
res: EditResult = resolved_backend.edit(validated_path, old_string, new_string, replace_all=replace_all)
|
|
664
687
|
if res.error:
|
|
665
688
|
return res.error
|
|
@@ -687,7 +710,10 @@ class FilesystemMiddleware(AgentMiddleware):
|
|
|
687
710
|
) -> Command | str:
|
|
688
711
|
"""Asynchronous wrapper for edit_file tool."""
|
|
689
712
|
resolved_backend = self._get_backend(runtime)
|
|
690
|
-
|
|
713
|
+
try:
|
|
714
|
+
validated_path = _validate_path(file_path)
|
|
715
|
+
except ValueError as e:
|
|
716
|
+
return f"Error: {e}"
|
|
691
717
|
res: EditResult = await resolved_backend.aedit(validated_path, old_string, new_string, replace_all=replace_all)
|
|
692
718
|
if res.error:
|
|
693
719
|
return res.error
|
deepagents/middleware/skills.py
CHANGED
|
@@ -155,7 +155,7 @@ class SkillsState(AgentState):
|
|
|
155
155
|
"""State for the skills middleware."""
|
|
156
156
|
|
|
157
157
|
skills_metadata: NotRequired[Annotated[list[SkillMetadata], PrivateStateAttr]]
|
|
158
|
-
"""List of loaded skill metadata from
|
|
158
|
+
"""List of loaded skill metadata from configured sources. Not propagated to parent agents."""
|
|
159
159
|
|
|
160
160
|
|
|
161
161
|
class SkillsStateUpdate(TypedDict):
|