deepagents 0.3.7a1__py3-none-any.whl → 0.3.9__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/backends/filesystem.py +55 -7
- deepagents/backends/sandbox.py +76 -23
- deepagents/graph.py +29 -10
- deepagents/middleware/__init__.py +3 -1
- deepagents/middleware/filesystem.py +508 -544
- deepagents/middleware/memory.py +11 -7
- deepagents/middleware/skills.py +4 -2
- deepagents/middleware/subagents.py +35 -19
- deepagents/middleware/summarization.py +763 -0
- {deepagents-0.3.7a1.dist-info → deepagents-0.3.9.dist-info}/METADATA +7 -7
- deepagents-0.3.9.dist-info/RECORD +22 -0
- {deepagents-0.3.7a1.dist-info → deepagents-0.3.9.dist-info}/WHEEL +1 -1
- deepagents-0.3.7a1.dist-info/RECORD +0 -21
- {deepagents-0.3.7a1.dist-info → deepagents-0.3.9.dist-info}/top_level.txt +0 -0
|
@@ -20,8 +20,9 @@ from langgraph.types import Command
|
|
|
20
20
|
from typing_extensions import TypedDict
|
|
21
21
|
|
|
22
22
|
from deepagents.backends import StateBackend
|
|
23
|
+
from deepagents.backends.composite import CompositeBackend
|
|
23
24
|
from deepagents.backends.protocol import (
|
|
24
|
-
BACKEND_TYPES as BACKEND_TYPES, # Re-export for backwards compatibility
|
|
25
|
+
BACKEND_TYPES as BACKEND_TYPES, # Re-export type here for backwards compatibility
|
|
25
26
|
BackendProtocol,
|
|
26
27
|
EditResult,
|
|
27
28
|
SandboxBackendProtocol,
|
|
@@ -40,6 +41,21 @@ LINE_NUMBER_WIDTH = 6
|
|
|
40
41
|
DEFAULT_READ_OFFSET = 0
|
|
41
42
|
DEFAULT_READ_LIMIT = 100
|
|
42
43
|
|
|
44
|
+
# Template for truncation message in read_file
|
|
45
|
+
# {file_path} will be filled in at runtime
|
|
46
|
+
READ_FILE_TRUNCATION_MSG = (
|
|
47
|
+
"\n\n[Output was truncated due to size limits. "
|
|
48
|
+
"The file content is very large. "
|
|
49
|
+
"Consider reformatting the file to make it easier to navigate. "
|
|
50
|
+
"For example, if this is JSON, use execute(command='jq . {file_path}') to pretty-print it with line breaks. "
|
|
51
|
+
"For other formats, you can use appropriate formatting tools to split long lines.]"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Approximate number of characters per token for truncation calculations.
|
|
55
|
+
# Using 4 chars per token as a conservative approximation (actual ratio varies by content)
|
|
56
|
+
# This errs on the high side to avoid premature eviction of content that might fit
|
|
57
|
+
NUM_CHARS_PER_TOKEN = 4
|
|
58
|
+
|
|
43
59
|
|
|
44
60
|
class FileData(TypedDict):
|
|
45
61
|
"""Data structure for storing file contents with metadata."""
|
|
@@ -154,19 +170,16 @@ class FilesystemState(AgentState):
|
|
|
154
170
|
"""Files in the filesystem."""
|
|
155
171
|
|
|
156
172
|
|
|
157
|
-
LIST_FILES_TOOL_DESCRIPTION = """Lists all files in
|
|
173
|
+
LIST_FILES_TOOL_DESCRIPTION = """Lists all files in a directory.
|
|
158
174
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
- You should almost ALWAYS use this tool before using the Read or Edit tools."""
|
|
175
|
+
This is useful for exploring the filesystem and finding the right file to read or edit.
|
|
176
|
+
You should almost ALWAYS use this tool before using the read_file or edit_file tools."""
|
|
177
|
+
|
|
178
|
+
READ_FILE_TOOL_DESCRIPTION = """Reads a file from the filesystem.
|
|
164
179
|
|
|
165
|
-
|
|
166
|
-
Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
|
|
180
|
+
Assume this tool is able to read all files. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
|
|
167
181
|
|
|
168
182
|
Usage:
|
|
169
|
-
- The file_path parameter must be an absolute path, not a relative path
|
|
170
183
|
- By default, it reads up to 100 lines starting from the beginning of the file
|
|
171
184
|
- **IMPORTANT for large files and codebase exploration**: Use pagination with offset and limit parameters to avoid context overflow
|
|
172
185
|
- First scan: read_file(path, limit=100) to see file structure
|
|
@@ -182,61 +195,46 @@ Usage:
|
|
|
182
195
|
EDIT_FILE_TOOL_DESCRIPTION = """Performs exact string replacements in files.
|
|
183
196
|
|
|
184
197
|
Usage:
|
|
185
|
-
- You must
|
|
186
|
-
- When editing
|
|
187
|
-
- ALWAYS prefer editing existing files
|
|
188
|
-
- Only use emojis if the user explicitly requests it.
|
|
189
|
-
- The edit will FAIL if `old_string` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use `replace_all` to change every instance of `old_string`.
|
|
190
|
-
- Use `replace_all` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance."""
|
|
198
|
+
- You must read the file before editing. This tool will error if you attempt an edit without reading the file first.
|
|
199
|
+
- When editing, preserve the exact indentation (tabs/spaces) from the read output. Never include line number prefixes in old_string or new_string.
|
|
200
|
+
- ALWAYS prefer editing existing files over creating new ones.
|
|
201
|
+
- Only use emojis if the user explicitly requests it."""
|
|
191
202
|
|
|
192
203
|
|
|
193
204
|
WRITE_FILE_TOOL_DESCRIPTION = """Writes to a new file in the filesystem.
|
|
194
205
|
|
|
195
206
|
Usage:
|
|
196
|
-
- The file_path parameter must be an absolute path, not a relative path
|
|
197
|
-
- The content parameter must be a string
|
|
198
207
|
- The write_file tool will create the a new file.
|
|
199
|
-
- Prefer to edit existing files over creating new ones when possible.
|
|
200
|
-
|
|
208
|
+
- Prefer to edit existing files (with the edit_file tool) over creating new ones when possible.
|
|
209
|
+
"""
|
|
201
210
|
|
|
202
211
|
GLOB_TOOL_DESCRIPTION = """Find files matching a glob pattern.
|
|
203
212
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
- Supports standard glob patterns: `*` (any characters), `**` (any directories), `?` (single character)
|
|
207
|
-
- Patterns can be absolute (starting with `/`) or relative
|
|
208
|
-
- Returns a list of absolute file paths that match the pattern
|
|
213
|
+
Supports standard glob patterns: `*` (any characters), `**` (any directories), `?` (single character).
|
|
214
|
+
Returns a list of absolute file paths that match the pattern.
|
|
209
215
|
|
|
210
216
|
Examples:
|
|
211
217
|
- `**/*.py` - Find all Python files
|
|
212
218
|
- `*.txt` - Find all text files in root
|
|
213
219
|
- `/subdir/**/*.md` - Find all markdown files under /subdir"""
|
|
214
220
|
|
|
215
|
-
GREP_TOOL_DESCRIPTION = """Search for a pattern
|
|
221
|
+
GREP_TOOL_DESCRIPTION = """Search for a text pattern across files.
|
|
216
222
|
|
|
217
|
-
|
|
218
|
-
- The grep tool searches for text patterns across files
|
|
219
|
-
- The pattern parameter is the text to search for (literal string, not regex)
|
|
220
|
-
- The path parameter filters which directory to search in (default is the current working directory)
|
|
221
|
-
- The glob parameter accepts a glob pattern to filter which files to search (e.g., `*.py`)
|
|
222
|
-
- The output_mode parameter controls the output format:
|
|
223
|
-
- `files_with_matches`: List only file paths containing matches (default)
|
|
224
|
-
- `content`: Show matching lines with file path and line numbers
|
|
225
|
-
- `count`: Show count of matches per file
|
|
223
|
+
Searches for literal text (not regex) and returns matching files or content based on output_mode.
|
|
226
224
|
|
|
227
225
|
Examples:
|
|
228
226
|
- Search all files: `grep(pattern="TODO")`
|
|
229
227
|
- Search Python files only: `grep(pattern="import", glob="*.py")`
|
|
230
228
|
- Show matching lines: `grep(pattern="error", output_mode="content")`"""
|
|
231
229
|
|
|
232
|
-
EXECUTE_TOOL_DESCRIPTION = """Executes a
|
|
230
|
+
EXECUTE_TOOL_DESCRIPTION = """Executes a shell command in an isolated sandbox environment.
|
|
233
231
|
|
|
232
|
+
Usage:
|
|
233
|
+
Executes a given command in the sandbox environment with proper handling and security measures.
|
|
234
234
|
Before executing the command, please follow these steps:
|
|
235
|
-
|
|
236
235
|
1. Directory Verification:
|
|
237
236
|
- If the command will create new directories or files, first use the ls tool to verify the parent directory exists and is the correct location
|
|
238
237
|
- For example, before running "mkdir foo/bar", first use ls to check that "foo" exists and is the intended parent directory
|
|
239
|
-
|
|
240
238
|
2. Command Execution:
|
|
241
239
|
- Always quote file paths that contain spaces with double quotes (e.g., cd "path with spaces/file.txt")
|
|
242
240
|
- Examples of proper quoting:
|
|
@@ -246,9 +244,7 @@ Before executing the command, please follow these steps:
|
|
|
246
244
|
- python /path/with spaces/script.py (incorrect - will fail)
|
|
247
245
|
- After ensuring proper quoting, execute the command
|
|
248
246
|
- Capture the output of the command
|
|
249
|
-
|
|
250
247
|
Usage notes:
|
|
251
|
-
- The command parameter is required
|
|
252
248
|
- Commands run in an isolated sandbox environment
|
|
253
249
|
- Returns combined stdout/stderr output with exit code
|
|
254
250
|
- If the output is very large, it may be truncated
|
|
@@ -293,367 +289,6 @@ Use this tool to run commands, scripts, tests, builds, and other shell operation
|
|
|
293
289
|
- execute: run a shell command in the sandbox (returns output and exit code)"""
|
|
294
290
|
|
|
295
291
|
|
|
296
|
-
def _get_backend(backend: BACKEND_TYPES, runtime: ToolRuntime) -> BackendProtocol:
|
|
297
|
-
"""Get the resolved backend instance from backend or factory.
|
|
298
|
-
|
|
299
|
-
Args:
|
|
300
|
-
backend: Backend instance or factory function.
|
|
301
|
-
runtime: The tool runtime context.
|
|
302
|
-
|
|
303
|
-
Returns:
|
|
304
|
-
Resolved backend instance.
|
|
305
|
-
"""
|
|
306
|
-
if callable(backend):
|
|
307
|
-
return backend(runtime)
|
|
308
|
-
return backend
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
def _ls_tool_generator(
|
|
312
|
-
backend: BackendProtocol | Callable[[ToolRuntime], BackendProtocol],
|
|
313
|
-
custom_description: str | None = None,
|
|
314
|
-
) -> BaseTool:
|
|
315
|
-
"""Generate the ls (list files) tool.
|
|
316
|
-
|
|
317
|
-
Args:
|
|
318
|
-
backend: Backend to use for file storage, or a factory function that takes runtime and returns a backend.
|
|
319
|
-
custom_description: Optional custom description for the tool.
|
|
320
|
-
|
|
321
|
-
Returns:
|
|
322
|
-
Configured ls tool that lists files using the backend.
|
|
323
|
-
"""
|
|
324
|
-
tool_description = custom_description or LIST_FILES_TOOL_DESCRIPTION
|
|
325
|
-
|
|
326
|
-
def sync_ls(runtime: ToolRuntime[None, FilesystemState], path: str) -> str:
|
|
327
|
-
"""Synchronous wrapper for ls tool."""
|
|
328
|
-
resolved_backend = _get_backend(backend, runtime)
|
|
329
|
-
validated_path = _validate_path(path)
|
|
330
|
-
infos = resolved_backend.ls_info(validated_path)
|
|
331
|
-
paths = [fi.get("path", "") for fi in infos]
|
|
332
|
-
result = truncate_if_too_long(paths)
|
|
333
|
-
return str(result)
|
|
334
|
-
|
|
335
|
-
async def async_ls(runtime: ToolRuntime[None, FilesystemState], path: str) -> str:
|
|
336
|
-
"""Asynchronous wrapper for ls tool."""
|
|
337
|
-
resolved_backend = _get_backend(backend, runtime)
|
|
338
|
-
validated_path = _validate_path(path)
|
|
339
|
-
infos = await resolved_backend.als_info(validated_path)
|
|
340
|
-
paths = [fi.get("path", "") for fi in infos]
|
|
341
|
-
result = truncate_if_too_long(paths)
|
|
342
|
-
return str(result)
|
|
343
|
-
|
|
344
|
-
return StructuredTool.from_function(
|
|
345
|
-
name="ls",
|
|
346
|
-
description=tool_description,
|
|
347
|
-
func=sync_ls,
|
|
348
|
-
coroutine=async_ls,
|
|
349
|
-
)
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
def _read_file_tool_generator(
|
|
353
|
-
backend: BackendProtocol | Callable[[ToolRuntime], BackendProtocol],
|
|
354
|
-
custom_description: str | None = None,
|
|
355
|
-
) -> BaseTool:
|
|
356
|
-
"""Generate the read_file tool.
|
|
357
|
-
|
|
358
|
-
Args:
|
|
359
|
-
backend: Backend to use for file storage, or a factory function that takes runtime and returns a backend.
|
|
360
|
-
custom_description: Optional custom description for the tool.
|
|
361
|
-
|
|
362
|
-
Returns:
|
|
363
|
-
Configured read_file tool that reads files using the backend.
|
|
364
|
-
"""
|
|
365
|
-
tool_description = custom_description or READ_FILE_TOOL_DESCRIPTION
|
|
366
|
-
|
|
367
|
-
def sync_read_file(
|
|
368
|
-
file_path: str,
|
|
369
|
-
runtime: ToolRuntime[None, FilesystemState],
|
|
370
|
-
offset: int = DEFAULT_READ_OFFSET,
|
|
371
|
-
limit: int = DEFAULT_READ_LIMIT,
|
|
372
|
-
) -> str:
|
|
373
|
-
"""Synchronous wrapper for read_file tool."""
|
|
374
|
-
resolved_backend = _get_backend(backend, runtime)
|
|
375
|
-
file_path = _validate_path(file_path)
|
|
376
|
-
result = resolved_backend.read(file_path, offset=offset, limit=limit)
|
|
377
|
-
|
|
378
|
-
lines = result.splitlines(keepends=True)
|
|
379
|
-
if len(lines) > limit:
|
|
380
|
-
lines = lines[:limit]
|
|
381
|
-
result = "".join(lines)
|
|
382
|
-
|
|
383
|
-
return result
|
|
384
|
-
|
|
385
|
-
async def async_read_file(
|
|
386
|
-
file_path: str,
|
|
387
|
-
runtime: ToolRuntime[None, FilesystemState],
|
|
388
|
-
offset: int = DEFAULT_READ_OFFSET,
|
|
389
|
-
limit: int = DEFAULT_READ_LIMIT,
|
|
390
|
-
) -> str:
|
|
391
|
-
"""Asynchronous wrapper for read_file tool."""
|
|
392
|
-
resolved_backend = _get_backend(backend, runtime)
|
|
393
|
-
file_path = _validate_path(file_path)
|
|
394
|
-
result = await resolved_backend.aread(file_path, offset=offset, limit=limit)
|
|
395
|
-
|
|
396
|
-
lines = result.splitlines(keepends=True)
|
|
397
|
-
if len(lines) > limit:
|
|
398
|
-
lines = lines[:limit]
|
|
399
|
-
result = "".join(lines)
|
|
400
|
-
|
|
401
|
-
return result
|
|
402
|
-
|
|
403
|
-
return StructuredTool.from_function(
|
|
404
|
-
name="read_file",
|
|
405
|
-
description=tool_description,
|
|
406
|
-
func=sync_read_file,
|
|
407
|
-
coroutine=async_read_file,
|
|
408
|
-
)
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
def _write_file_tool_generator(
|
|
412
|
-
backend: BackendProtocol | Callable[[ToolRuntime], BackendProtocol],
|
|
413
|
-
custom_description: str | None = None,
|
|
414
|
-
) -> BaseTool:
|
|
415
|
-
"""Generate the write_file tool.
|
|
416
|
-
|
|
417
|
-
Args:
|
|
418
|
-
backend: Backend to use for file storage, or a factory function that takes runtime and returns a backend.
|
|
419
|
-
custom_description: Optional custom description for the tool.
|
|
420
|
-
|
|
421
|
-
Returns:
|
|
422
|
-
Configured write_file tool that creates new files using the backend.
|
|
423
|
-
"""
|
|
424
|
-
tool_description = custom_description or WRITE_FILE_TOOL_DESCRIPTION
|
|
425
|
-
|
|
426
|
-
def sync_write_file(
|
|
427
|
-
file_path: str,
|
|
428
|
-
content: str,
|
|
429
|
-
runtime: ToolRuntime[None, FilesystemState],
|
|
430
|
-
) -> Command | str:
|
|
431
|
-
"""Synchronous wrapper for write_file tool."""
|
|
432
|
-
resolved_backend = _get_backend(backend, runtime)
|
|
433
|
-
file_path = _validate_path(file_path)
|
|
434
|
-
res: WriteResult = resolved_backend.write(file_path, content)
|
|
435
|
-
if res.error:
|
|
436
|
-
return res.error
|
|
437
|
-
# If backend returns state update, wrap into Command with ToolMessage
|
|
438
|
-
if res.files_update is not None:
|
|
439
|
-
return Command(
|
|
440
|
-
update={
|
|
441
|
-
"files": res.files_update,
|
|
442
|
-
"messages": [
|
|
443
|
-
ToolMessage(
|
|
444
|
-
content=f"Updated file {res.path}",
|
|
445
|
-
tool_call_id=runtime.tool_call_id,
|
|
446
|
-
)
|
|
447
|
-
],
|
|
448
|
-
}
|
|
449
|
-
)
|
|
450
|
-
return f"Updated file {res.path}"
|
|
451
|
-
|
|
452
|
-
async def async_write_file(
|
|
453
|
-
file_path: str,
|
|
454
|
-
content: str,
|
|
455
|
-
runtime: ToolRuntime[None, FilesystemState],
|
|
456
|
-
) -> Command | str:
|
|
457
|
-
"""Asynchronous wrapper for write_file tool."""
|
|
458
|
-
resolved_backend = _get_backend(backend, runtime)
|
|
459
|
-
file_path = _validate_path(file_path)
|
|
460
|
-
res: WriteResult = await resolved_backend.awrite(file_path, content)
|
|
461
|
-
if res.error:
|
|
462
|
-
return res.error
|
|
463
|
-
# If backend returns state update, wrap into Command with ToolMessage
|
|
464
|
-
if res.files_update is not None:
|
|
465
|
-
return Command(
|
|
466
|
-
update={
|
|
467
|
-
"files": res.files_update,
|
|
468
|
-
"messages": [
|
|
469
|
-
ToolMessage(
|
|
470
|
-
content=f"Updated file {res.path}",
|
|
471
|
-
tool_call_id=runtime.tool_call_id,
|
|
472
|
-
)
|
|
473
|
-
],
|
|
474
|
-
}
|
|
475
|
-
)
|
|
476
|
-
return f"Updated file {res.path}"
|
|
477
|
-
|
|
478
|
-
return StructuredTool.from_function(
|
|
479
|
-
name="write_file",
|
|
480
|
-
description=tool_description,
|
|
481
|
-
func=sync_write_file,
|
|
482
|
-
coroutine=async_write_file,
|
|
483
|
-
)
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
def _edit_file_tool_generator(
|
|
487
|
-
backend: BackendProtocol | Callable[[ToolRuntime], BackendProtocol],
|
|
488
|
-
custom_description: str | None = None,
|
|
489
|
-
) -> BaseTool:
|
|
490
|
-
"""Generate the edit_file tool.
|
|
491
|
-
|
|
492
|
-
Args:
|
|
493
|
-
backend: Backend to use for file storage, or a factory function that takes runtime and returns a backend.
|
|
494
|
-
custom_description: Optional custom description for the tool.
|
|
495
|
-
|
|
496
|
-
Returns:
|
|
497
|
-
Configured edit_file tool that performs string replacements in files using the backend.
|
|
498
|
-
"""
|
|
499
|
-
tool_description = custom_description or EDIT_FILE_TOOL_DESCRIPTION
|
|
500
|
-
|
|
501
|
-
def sync_edit_file(
|
|
502
|
-
file_path: str,
|
|
503
|
-
old_string: str,
|
|
504
|
-
new_string: str,
|
|
505
|
-
runtime: ToolRuntime[None, FilesystemState],
|
|
506
|
-
*,
|
|
507
|
-
replace_all: bool = False,
|
|
508
|
-
) -> Command | str:
|
|
509
|
-
"""Synchronous wrapper for edit_file tool."""
|
|
510
|
-
resolved_backend = _get_backend(backend, runtime)
|
|
511
|
-
file_path = _validate_path(file_path)
|
|
512
|
-
res: EditResult = resolved_backend.edit(file_path, old_string, new_string, replace_all=replace_all)
|
|
513
|
-
if res.error:
|
|
514
|
-
return res.error
|
|
515
|
-
if res.files_update is not None:
|
|
516
|
-
return Command(
|
|
517
|
-
update={
|
|
518
|
-
"files": res.files_update,
|
|
519
|
-
"messages": [
|
|
520
|
-
ToolMessage(
|
|
521
|
-
content=f"Successfully replaced {res.occurrences} instance(s) of the string in '{res.path}'",
|
|
522
|
-
tool_call_id=runtime.tool_call_id,
|
|
523
|
-
)
|
|
524
|
-
],
|
|
525
|
-
}
|
|
526
|
-
)
|
|
527
|
-
return f"Successfully replaced {res.occurrences} instance(s) of the string in '{res.path}'"
|
|
528
|
-
|
|
529
|
-
async def async_edit_file(
|
|
530
|
-
file_path: str,
|
|
531
|
-
old_string: str,
|
|
532
|
-
new_string: str,
|
|
533
|
-
runtime: ToolRuntime[None, FilesystemState],
|
|
534
|
-
*,
|
|
535
|
-
replace_all: bool = False,
|
|
536
|
-
) -> Command | str:
|
|
537
|
-
"""Asynchronous wrapper for edit_file tool."""
|
|
538
|
-
resolved_backend = _get_backend(backend, runtime)
|
|
539
|
-
file_path = _validate_path(file_path)
|
|
540
|
-
res: EditResult = await resolved_backend.aedit(file_path, old_string, new_string, replace_all=replace_all)
|
|
541
|
-
if res.error:
|
|
542
|
-
return res.error
|
|
543
|
-
if res.files_update is not None:
|
|
544
|
-
return Command(
|
|
545
|
-
update={
|
|
546
|
-
"files": res.files_update,
|
|
547
|
-
"messages": [
|
|
548
|
-
ToolMessage(
|
|
549
|
-
content=f"Successfully replaced {res.occurrences} instance(s) of the string in '{res.path}'",
|
|
550
|
-
tool_call_id=runtime.tool_call_id,
|
|
551
|
-
)
|
|
552
|
-
],
|
|
553
|
-
}
|
|
554
|
-
)
|
|
555
|
-
return f"Successfully replaced {res.occurrences} instance(s) of the string in '{res.path}'"
|
|
556
|
-
|
|
557
|
-
return StructuredTool.from_function(
|
|
558
|
-
name="edit_file",
|
|
559
|
-
description=tool_description,
|
|
560
|
-
func=sync_edit_file,
|
|
561
|
-
coroutine=async_edit_file,
|
|
562
|
-
)
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
def _glob_tool_generator(
|
|
566
|
-
backend: BackendProtocol | Callable[[ToolRuntime], BackendProtocol],
|
|
567
|
-
custom_description: str | None = None,
|
|
568
|
-
) -> BaseTool:
|
|
569
|
-
"""Generate the glob tool.
|
|
570
|
-
|
|
571
|
-
Args:
|
|
572
|
-
backend: Backend to use for file storage, or a factory function that takes runtime and returns a backend.
|
|
573
|
-
custom_description: Optional custom description for the tool.
|
|
574
|
-
|
|
575
|
-
Returns:
|
|
576
|
-
Configured glob tool that finds files by pattern using the backend.
|
|
577
|
-
"""
|
|
578
|
-
tool_description = custom_description or GLOB_TOOL_DESCRIPTION
|
|
579
|
-
|
|
580
|
-
def sync_glob(pattern: str, runtime: ToolRuntime[None, FilesystemState], path: str = "/") -> str:
|
|
581
|
-
"""Synchronous wrapper for glob tool."""
|
|
582
|
-
resolved_backend = _get_backend(backend, runtime)
|
|
583
|
-
infos = resolved_backend.glob_info(pattern, path=path)
|
|
584
|
-
paths = [fi.get("path", "") for fi in infos]
|
|
585
|
-
result = truncate_if_too_long(paths)
|
|
586
|
-
return str(result)
|
|
587
|
-
|
|
588
|
-
async def async_glob(pattern: str, runtime: ToolRuntime[None, FilesystemState], path: str = "/") -> str:
|
|
589
|
-
"""Asynchronous wrapper for glob tool."""
|
|
590
|
-
resolved_backend = _get_backend(backend, runtime)
|
|
591
|
-
infos = await resolved_backend.aglob_info(pattern, path=path)
|
|
592
|
-
paths = [fi.get("path", "") for fi in infos]
|
|
593
|
-
result = truncate_if_too_long(paths)
|
|
594
|
-
return str(result)
|
|
595
|
-
|
|
596
|
-
return StructuredTool.from_function(
|
|
597
|
-
name="glob",
|
|
598
|
-
description=tool_description,
|
|
599
|
-
func=sync_glob,
|
|
600
|
-
coroutine=async_glob,
|
|
601
|
-
)
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
def _grep_tool_generator(
|
|
605
|
-
backend: BackendProtocol | Callable[[ToolRuntime], BackendProtocol],
|
|
606
|
-
custom_description: str | None = None,
|
|
607
|
-
) -> BaseTool:
|
|
608
|
-
"""Generate the grep tool.
|
|
609
|
-
|
|
610
|
-
Args:
|
|
611
|
-
backend: Backend to use for file storage, or a factory function that takes runtime and returns a backend.
|
|
612
|
-
custom_description: Optional custom description for the tool.
|
|
613
|
-
|
|
614
|
-
Returns:
|
|
615
|
-
Configured grep tool that searches for patterns in files using the backend.
|
|
616
|
-
"""
|
|
617
|
-
tool_description = custom_description or GREP_TOOL_DESCRIPTION
|
|
618
|
-
|
|
619
|
-
def sync_grep(
|
|
620
|
-
pattern: str,
|
|
621
|
-
runtime: ToolRuntime[None, FilesystemState],
|
|
622
|
-
path: str | None = None,
|
|
623
|
-
glob: str | None = None,
|
|
624
|
-
output_mode: Literal["files_with_matches", "content", "count"] = "files_with_matches",
|
|
625
|
-
) -> str:
|
|
626
|
-
"""Synchronous wrapper for grep tool."""
|
|
627
|
-
resolved_backend = _get_backend(backend, runtime)
|
|
628
|
-
raw = resolved_backend.grep_raw(pattern, path=path, glob=glob)
|
|
629
|
-
if isinstance(raw, str):
|
|
630
|
-
return raw
|
|
631
|
-
formatted = format_grep_matches(raw, output_mode)
|
|
632
|
-
return truncate_if_too_long(formatted) # type: ignore[arg-type]
|
|
633
|
-
|
|
634
|
-
async def async_grep(
|
|
635
|
-
pattern: str,
|
|
636
|
-
runtime: ToolRuntime[None, FilesystemState],
|
|
637
|
-
path: str | None = None,
|
|
638
|
-
glob: str | None = None,
|
|
639
|
-
output_mode: Literal["files_with_matches", "content", "count"] = "files_with_matches",
|
|
640
|
-
) -> str:
|
|
641
|
-
"""Asynchronous wrapper for grep tool."""
|
|
642
|
-
resolved_backend = _get_backend(backend, runtime)
|
|
643
|
-
raw = await resolved_backend.agrep_raw(pattern, path=path, glob=glob)
|
|
644
|
-
if isinstance(raw, str):
|
|
645
|
-
return raw
|
|
646
|
-
formatted = format_grep_matches(raw, output_mode)
|
|
647
|
-
return truncate_if_too_long(formatted) # type: ignore[arg-type]
|
|
648
|
-
|
|
649
|
-
return StructuredTool.from_function(
|
|
650
|
-
name="grep",
|
|
651
|
-
description=tool_description,
|
|
652
|
-
func=sync_grep,
|
|
653
|
-
coroutine=async_grep,
|
|
654
|
-
)
|
|
655
|
-
|
|
656
|
-
|
|
657
292
|
def _supports_execution(backend: BackendProtocol) -> bool:
|
|
658
293
|
"""Check if a backend supports command execution.
|
|
659
294
|
|
|
@@ -666,9 +301,6 @@ def _supports_execution(backend: BackendProtocol) -> bool:
|
|
|
666
301
|
Returns:
|
|
667
302
|
True if the backend supports execution, False otherwise.
|
|
668
303
|
"""
|
|
669
|
-
# Import here to avoid circular dependency
|
|
670
|
-
from deepagents.backends.composite import CompositeBackend
|
|
671
|
-
|
|
672
304
|
# For CompositeBackend, check the default backend
|
|
673
305
|
if isinstance(backend, CompositeBackend):
|
|
674
306
|
return isinstance(backend.default, SandboxBackendProtocol)
|
|
@@ -677,145 +309,86 @@ def _supports_execution(backend: BackendProtocol) -> bool:
|
|
|
677
309
|
return isinstance(backend, SandboxBackendProtocol)
|
|
678
310
|
|
|
679
311
|
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
try:
|
|
711
|
-
result = resolved_backend.execute(command)
|
|
712
|
-
except NotImplementedError as e:
|
|
713
|
-
# Handle case where execute() exists but raises NotImplementedError
|
|
714
|
-
return f"Error: Execution not available. {e}"
|
|
715
|
-
|
|
716
|
-
# Format output for LLM consumption
|
|
717
|
-
parts = [result.output]
|
|
718
|
-
|
|
719
|
-
if result.exit_code is not None:
|
|
720
|
-
status = "succeeded" if result.exit_code == 0 else "failed"
|
|
721
|
-
parts.append(f"\n[Command {status} with exit code {result.exit_code}]")
|
|
722
|
-
|
|
723
|
-
if result.truncated:
|
|
724
|
-
parts.append("\n[Output was truncated due to size limits]")
|
|
725
|
-
|
|
726
|
-
return "".join(parts)
|
|
727
|
-
|
|
728
|
-
async def async_execute(
|
|
729
|
-
command: str,
|
|
730
|
-
runtime: ToolRuntime[None, FilesystemState],
|
|
731
|
-
) -> str:
|
|
732
|
-
"""Asynchronous wrapper for execute tool."""
|
|
733
|
-
resolved_backend = _get_backend(backend, runtime)
|
|
734
|
-
|
|
735
|
-
# Runtime check - fail gracefully if not supported
|
|
736
|
-
if not _supports_execution(resolved_backend):
|
|
737
|
-
return (
|
|
738
|
-
"Error: Execution not available. This agent's backend "
|
|
739
|
-
"does not support command execution (SandboxBackendProtocol). "
|
|
740
|
-
"To use the execute tool, provide a backend that implements SandboxBackendProtocol."
|
|
741
|
-
)
|
|
742
|
-
|
|
743
|
-
try:
|
|
744
|
-
result = await resolved_backend.aexecute(command)
|
|
745
|
-
except NotImplementedError as e:
|
|
746
|
-
# Handle case where execute() exists but raises NotImplementedError
|
|
747
|
-
return f"Error: Execution not available. {e}"
|
|
748
|
-
|
|
749
|
-
# Format output for LLM consumption
|
|
750
|
-
parts = [result.output]
|
|
751
|
-
|
|
752
|
-
if result.exit_code is not None:
|
|
753
|
-
status = "succeeded" if result.exit_code == 0 else "failed"
|
|
754
|
-
parts.append(f"\n[Command {status} with exit code {result.exit_code}]")
|
|
755
|
-
|
|
756
|
-
if result.truncated:
|
|
757
|
-
parts.append("\n[Output was truncated due to size limits]")
|
|
312
|
+
# Tools that should be excluded from the large result eviction logic.
|
|
313
|
+
#
|
|
314
|
+
# This tuple contains tools that should NOT have their results evicted to the filesystem
|
|
315
|
+
# when they exceed token limits. Tools are excluded for different reasons:
|
|
316
|
+
#
|
|
317
|
+
# 1. Tools with built-in truncation (ls, glob, grep):
|
|
318
|
+
# These tools truncate their own output when it becomes too large. When these tools
|
|
319
|
+
# produce truncated output due to many matches, it typically indicates the query
|
|
320
|
+
# needs refinement rather than full result preservation. In such cases, the truncated
|
|
321
|
+
# matches are potentially more like noise and the LLM should be prompted to narrow
|
|
322
|
+
# its search criteria instead.
|
|
323
|
+
#
|
|
324
|
+
# 2. Tools with problematic truncation behavior (read_file):
|
|
325
|
+
# read_file is tricky to handle as the failure mode here is single long lines
|
|
326
|
+
# (e.g., imagine a jsonl file with very long payloads on each line). If we try to
|
|
327
|
+
# truncate the result of read_file, the agent may then attempt to re-read the
|
|
328
|
+
# truncated file using read_file again, which won't help.
|
|
329
|
+
#
|
|
330
|
+
# 3. Tools that never exceed limits (edit_file, write_file):
|
|
331
|
+
# These tools return minimal confirmation messages and are never expected to produce
|
|
332
|
+
# output large enough to exceed token limits, so checking them would be unnecessary.
|
|
333
|
+
TOOLS_EXCLUDED_FROM_EVICTION = (
|
|
334
|
+
"ls",
|
|
335
|
+
"glob",
|
|
336
|
+
"grep",
|
|
337
|
+
"read_file",
|
|
338
|
+
"edit_file",
|
|
339
|
+
"write_file",
|
|
340
|
+
)
|
|
758
341
|
|
|
759
|
-
return "".join(parts)
|
|
760
342
|
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
coroutine=async_execute,
|
|
766
|
-
)
|
|
343
|
+
TOO_LARGE_TOOL_MSG = """Tool result too large, the result of this tool call {tool_call_id} was saved in the filesystem at this path: {file_path}
|
|
344
|
+
You can read the result from the filesystem by using the read_file tool, but make sure to only read part of the result at a time.
|
|
345
|
+
You can do this by specifying an offset and limit in the read_file tool call.
|
|
346
|
+
For example, to read the first 100 lines, you can use the read_file tool with offset=0 and limit=100.
|
|
767
347
|
|
|
348
|
+
Here is a preview showing the head and tail of the result (lines of the form
|
|
349
|
+
... [N lines truncated] ...
|
|
350
|
+
indicate omitted lines in the middle of the content):
|
|
768
351
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
"read_file": _read_file_tool_generator,
|
|
772
|
-
"write_file": _write_file_tool_generator,
|
|
773
|
-
"edit_file": _edit_file_tool_generator,
|
|
774
|
-
"glob": _glob_tool_generator,
|
|
775
|
-
"grep": _grep_tool_generator,
|
|
776
|
-
"execute": _execute_tool_generator,
|
|
777
|
-
}
|
|
352
|
+
{content_sample}
|
|
353
|
+
"""
|
|
778
354
|
|
|
779
355
|
|
|
780
|
-
def
|
|
781
|
-
|
|
782
|
-
custom_tool_descriptions: dict[str, str] | None = None,
|
|
783
|
-
) -> list[BaseTool]:
|
|
784
|
-
"""Get filesystem and execution tools.
|
|
356
|
+
def _create_content_preview(content_str: str, *, head_lines: int = 5, tail_lines: int = 5) -> str:
|
|
357
|
+
"""Create a preview of content showing head and tail with truncation marker.
|
|
785
358
|
|
|
786
359
|
Args:
|
|
787
|
-
|
|
788
|
-
|
|
360
|
+
content_str: The full content string to preview.
|
|
361
|
+
head_lines: Number of lines to show from the start.
|
|
362
|
+
tail_lines: Number of lines to show from the end.
|
|
789
363
|
|
|
790
364
|
Returns:
|
|
791
|
-
|
|
365
|
+
Formatted preview string with line numbers.
|
|
792
366
|
"""
|
|
793
|
-
|
|
794
|
-
custom_tool_descriptions = {}
|
|
795
|
-
tools = []
|
|
367
|
+
lines = content_str.splitlines()
|
|
796
368
|
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
369
|
+
if len(lines) <= head_lines + tail_lines:
|
|
370
|
+
# If file is small enough, show all lines
|
|
371
|
+
preview_lines = [line[:1000] for line in lines]
|
|
372
|
+
return format_content_with_line_numbers(preview_lines, start_line=1)
|
|
801
373
|
|
|
374
|
+
# Show head and tail with truncation marker
|
|
375
|
+
head = [line[:1000] for line in lines[:head_lines]]
|
|
376
|
+
tail = [line[:1000] for line in lines[-tail_lines:]]
|
|
802
377
|
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
For example, to read the first 100 lines, you can use the read_file tool with offset=0 and limit=100.
|
|
378
|
+
head_sample = format_content_with_line_numbers(head, start_line=1)
|
|
379
|
+
truncation_notice = f"\n... [{len(lines) - head_lines - tail_lines} lines truncated] ...\n"
|
|
380
|
+
tail_sample = format_content_with_line_numbers(tail, start_line=len(lines) - tail_lines + 1)
|
|
807
381
|
|
|
808
|
-
|
|
809
|
-
{content_sample}
|
|
810
|
-
"""
|
|
382
|
+
return head_sample + truncation_notice + tail_sample
|
|
811
383
|
|
|
812
384
|
|
|
813
385
|
class FilesystemMiddleware(AgentMiddleware):
|
|
814
386
|
"""Middleware for providing filesystem and optional execution tools to an agent.
|
|
815
387
|
|
|
816
388
|
This middleware adds filesystem tools to the agent: `ls`, `read_file`, `write_file`,
|
|
817
|
-
`edit_file`, `glob`, and `grep`.
|
|
818
|
-
|
|
389
|
+
`edit_file`, `glob`, and `grep`.
|
|
390
|
+
|
|
391
|
+
Files can be stored using any backend that implements the `BackendProtocol`.
|
|
819
392
|
|
|
820
393
|
If the backend implements `SandboxBackendProtocol`, an `execute` tool is also added
|
|
821
394
|
for running shell commands.
|
|
@@ -836,8 +409,6 @@ class FilesystemMiddleware(AgentMiddleware):
|
|
|
836
409
|
tool_token_limit_before_evict: Token limit before evicting a tool result to the
|
|
837
410
|
filesystem.
|
|
838
411
|
|
|
839
|
-
Defaults to 20,000 tokens.
|
|
840
|
-
|
|
841
412
|
When exceeded, writes the result using the configured backend and replaces it
|
|
842
413
|
with a truncated preview and file reference.
|
|
843
414
|
|
|
@@ -881,15 +452,23 @@ class FilesystemMiddleware(AgentMiddleware):
|
|
|
881
452
|
custom_tool_descriptions: Optional custom tool descriptions override.
|
|
882
453
|
tool_token_limit_before_evict: Optional token limit before evicting a tool result to the filesystem.
|
|
883
454
|
"""
|
|
884
|
-
self.tool_token_limit_before_evict = tool_token_limit_before_evict
|
|
885
|
-
|
|
886
455
|
# Use provided backend or default to StateBackend factory
|
|
887
456
|
self.backend = backend if backend is not None else (lambda rt: StateBackend(rt))
|
|
888
457
|
|
|
889
|
-
#
|
|
458
|
+
# Store configuration (private - internal implementation details)
|
|
890
459
|
self._custom_system_prompt = system_prompt
|
|
891
|
-
|
|
892
|
-
self.
|
|
460
|
+
self._custom_tool_descriptions = custom_tool_descriptions or {}
|
|
461
|
+
self._tool_token_limit_before_evict = tool_token_limit_before_evict
|
|
462
|
+
|
|
463
|
+
self.tools = [
|
|
464
|
+
self._create_ls_tool(),
|
|
465
|
+
self._create_read_file_tool(),
|
|
466
|
+
self._create_write_file_tool(),
|
|
467
|
+
self._create_edit_file_tool(),
|
|
468
|
+
self._create_glob_tool(),
|
|
469
|
+
self._create_grep_tool(),
|
|
470
|
+
self._create_execute_tool(),
|
|
471
|
+
]
|
|
893
472
|
|
|
894
473
|
def _get_backend(self, runtime: ToolRuntime) -> BackendProtocol:
|
|
895
474
|
"""Get the resolved backend instance from backend or factory.
|
|
@@ -904,6 +483,394 @@ class FilesystemMiddleware(AgentMiddleware):
|
|
|
904
483
|
return self.backend(runtime)
|
|
905
484
|
return self.backend
|
|
906
485
|
|
|
486
|
+
def _create_ls_tool(self) -> BaseTool:
|
|
487
|
+
"""Create the ls (list files) tool."""
|
|
488
|
+
tool_description = self._custom_tool_descriptions.get("ls") or LIST_FILES_TOOL_DESCRIPTION
|
|
489
|
+
|
|
490
|
+
def sync_ls(
|
|
491
|
+
runtime: ToolRuntime[None, FilesystemState],
|
|
492
|
+
path: Annotated[str, "Absolute path to the directory to list. Must be absolute, not relative."],
|
|
493
|
+
) -> str:
|
|
494
|
+
"""Synchronous wrapper for ls tool."""
|
|
495
|
+
resolved_backend = self._get_backend(runtime)
|
|
496
|
+
validated_path = _validate_path(path)
|
|
497
|
+
infos = resolved_backend.ls_info(validated_path)
|
|
498
|
+
paths = [fi.get("path", "") for fi in infos]
|
|
499
|
+
result = truncate_if_too_long(paths)
|
|
500
|
+
return str(result)
|
|
501
|
+
|
|
502
|
+
async def async_ls(
|
|
503
|
+
runtime: ToolRuntime[None, FilesystemState],
|
|
504
|
+
path: Annotated[str, "Absolute path to the directory to list. Must be absolute, not relative."],
|
|
505
|
+
) -> str:
|
|
506
|
+
"""Asynchronous wrapper for ls tool."""
|
|
507
|
+
resolved_backend = self._get_backend(runtime)
|
|
508
|
+
validated_path = _validate_path(path)
|
|
509
|
+
infos = await resolved_backend.als_info(validated_path)
|
|
510
|
+
paths = [fi.get("path", "") for fi in infos]
|
|
511
|
+
result = truncate_if_too_long(paths)
|
|
512
|
+
return str(result)
|
|
513
|
+
|
|
514
|
+
return StructuredTool.from_function(
|
|
515
|
+
name="ls",
|
|
516
|
+
description=tool_description,
|
|
517
|
+
func=sync_ls,
|
|
518
|
+
coroutine=async_ls,
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
def _create_read_file_tool(self) -> BaseTool:
|
|
522
|
+
"""Create the read_file tool."""
|
|
523
|
+
tool_description = self._custom_tool_descriptions.get("read_file") or READ_FILE_TOOL_DESCRIPTION
|
|
524
|
+
token_limit = self._tool_token_limit_before_evict
|
|
525
|
+
|
|
526
|
+
def sync_read_file(
|
|
527
|
+
file_path: Annotated[str, "Absolute path to the file to read. Must be absolute, not relative."],
|
|
528
|
+
runtime: ToolRuntime[None, FilesystemState],
|
|
529
|
+
offset: Annotated[int, "Line number to start reading from (0-indexed). Use for pagination of large files."] = DEFAULT_READ_OFFSET,
|
|
530
|
+
limit: Annotated[int, "Maximum number of lines to read. Use for pagination of large files."] = DEFAULT_READ_LIMIT,
|
|
531
|
+
) -> str:
|
|
532
|
+
"""Synchronous wrapper for read_file tool."""
|
|
533
|
+
resolved_backend = self._get_backend(runtime)
|
|
534
|
+
validated_path = _validate_path(file_path)
|
|
535
|
+
result = resolved_backend.read(validated_path, offset=offset, limit=limit)
|
|
536
|
+
|
|
537
|
+
lines = result.splitlines(keepends=True)
|
|
538
|
+
if len(lines) > limit:
|
|
539
|
+
lines = lines[:limit]
|
|
540
|
+
result = "".join(lines)
|
|
541
|
+
|
|
542
|
+
# Check if result exceeds token threshold and truncate if necessary
|
|
543
|
+
if token_limit and len(result) >= NUM_CHARS_PER_TOKEN * token_limit:
|
|
544
|
+
# Calculate truncation message length to ensure final result stays under threshold
|
|
545
|
+
truncation_msg = READ_FILE_TRUNCATION_MSG.format(file_path=validated_path)
|
|
546
|
+
max_content_length = NUM_CHARS_PER_TOKEN * token_limit - len(truncation_msg)
|
|
547
|
+
result = result[:max_content_length]
|
|
548
|
+
result += truncation_msg
|
|
549
|
+
|
|
550
|
+
return result
|
|
551
|
+
|
|
552
|
+
async def async_read_file(
|
|
553
|
+
file_path: Annotated[str, "Absolute path to the file to read. Must be absolute, not relative."],
|
|
554
|
+
runtime: ToolRuntime[None, FilesystemState],
|
|
555
|
+
offset: Annotated[int, "Line number to start reading from (0-indexed). Use for pagination of large files."] = DEFAULT_READ_OFFSET,
|
|
556
|
+
limit: Annotated[int, "Maximum number of lines to read. Use for pagination of large files."] = DEFAULT_READ_LIMIT,
|
|
557
|
+
) -> str:
|
|
558
|
+
"""Asynchronous wrapper for read_file tool."""
|
|
559
|
+
resolved_backend = self._get_backend(runtime)
|
|
560
|
+
validated_path = _validate_path(file_path)
|
|
561
|
+
result = await resolved_backend.aread(validated_path, offset=offset, limit=limit)
|
|
562
|
+
|
|
563
|
+
lines = result.splitlines(keepends=True)
|
|
564
|
+
if len(lines) > limit:
|
|
565
|
+
lines = lines[:limit]
|
|
566
|
+
result = "".join(lines)
|
|
567
|
+
|
|
568
|
+
# Check if result exceeds token threshold and truncate if necessary
|
|
569
|
+
if token_limit and len(result) >= NUM_CHARS_PER_TOKEN * token_limit:
|
|
570
|
+
# Calculate truncation message length to ensure final result stays under threshold
|
|
571
|
+
truncation_msg = READ_FILE_TRUNCATION_MSG.format(file_path=validated_path)
|
|
572
|
+
max_content_length = NUM_CHARS_PER_TOKEN * token_limit - len(truncation_msg)
|
|
573
|
+
result = result[:max_content_length]
|
|
574
|
+
result += truncation_msg
|
|
575
|
+
|
|
576
|
+
return result
|
|
577
|
+
|
|
578
|
+
return StructuredTool.from_function(
|
|
579
|
+
name="read_file",
|
|
580
|
+
description=tool_description,
|
|
581
|
+
func=sync_read_file,
|
|
582
|
+
coroutine=async_read_file,
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
def _create_write_file_tool(self) -> BaseTool:
|
|
586
|
+
"""Create the write_file tool."""
|
|
587
|
+
tool_description = self._custom_tool_descriptions.get("write_file") or WRITE_FILE_TOOL_DESCRIPTION
|
|
588
|
+
|
|
589
|
+
def sync_write_file(
|
|
590
|
+
file_path: Annotated[str, "Absolute path where the file should be created. Must be absolute, not relative."],
|
|
591
|
+
content: Annotated[str, "The text content to write to the file. This parameter is required."],
|
|
592
|
+
runtime: ToolRuntime[None, FilesystemState],
|
|
593
|
+
) -> Command | str:
|
|
594
|
+
"""Synchronous wrapper for write_file tool."""
|
|
595
|
+
resolved_backend = self._get_backend(runtime)
|
|
596
|
+
validated_path = _validate_path(file_path)
|
|
597
|
+
res: WriteResult = resolved_backend.write(validated_path, content)
|
|
598
|
+
if res.error:
|
|
599
|
+
return res.error
|
|
600
|
+
# If backend returns state update, wrap into Command with ToolMessage
|
|
601
|
+
if res.files_update is not None:
|
|
602
|
+
return Command(
|
|
603
|
+
update={
|
|
604
|
+
"files": res.files_update,
|
|
605
|
+
"messages": [
|
|
606
|
+
ToolMessage(
|
|
607
|
+
content=f"Updated file {res.path}",
|
|
608
|
+
tool_call_id=runtime.tool_call_id,
|
|
609
|
+
)
|
|
610
|
+
],
|
|
611
|
+
}
|
|
612
|
+
)
|
|
613
|
+
return f"Updated file {res.path}"
|
|
614
|
+
|
|
615
|
+
async def async_write_file(
|
|
616
|
+
file_path: Annotated[str, "Absolute path where the file should be created. Must be absolute, not relative."],
|
|
617
|
+
content: Annotated[str, "The text content to write to the file. This parameter is required."],
|
|
618
|
+
runtime: ToolRuntime[None, FilesystemState],
|
|
619
|
+
) -> Command | str:
|
|
620
|
+
"""Asynchronous wrapper for write_file tool."""
|
|
621
|
+
resolved_backend = self._get_backend(runtime)
|
|
622
|
+
validated_path = _validate_path(file_path)
|
|
623
|
+
res: WriteResult = await resolved_backend.awrite(validated_path, content)
|
|
624
|
+
if res.error:
|
|
625
|
+
return res.error
|
|
626
|
+
# If backend returns state update, wrap into Command with ToolMessage
|
|
627
|
+
if res.files_update is not None:
|
|
628
|
+
return Command(
|
|
629
|
+
update={
|
|
630
|
+
"files": res.files_update,
|
|
631
|
+
"messages": [
|
|
632
|
+
ToolMessage(
|
|
633
|
+
content=f"Updated file {res.path}",
|
|
634
|
+
tool_call_id=runtime.tool_call_id,
|
|
635
|
+
)
|
|
636
|
+
],
|
|
637
|
+
}
|
|
638
|
+
)
|
|
639
|
+
return f"Updated file {res.path}"
|
|
640
|
+
|
|
641
|
+
return StructuredTool.from_function(
|
|
642
|
+
name="write_file",
|
|
643
|
+
description=tool_description,
|
|
644
|
+
func=sync_write_file,
|
|
645
|
+
coroutine=async_write_file,
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
def _create_edit_file_tool(self) -> BaseTool:
|
|
649
|
+
"""Create the edit_file tool."""
|
|
650
|
+
tool_description = self._custom_tool_descriptions.get("edit_file") or EDIT_FILE_TOOL_DESCRIPTION
|
|
651
|
+
|
|
652
|
+
def sync_edit_file(
|
|
653
|
+
file_path: Annotated[str, "Absolute path to the file to edit. Must be absolute, not relative."],
|
|
654
|
+
old_string: Annotated[str, "The exact text to find and replace. Must be unique in the file unless replace_all is True."],
|
|
655
|
+
new_string: Annotated[str, "The text to replace old_string with. Must be different from old_string."],
|
|
656
|
+
runtime: ToolRuntime[None, FilesystemState],
|
|
657
|
+
*,
|
|
658
|
+
replace_all: Annotated[bool, "If True, replace all occurrences of old_string. If False (default), old_string must be unique."] = False,
|
|
659
|
+
) -> Command | str:
|
|
660
|
+
"""Synchronous wrapper for edit_file tool."""
|
|
661
|
+
resolved_backend = self._get_backend(runtime)
|
|
662
|
+
validated_path = _validate_path(file_path)
|
|
663
|
+
res: EditResult = resolved_backend.edit(validated_path, old_string, new_string, replace_all=replace_all)
|
|
664
|
+
if res.error:
|
|
665
|
+
return res.error
|
|
666
|
+
if res.files_update is not None:
|
|
667
|
+
return Command(
|
|
668
|
+
update={
|
|
669
|
+
"files": res.files_update,
|
|
670
|
+
"messages": [
|
|
671
|
+
ToolMessage(
|
|
672
|
+
content=f"Successfully replaced {res.occurrences} instance(s) of the string in '{res.path}'",
|
|
673
|
+
tool_call_id=runtime.tool_call_id,
|
|
674
|
+
)
|
|
675
|
+
],
|
|
676
|
+
}
|
|
677
|
+
)
|
|
678
|
+
return f"Successfully replaced {res.occurrences} instance(s) of the string in '{res.path}'"
|
|
679
|
+
|
|
680
|
+
async def async_edit_file(
|
|
681
|
+
file_path: Annotated[str, "Absolute path to the file to edit. Must be absolute, not relative."],
|
|
682
|
+
old_string: Annotated[str, "The exact text to find and replace. Must be unique in the file unless replace_all is True."],
|
|
683
|
+
new_string: Annotated[str, "The text to replace old_string with. Must be different from old_string."],
|
|
684
|
+
runtime: ToolRuntime[None, FilesystemState],
|
|
685
|
+
*,
|
|
686
|
+
replace_all: Annotated[bool, "If True, replace all occurrences of old_string. If False (default), old_string must be unique."] = False,
|
|
687
|
+
) -> Command | str:
|
|
688
|
+
"""Asynchronous wrapper for edit_file tool."""
|
|
689
|
+
resolved_backend = self._get_backend(runtime)
|
|
690
|
+
validated_path = _validate_path(file_path)
|
|
691
|
+
res: EditResult = await resolved_backend.aedit(validated_path, old_string, new_string, replace_all=replace_all)
|
|
692
|
+
if res.error:
|
|
693
|
+
return res.error
|
|
694
|
+
if res.files_update is not None:
|
|
695
|
+
return Command(
|
|
696
|
+
update={
|
|
697
|
+
"files": res.files_update,
|
|
698
|
+
"messages": [
|
|
699
|
+
ToolMessage(
|
|
700
|
+
content=f"Successfully replaced {res.occurrences} instance(s) of the string in '{res.path}'",
|
|
701
|
+
tool_call_id=runtime.tool_call_id,
|
|
702
|
+
)
|
|
703
|
+
],
|
|
704
|
+
}
|
|
705
|
+
)
|
|
706
|
+
return f"Successfully replaced {res.occurrences} instance(s) of the string in '{res.path}'"
|
|
707
|
+
|
|
708
|
+
return StructuredTool.from_function(
|
|
709
|
+
name="edit_file",
|
|
710
|
+
description=tool_description,
|
|
711
|
+
func=sync_edit_file,
|
|
712
|
+
coroutine=async_edit_file,
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
def _create_glob_tool(self) -> BaseTool:
|
|
716
|
+
"""Create the glob tool."""
|
|
717
|
+
tool_description = self._custom_tool_descriptions.get("glob") or GLOB_TOOL_DESCRIPTION
|
|
718
|
+
|
|
719
|
+
def sync_glob(
|
|
720
|
+
pattern: Annotated[str, "Glob pattern to match files (e.g., '**/*.py', '*.txt', '/subdir/**/*.md')."],
|
|
721
|
+
runtime: ToolRuntime[None, FilesystemState],
|
|
722
|
+
path: Annotated[str, "Base directory to search from. Defaults to root '/'."] = "/",
|
|
723
|
+
) -> str:
|
|
724
|
+
"""Synchronous wrapper for glob tool."""
|
|
725
|
+
resolved_backend = self._get_backend(runtime)
|
|
726
|
+
infos = resolved_backend.glob_info(pattern, path=path)
|
|
727
|
+
paths = [fi.get("path", "") for fi in infos]
|
|
728
|
+
result = truncate_if_too_long(paths)
|
|
729
|
+
return str(result)
|
|
730
|
+
|
|
731
|
+
async def async_glob(
|
|
732
|
+
pattern: Annotated[str, "Glob pattern to match files (e.g., '**/*.py', '*.txt', '/subdir/**/*.md')."],
|
|
733
|
+
runtime: ToolRuntime[None, FilesystemState],
|
|
734
|
+
path: Annotated[str, "Base directory to search from. Defaults to root '/'."] = "/",
|
|
735
|
+
) -> str:
|
|
736
|
+
"""Asynchronous wrapper for glob tool."""
|
|
737
|
+
resolved_backend = self._get_backend(runtime)
|
|
738
|
+
infos = await resolved_backend.aglob_info(pattern, path=path)
|
|
739
|
+
paths = [fi.get("path", "") for fi in infos]
|
|
740
|
+
result = truncate_if_too_long(paths)
|
|
741
|
+
return str(result)
|
|
742
|
+
|
|
743
|
+
return StructuredTool.from_function(
|
|
744
|
+
name="glob",
|
|
745
|
+
description=tool_description,
|
|
746
|
+
func=sync_glob,
|
|
747
|
+
coroutine=async_glob,
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
def _create_grep_tool(self) -> BaseTool:
|
|
751
|
+
"""Create the grep tool."""
|
|
752
|
+
tool_description = self._custom_tool_descriptions.get("grep") or GREP_TOOL_DESCRIPTION
|
|
753
|
+
|
|
754
|
+
def sync_grep(
|
|
755
|
+
pattern: Annotated[str, "Text pattern to search for (literal string, not regex)."],
|
|
756
|
+
runtime: ToolRuntime[None, FilesystemState],
|
|
757
|
+
path: Annotated[str | None, "Directory to search in. Defaults to current working directory."] = None,
|
|
758
|
+
glob: Annotated[str | None, "Glob pattern to filter which files to search (e.g., '*.py')."] = None,
|
|
759
|
+
output_mode: Annotated[
|
|
760
|
+
Literal["files_with_matches", "content", "count"],
|
|
761
|
+
"Output format: 'files_with_matches' (file paths only, default), 'content' (matching lines with context), 'count' (match counts per file).",
|
|
762
|
+
] = "files_with_matches",
|
|
763
|
+
) -> str:
|
|
764
|
+
"""Synchronous wrapper for grep tool."""
|
|
765
|
+
resolved_backend = self._get_backend(runtime)
|
|
766
|
+
raw = resolved_backend.grep_raw(pattern, path=path, glob=glob)
|
|
767
|
+
if isinstance(raw, str):
|
|
768
|
+
return raw
|
|
769
|
+
formatted = format_grep_matches(raw, output_mode)
|
|
770
|
+
return truncate_if_too_long(formatted) # type: ignore[arg-type]
|
|
771
|
+
|
|
772
|
+
async def async_grep(
|
|
773
|
+
pattern: Annotated[str, "Text pattern to search for (literal string, not regex)."],
|
|
774
|
+
runtime: ToolRuntime[None, FilesystemState],
|
|
775
|
+
path: Annotated[str | None, "Directory to search in. Defaults to current working directory."] = None,
|
|
776
|
+
glob: Annotated[str | None, "Glob pattern to filter which files to search (e.g., '*.py')."] = None,
|
|
777
|
+
output_mode: Annotated[
|
|
778
|
+
Literal["files_with_matches", "content", "count"],
|
|
779
|
+
"Output format: 'files_with_matches' (file paths only, default), 'content' (matching lines with context), 'count' (match counts per file).",
|
|
780
|
+
] = "files_with_matches",
|
|
781
|
+
) -> str:
|
|
782
|
+
"""Asynchronous wrapper for grep tool."""
|
|
783
|
+
resolved_backend = self._get_backend(runtime)
|
|
784
|
+
raw = await resolved_backend.agrep_raw(pattern, path=path, glob=glob)
|
|
785
|
+
if isinstance(raw, str):
|
|
786
|
+
return raw
|
|
787
|
+
formatted = format_grep_matches(raw, output_mode)
|
|
788
|
+
return truncate_if_too_long(formatted) # type: ignore[arg-type]
|
|
789
|
+
|
|
790
|
+
return StructuredTool.from_function(
|
|
791
|
+
name="grep",
|
|
792
|
+
description=tool_description,
|
|
793
|
+
func=sync_grep,
|
|
794
|
+
coroutine=async_grep,
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
def _create_execute_tool(self) -> BaseTool:
|
|
798
|
+
"""Create the execute tool for sandbox command execution."""
|
|
799
|
+
tool_description = self._custom_tool_descriptions.get("execute") or EXECUTE_TOOL_DESCRIPTION
|
|
800
|
+
|
|
801
|
+
def sync_execute(
|
|
802
|
+
command: Annotated[str, "Shell command to execute in the sandbox environment."],
|
|
803
|
+
runtime: ToolRuntime[None, FilesystemState],
|
|
804
|
+
) -> str:
|
|
805
|
+
"""Synchronous wrapper for execute tool."""
|
|
806
|
+
resolved_backend = self._get_backend(runtime)
|
|
807
|
+
|
|
808
|
+
# Runtime check - fail gracefully if not supported
|
|
809
|
+
if not _supports_execution(resolved_backend):
|
|
810
|
+
return (
|
|
811
|
+
"Error: Execution not available. This agent's backend "
|
|
812
|
+
"does not support command execution (SandboxBackendProtocol). "
|
|
813
|
+
"To use the execute tool, provide a backend that implements SandboxBackendProtocol."
|
|
814
|
+
)
|
|
815
|
+
|
|
816
|
+
try:
|
|
817
|
+
result = resolved_backend.execute(command)
|
|
818
|
+
except NotImplementedError as e:
|
|
819
|
+
# Handle case where execute() exists but raises NotImplementedError
|
|
820
|
+
return f"Error: Execution not available. {e}"
|
|
821
|
+
|
|
822
|
+
# Format output for LLM consumption
|
|
823
|
+
parts = [result.output]
|
|
824
|
+
|
|
825
|
+
if result.exit_code is not None:
|
|
826
|
+
status = "succeeded" if result.exit_code == 0 else "failed"
|
|
827
|
+
parts.append(f"\n[Command {status} with exit code {result.exit_code}]")
|
|
828
|
+
|
|
829
|
+
if result.truncated:
|
|
830
|
+
parts.append("\n[Output was truncated due to size limits]")
|
|
831
|
+
|
|
832
|
+
return "".join(parts)
|
|
833
|
+
|
|
834
|
+
async def async_execute(
|
|
835
|
+
command: Annotated[str, "Shell command to execute in the sandbox environment."],
|
|
836
|
+
runtime: ToolRuntime[None, FilesystemState],
|
|
837
|
+
) -> str:
|
|
838
|
+
"""Asynchronous wrapper for execute tool."""
|
|
839
|
+
resolved_backend = self._get_backend(runtime)
|
|
840
|
+
|
|
841
|
+
# Runtime check - fail gracefully if not supported
|
|
842
|
+
if not _supports_execution(resolved_backend):
|
|
843
|
+
return (
|
|
844
|
+
"Error: Execution not available. This agent's backend "
|
|
845
|
+
"does not support command execution (SandboxBackendProtocol). "
|
|
846
|
+
"To use the execute tool, provide a backend that implements SandboxBackendProtocol."
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
try:
|
|
850
|
+
result = await resolved_backend.aexecute(command)
|
|
851
|
+
except NotImplementedError as e:
|
|
852
|
+
# Handle case where execute() exists but raises NotImplementedError
|
|
853
|
+
return f"Error: Execution not available. {e}"
|
|
854
|
+
|
|
855
|
+
# Format output for LLM consumption
|
|
856
|
+
parts = [result.output]
|
|
857
|
+
|
|
858
|
+
if result.exit_code is not None:
|
|
859
|
+
status = "succeeded" if result.exit_code == 0 else "failed"
|
|
860
|
+
parts.append(f"\n[Command {status} with exit code {result.exit_code}]")
|
|
861
|
+
|
|
862
|
+
if result.truncated:
|
|
863
|
+
parts.append("\n[Output was truncated due to size limits]")
|
|
864
|
+
|
|
865
|
+
return "".join(parts)
|
|
866
|
+
|
|
867
|
+
return StructuredTool.from_function(
|
|
868
|
+
name="execute",
|
|
869
|
+
description=tool_description,
|
|
870
|
+
func=sync_execute,
|
|
871
|
+
coroutine=async_execute,
|
|
872
|
+
)
|
|
873
|
+
|
|
907
874
|
def wrap_model_call(
|
|
908
875
|
self,
|
|
909
876
|
request: ModelRequest,
|
|
@@ -1026,7 +993,7 @@ class FilesystemMiddleware(AgentMiddleware):
|
|
|
1026
993
|
The model can recover by reading the offloaded file from the backend.
|
|
1027
994
|
"""
|
|
1028
995
|
# Early exit if eviction not configured
|
|
1029
|
-
if not self.
|
|
996
|
+
if not self._tool_token_limit_before_evict:
|
|
1030
997
|
return message, None
|
|
1031
998
|
|
|
1032
999
|
# Convert content to string once for both size check and eviction
|
|
@@ -1046,9 +1013,7 @@ class FilesystemMiddleware(AgentMiddleware):
|
|
|
1046
1013
|
content_str = str(message.content)
|
|
1047
1014
|
|
|
1048
1015
|
# Check if content exceeds eviction threshold
|
|
1049
|
-
|
|
1050
|
-
# This errs on the high side to avoid premature eviction of content that might fit
|
|
1051
|
-
if len(content_str) <= 4 * self.tool_token_limit_before_evict:
|
|
1016
|
+
if len(content_str) <= NUM_CHARS_PER_TOKEN * self._tool_token_limit_before_evict:
|
|
1052
1017
|
return message, None
|
|
1053
1018
|
|
|
1054
1019
|
# Write content to filesystem
|
|
@@ -1058,8 +1023,8 @@ class FilesystemMiddleware(AgentMiddleware):
|
|
|
1058
1023
|
if result.error:
|
|
1059
1024
|
return message, None
|
|
1060
1025
|
|
|
1061
|
-
# Create
|
|
1062
|
-
content_sample =
|
|
1026
|
+
# Create preview showing head and tail of the result
|
|
1027
|
+
content_sample = _create_content_preview(content_str)
|
|
1063
1028
|
replacement_text = TOO_LARGE_TOOL_MSG.format(
|
|
1064
1029
|
tool_call_id=message.tool_call_id,
|
|
1065
1030
|
file_path=file_path,
|
|
@@ -1070,6 +1035,7 @@ class FilesystemMiddleware(AgentMiddleware):
|
|
|
1070
1035
|
processed_message = ToolMessage(
|
|
1071
1036
|
content=replacement_text,
|
|
1072
1037
|
tool_call_id=message.tool_call_id,
|
|
1038
|
+
name=message.name,
|
|
1073
1039
|
)
|
|
1074
1040
|
return processed_message, result.files_update
|
|
1075
1041
|
|
|
@@ -1084,7 +1050,7 @@ class FilesystemMiddleware(AgentMiddleware):
|
|
|
1084
1050
|
See _process_large_message for full documentation.
|
|
1085
1051
|
"""
|
|
1086
1052
|
# Early exit if eviction not configured
|
|
1087
|
-
if not self.
|
|
1053
|
+
if not self._tool_token_limit_before_evict:
|
|
1088
1054
|
return message, None
|
|
1089
1055
|
|
|
1090
1056
|
# Convert content to string once for both size check and eviction
|
|
@@ -1103,10 +1069,7 @@ class FilesystemMiddleware(AgentMiddleware):
|
|
|
1103
1069
|
# Multiple blocks or non-text content - stringify entire structure
|
|
1104
1070
|
content_str = str(message.content)
|
|
1105
1071
|
|
|
1106
|
-
|
|
1107
|
-
# Using 4 chars per token as a conservative approximation (actual ratio varies by content)
|
|
1108
|
-
# This errs on the high side to avoid premature eviction of content that might fit
|
|
1109
|
-
if len(content_str) <= 4 * self.tool_token_limit_before_evict:
|
|
1072
|
+
if len(content_str) <= NUM_CHARS_PER_TOKEN * self._tool_token_limit_before_evict:
|
|
1110
1073
|
return message, None
|
|
1111
1074
|
|
|
1112
1075
|
# Write content to filesystem using async method
|
|
@@ -1116,8 +1079,8 @@ class FilesystemMiddleware(AgentMiddleware):
|
|
|
1116
1079
|
if result.error:
|
|
1117
1080
|
return message, None
|
|
1118
1081
|
|
|
1119
|
-
# Create
|
|
1120
|
-
content_sample =
|
|
1082
|
+
# Create preview showing head and tail of the result
|
|
1083
|
+
content_sample = _create_content_preview(content_str)
|
|
1121
1084
|
replacement_text = TOO_LARGE_TOOL_MSG.format(
|
|
1122
1085
|
tool_call_id=message.tool_call_id,
|
|
1123
1086
|
file_path=file_path,
|
|
@@ -1128,6 +1091,7 @@ class FilesystemMiddleware(AgentMiddleware):
|
|
|
1128
1091
|
processed_message = ToolMessage(
|
|
1129
1092
|
content=replacement_text,
|
|
1130
1093
|
tool_call_id=message.tool_call_id,
|
|
1094
|
+
name=message.name,
|
|
1131
1095
|
)
|
|
1132
1096
|
return processed_message, result.files_update
|
|
1133
1097
|
|
|
@@ -1247,7 +1211,7 @@ class FilesystemMiddleware(AgentMiddleware):
|
|
|
1247
1211
|
Returns:
|
|
1248
1212
|
The raw ToolMessage, or a pseudo tool message with the ToolResult in state.
|
|
1249
1213
|
"""
|
|
1250
|
-
if self.
|
|
1214
|
+
if self._tool_token_limit_before_evict is None or request.tool_call["name"] in TOOLS_EXCLUDED_FROM_EVICTION:
|
|
1251
1215
|
return handler(request)
|
|
1252
1216
|
|
|
1253
1217
|
tool_result = handler(request)
|
|
@@ -1267,7 +1231,7 @@ class FilesystemMiddleware(AgentMiddleware):
|
|
|
1267
1231
|
Returns:
|
|
1268
1232
|
The raw ToolMessage, or a pseudo tool message with the ToolResult in state.
|
|
1269
1233
|
"""
|
|
1270
|
-
if self.
|
|
1234
|
+
if self._tool_token_limit_before_evict is None or request.tool_call["name"] in TOOLS_EXCLUDED_FROM_EVICTION:
|
|
1271
1235
|
return await handler(request)
|
|
1272
1236
|
|
|
1273
1237
|
tool_result = await handler(request)
|