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.
@@ -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
@@ -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 _validate_path(path: str | None) -> str:
223
- """Validate and normalize a path.
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 validate
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
- if not normalized.endswith("/"):
241
- normalized += "/"
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 = _validate_path(path)
310
+ normalized_path = _normalize_path(path)
271
311
  except ValueError:
272
312
  return "No files found"
273
313
 
274
- filtered = {fp: fd for fp, fd in files.items() if fp.startswith(normalized_path)}
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 = file_path[len(normalized_path) :].lstrip("/")
285
- if not relative:
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 = _validate_path(path)
408
+ normalized_path = _normalize_path(path)
361
409
  except ValueError:
362
410
  return "No matches found"
363
411
 
364
- filtered = {fp: fd for fp, fd in files.items() if fp.startswith(normalized_path)}
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
- Returns a list of GrepMatch on success, or a string for invalid inputs
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 = _validate_path(path)
448
+ normalized_path = _normalize_path(path)
404
449
  except ValueError:
405
450
  return []
406
451
 
407
- filtered = {fp: fd for fp, fd in files.items() if fp.startswith(normalized_path)}
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 regex.search(line):
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
- validated_path = _validate_path(path)
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
- validated_path = _validate_path(path)
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
- validated_path = _validate_path(file_path)
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
- validated_path = _validate_path(file_path)
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
- validated_path = _validate_path(file_path)
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
- validated_path = _validate_path(file_path)
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
- validated_path = _validate_path(file_path)
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
- validated_path = _validate_path(file_path)
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
@@ -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 all configured sources."""
158
+ """List of loaded skill metadata from configured sources. Not propagated to parent agents."""
159
159
 
160
160
 
161
161
  class SkillsStateUpdate(TypedDict):