deepagents 0.3.8__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.
@@ -41,6 +41,21 @@ LINE_NUMBER_WIDTH = 6
41
41
  DEFAULT_READ_OFFSET = 0
42
42
  DEFAULT_READ_LIMIT = 100
43
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
+
44
59
 
45
60
  class FileData(TypedDict):
46
61
  """Data structure for storing file contents with metadata."""
@@ -274,387 +289,6 @@ Use this tool to run commands, scripts, tests, builds, and other shell operation
274
289
  - execute: run a shell command in the sandbox (returns output and exit code)"""
275
290
 
276
291
 
277
- def _get_backend(backend: BACKEND_TYPES, runtime: ToolRuntime) -> BackendProtocol:
278
- """Get the resolved backend instance from backend or factory.
279
-
280
- Args:
281
- backend: Backend instance or factory function.
282
- runtime: The tool runtime context.
283
-
284
- Returns:
285
- Resolved backend instance.
286
- """
287
- if callable(backend):
288
- return backend(runtime)
289
- return backend
290
-
291
-
292
- def _ls_tool_generator(
293
- backend: BackendProtocol | Callable[[ToolRuntime], BackendProtocol],
294
- custom_description: str | None = None,
295
- ) -> BaseTool:
296
- """Generate the ls (list files) tool.
297
-
298
- Args:
299
- backend: Backend to use for file storage, or a factory function that takes runtime and returns a backend.
300
- custom_description: Optional custom description for the tool.
301
-
302
- Returns:
303
- Configured ls tool that lists files using the backend.
304
- """
305
- tool_description = custom_description or LIST_FILES_TOOL_DESCRIPTION
306
-
307
- def sync_ls(
308
- runtime: ToolRuntime[None, FilesystemState],
309
- path: Annotated[str, "Absolute path to the directory to list. Must be absolute, not relative."],
310
- ) -> str:
311
- """Synchronous wrapper for ls tool."""
312
- resolved_backend = _get_backend(backend, runtime)
313
- validated_path = _validate_path(path)
314
- infos = resolved_backend.ls_info(validated_path)
315
- paths = [fi.get("path", "") for fi in infos]
316
- result = truncate_if_too_long(paths)
317
- return str(result)
318
-
319
- async def async_ls(
320
- runtime: ToolRuntime[None, FilesystemState],
321
- path: Annotated[str, "Absolute path to the directory to list. Must be absolute, not relative."],
322
- ) -> str:
323
- """Asynchronous wrapper for ls tool."""
324
- resolved_backend = _get_backend(backend, runtime)
325
- validated_path = _validate_path(path)
326
- infos = await resolved_backend.als_info(validated_path)
327
- paths = [fi.get("path", "") for fi in infos]
328
- result = truncate_if_too_long(paths)
329
- return str(result)
330
-
331
- return StructuredTool.from_function(
332
- name="ls",
333
- description=tool_description,
334
- func=sync_ls,
335
- coroutine=async_ls,
336
- )
337
-
338
-
339
- def _read_file_tool_generator(
340
- backend: BackendProtocol | Callable[[ToolRuntime], BackendProtocol],
341
- custom_description: str | None = None,
342
- ) -> BaseTool:
343
- """Generate the read_file tool.
344
-
345
- Args:
346
- backend: Backend to use for file storage, or a factory function that takes runtime and returns a backend.
347
- custom_description: Optional custom description for the tool.
348
-
349
- Returns:
350
- Configured read_file tool that reads files using the backend.
351
- """
352
- tool_description = custom_description or READ_FILE_TOOL_DESCRIPTION
353
-
354
- def sync_read_file(
355
- file_path: Annotated[str, "Absolute path to the file to read. Must be absolute, not relative."],
356
- runtime: ToolRuntime[None, FilesystemState],
357
- offset: Annotated[int, "Line number to start reading from (0-indexed). Use for pagination of large files."] = DEFAULT_READ_OFFSET,
358
- limit: Annotated[int, "Maximum number of lines to read. Use for pagination of large files."] = DEFAULT_READ_LIMIT,
359
- ) -> str:
360
- """Synchronous wrapper for read_file tool."""
361
- resolved_backend = _get_backend(backend, runtime)
362
- file_path = _validate_path(file_path)
363
- result = resolved_backend.read(file_path, offset=offset, limit=limit)
364
-
365
- lines = result.splitlines(keepends=True)
366
- if len(lines) > limit:
367
- lines = lines[:limit]
368
- result = "".join(lines)
369
-
370
- return result
371
-
372
- async def async_read_file(
373
- file_path: Annotated[str, "Absolute path to the file to read. Must be absolute, not relative."],
374
- runtime: ToolRuntime[None, FilesystemState],
375
- offset: Annotated[int, "Line number to start reading from (0-indexed). Use for pagination of large files."] = DEFAULT_READ_OFFSET,
376
- limit: Annotated[int, "Maximum number of lines to read. Use for pagination of large files."] = DEFAULT_READ_LIMIT,
377
- ) -> str:
378
- """Asynchronous wrapper for read_file tool."""
379
- resolved_backend = _get_backend(backend, runtime)
380
- file_path = _validate_path(file_path)
381
- result = await resolved_backend.aread(file_path, offset=offset, limit=limit)
382
-
383
- lines = result.splitlines(keepends=True)
384
- if len(lines) > limit:
385
- lines = lines[:limit]
386
- result = "".join(lines)
387
-
388
- return result
389
-
390
- return StructuredTool.from_function(
391
- name="read_file",
392
- description=tool_description,
393
- func=sync_read_file,
394
- coroutine=async_read_file,
395
- )
396
-
397
-
398
- def _write_file_tool_generator(
399
- backend: BackendProtocol | Callable[[ToolRuntime], BackendProtocol],
400
- custom_description: str | None = None,
401
- ) -> BaseTool:
402
- """Generate the write_file tool.
403
-
404
- Args:
405
- backend: Backend to use for file storage, or a factory function that takes runtime and returns a backend.
406
- custom_description: Optional custom description for the tool.
407
-
408
- Returns:
409
- Configured write_file tool that creates new files using the backend.
410
- """
411
- tool_description = custom_description or WRITE_FILE_TOOL_DESCRIPTION
412
-
413
- def sync_write_file(
414
- file_path: Annotated[str, "Absolute path where the file should be created. Must be absolute, not relative."],
415
- content: Annotated[str, "The text content to write to the file. This parameter is required."],
416
- runtime: ToolRuntime[None, FilesystemState],
417
- ) -> Command | str:
418
- """Synchronous wrapper for write_file tool."""
419
- resolved_backend = _get_backend(backend, runtime)
420
- file_path = _validate_path(file_path)
421
- res: WriteResult = resolved_backend.write(file_path, content)
422
- if res.error:
423
- return res.error
424
- # If backend returns state update, wrap into Command with ToolMessage
425
- if res.files_update is not None:
426
- return Command(
427
- update={
428
- "files": res.files_update,
429
- "messages": [
430
- ToolMessage(
431
- content=f"Updated file {res.path}",
432
- tool_call_id=runtime.tool_call_id,
433
- )
434
- ],
435
- }
436
- )
437
- return f"Updated file {res.path}"
438
-
439
- async def async_write_file(
440
- file_path: Annotated[str, "Absolute path where the file should be created. Must be absolute, not relative."],
441
- content: Annotated[str, "The text content to write to the file. This parameter is required."],
442
- runtime: ToolRuntime[None, FilesystemState],
443
- ) -> Command | str:
444
- """Asynchronous wrapper for write_file tool."""
445
- resolved_backend = _get_backend(backend, runtime)
446
- file_path = _validate_path(file_path)
447
- res: WriteResult = await resolved_backend.awrite(file_path, content)
448
- if res.error:
449
- return res.error
450
- # If backend returns state update, wrap into Command with ToolMessage
451
- if res.files_update is not None:
452
- return Command(
453
- update={
454
- "files": res.files_update,
455
- "messages": [
456
- ToolMessage(
457
- content=f"Updated file {res.path}",
458
- tool_call_id=runtime.tool_call_id,
459
- )
460
- ],
461
- }
462
- )
463
- return f"Updated file {res.path}"
464
-
465
- return StructuredTool.from_function(
466
- name="write_file",
467
- description=tool_description,
468
- func=sync_write_file,
469
- coroutine=async_write_file,
470
- )
471
-
472
-
473
- def _edit_file_tool_generator(
474
- backend: BackendProtocol | Callable[[ToolRuntime], BackendProtocol],
475
- custom_description: str | None = None,
476
- ) -> BaseTool:
477
- """Generate the edit_file tool.
478
-
479
- Args:
480
- backend: Backend to use for file storage, or a factory function that takes runtime and returns a backend.
481
- custom_description: Optional custom description for the tool.
482
-
483
- Returns:
484
- Configured edit_file tool that performs string replacements in files using the backend.
485
- """
486
- tool_description = custom_description or EDIT_FILE_TOOL_DESCRIPTION
487
-
488
- def sync_edit_file(
489
- file_path: Annotated[str, "Absolute path to the file to edit. Must be absolute, not relative."],
490
- old_string: Annotated[str, "The exact text to find and replace. Must be unique in the file unless replace_all is True."],
491
- new_string: Annotated[str, "The text to replace old_string with. Must be different from old_string."],
492
- runtime: ToolRuntime[None, FilesystemState],
493
- *,
494
- replace_all: Annotated[bool, "If True, replace all occurrences of old_string. If False (default), old_string must be unique."] = False,
495
- ) -> Command | str:
496
- """Synchronous wrapper for edit_file tool."""
497
- resolved_backend = _get_backend(backend, runtime)
498
- file_path = _validate_path(file_path)
499
- res: EditResult = resolved_backend.edit(file_path, old_string, new_string, replace_all=replace_all)
500
- if res.error:
501
- return res.error
502
- if res.files_update is not None:
503
- return Command(
504
- update={
505
- "files": res.files_update,
506
- "messages": [
507
- ToolMessage(
508
- content=f"Successfully replaced {res.occurrences} instance(s) of the string in '{res.path}'",
509
- tool_call_id=runtime.tool_call_id,
510
- )
511
- ],
512
- }
513
- )
514
- return f"Successfully replaced {res.occurrences} instance(s) of the string in '{res.path}'"
515
-
516
- async def async_edit_file(
517
- file_path: Annotated[str, "Absolute path to the file to edit. Must be absolute, not relative."],
518
- old_string: Annotated[str, "The exact text to find and replace. Must be unique in the file unless replace_all is True."],
519
- new_string: Annotated[str, "The text to replace old_string with. Must be different from old_string."],
520
- runtime: ToolRuntime[None, FilesystemState],
521
- *,
522
- replace_all: Annotated[bool, "If True, replace all occurrences of old_string. If False (default), old_string must be unique."] = False,
523
- ) -> Command | str:
524
- """Asynchronous wrapper for edit_file tool."""
525
- resolved_backend = _get_backend(backend, runtime)
526
- file_path = _validate_path(file_path)
527
- res: EditResult = await resolved_backend.aedit(file_path, old_string, new_string, replace_all=replace_all)
528
- if res.error:
529
- return res.error
530
- if res.files_update is not None:
531
- return Command(
532
- update={
533
- "files": res.files_update,
534
- "messages": [
535
- ToolMessage(
536
- content=f"Successfully replaced {res.occurrences} instance(s) of the string in '{res.path}'",
537
- tool_call_id=runtime.tool_call_id,
538
- )
539
- ],
540
- }
541
- )
542
- return f"Successfully replaced {res.occurrences} instance(s) of the string in '{res.path}'"
543
-
544
- return StructuredTool.from_function(
545
- name="edit_file",
546
- description=tool_description,
547
- func=sync_edit_file,
548
- coroutine=async_edit_file,
549
- )
550
-
551
-
552
- def _glob_tool_generator(
553
- backend: BackendProtocol | Callable[[ToolRuntime], BackendProtocol],
554
- custom_description: str | None = None,
555
- ) -> BaseTool:
556
- """Generate the glob tool.
557
-
558
- Args:
559
- backend: Backend to use for file storage, or a factory function that takes runtime and returns a backend.
560
- custom_description: Optional custom description for the tool.
561
-
562
- Returns:
563
- Configured glob tool that finds files by pattern using the backend.
564
- """
565
- tool_description = custom_description or GLOB_TOOL_DESCRIPTION
566
-
567
- def sync_glob(
568
- pattern: Annotated[str, "Glob pattern to match files (e.g., '**/*.py', '*.txt', '/subdir/**/*.md')."],
569
- runtime: ToolRuntime[None, FilesystemState],
570
- path: Annotated[str, "Base directory to search from. Defaults to root '/'."] = "/",
571
- ) -> str:
572
- """Synchronous wrapper for glob tool."""
573
- resolved_backend = _get_backend(backend, runtime)
574
- infos = resolved_backend.glob_info(pattern, path=path)
575
- paths = [fi.get("path", "") for fi in infos]
576
- result = truncate_if_too_long(paths)
577
- return str(result)
578
-
579
- async def async_glob(
580
- pattern: Annotated[str, "Glob pattern to match files (e.g., '**/*.py', '*.txt', '/subdir/**/*.md')."],
581
- runtime: ToolRuntime[None, FilesystemState],
582
- path: Annotated[str, "Base directory to search from. Defaults to root '/'."] = "/",
583
- ) -> str:
584
- """Asynchronous wrapper for glob tool."""
585
- resolved_backend = _get_backend(backend, runtime)
586
- infos = await resolved_backend.aglob_info(pattern, path=path)
587
- paths = [fi.get("path", "") for fi in infos]
588
- result = truncate_if_too_long(paths)
589
- return str(result)
590
-
591
- return StructuredTool.from_function(
592
- name="glob",
593
- description=tool_description,
594
- func=sync_glob,
595
- coroutine=async_glob,
596
- )
597
-
598
-
599
- def _grep_tool_generator(
600
- backend: BackendProtocol | Callable[[ToolRuntime], BackendProtocol],
601
- custom_description: str | None = None,
602
- ) -> BaseTool:
603
- """Generate the grep tool.
604
-
605
- Args:
606
- backend: Backend to use for file storage, or a factory function that takes runtime and returns a backend.
607
- custom_description: Optional custom description for the tool.
608
-
609
- Returns:
610
- Configured grep tool that searches for patterns in files using the backend.
611
- """
612
- tool_description = custom_description or GREP_TOOL_DESCRIPTION
613
-
614
- def sync_grep(
615
- pattern: Annotated[str, "Text pattern to search for (literal string, not regex)."],
616
- runtime: ToolRuntime[None, FilesystemState],
617
- path: Annotated[str | None, "Directory to search in. Defaults to current working directory."] = None,
618
- glob: Annotated[str | None, "Glob pattern to filter which files to search (e.g., '*.py')."] = None,
619
- output_mode: Annotated[
620
- Literal["files_with_matches", "content", "count"],
621
- "Output format: 'files_with_matches' (file paths only, default), 'content' (matching lines with context), 'count' (match counts per file).",
622
- ] = "files_with_matches",
623
- ) -> str:
624
- """Synchronous wrapper for grep tool."""
625
- resolved_backend = _get_backend(backend, runtime)
626
- raw = resolved_backend.grep_raw(pattern, path=path, glob=glob)
627
- if isinstance(raw, str):
628
- return raw
629
- formatted = format_grep_matches(raw, output_mode)
630
- return truncate_if_too_long(formatted) # type: ignore[arg-type]
631
-
632
- async def async_grep(
633
- pattern: Annotated[str, "Text pattern to search for (literal string, not regex)."],
634
- runtime: ToolRuntime[None, FilesystemState],
635
- path: Annotated[str | None, "Directory to search in. Defaults to current working directory."] = None,
636
- glob: Annotated[str | None, "Glob pattern to filter which files to search (e.g., '*.py')."] = None,
637
- output_mode: Annotated[
638
- Literal["files_with_matches", "content", "count"],
639
- "Output format: 'files_with_matches' (file paths only, default), 'content' (matching lines with context), 'count' (match counts per file).",
640
- ] = "files_with_matches",
641
- ) -> str:
642
- """Asynchronous wrapper for grep tool."""
643
- resolved_backend = _get_backend(backend, runtime)
644
- raw = await resolved_backend.agrep_raw(pattern, path=path, glob=glob)
645
- if isinstance(raw, str):
646
- return raw
647
- formatted = format_grep_matches(raw, output_mode)
648
- return truncate_if_too_long(formatted) # type: ignore[arg-type]
649
-
650
- return StructuredTool.from_function(
651
- name="grep",
652
- description=tool_description,
653
- func=sync_grep,
654
- coroutine=async_grep,
655
- )
656
-
657
-
658
292
  def _supports_execution(backend: BackendProtocol) -> bool:
659
293
  """Check if a backend supports command execution.
660
294
 
@@ -675,95 +309,6 @@ def _supports_execution(backend: BackendProtocol) -> bool:
675
309
  return isinstance(backend, SandboxBackendProtocol)
676
310
 
677
311
 
678
- def _execute_tool_generator(
679
- backend: BackendProtocol | Callable[[ToolRuntime], BackendProtocol],
680
- custom_description: str | None = None,
681
- ) -> BaseTool:
682
- """Generate the execute tool for sandbox command execution.
683
-
684
- Args:
685
- backend: Backend to use for execution, or a factory function that takes runtime and returns a backend.
686
- custom_description: Optional custom description for the tool.
687
-
688
- Returns:
689
- Configured execute tool that runs commands if backend supports SandboxBackendProtocol.
690
- """
691
- tool_description = custom_description or EXECUTE_TOOL_DESCRIPTION
692
-
693
- def sync_execute(
694
- command: Annotated[str, "Shell command to execute in the sandbox environment."],
695
- runtime: ToolRuntime[None, FilesystemState],
696
- ) -> str:
697
- """Synchronous wrapper for execute tool."""
698
- resolved_backend = _get_backend(backend, runtime)
699
-
700
- # Runtime check - fail gracefully if not supported
701
- if not _supports_execution(resolved_backend):
702
- return (
703
- "Error: Execution not available. This agent's backend "
704
- "does not support command execution (SandboxBackendProtocol). "
705
- "To use the execute tool, provide a backend that implements SandboxBackendProtocol."
706
- )
707
-
708
- try:
709
- result = resolved_backend.execute(command)
710
- except NotImplementedError as e:
711
- # Handle case where execute() exists but raises NotImplementedError
712
- return f"Error: Execution not available. {e}"
713
-
714
- # Format output for LLM consumption
715
- parts = [result.output]
716
-
717
- if result.exit_code is not None:
718
- status = "succeeded" if result.exit_code == 0 else "failed"
719
- parts.append(f"\n[Command {status} with exit code {result.exit_code}]")
720
-
721
- if result.truncated:
722
- parts.append("\n[Output was truncated due to size limits]")
723
-
724
- return "".join(parts)
725
-
726
- async def async_execute(
727
- command: Annotated[str, "Shell command to execute in the sandbox environment."],
728
- runtime: ToolRuntime[None, FilesystemState],
729
- ) -> str:
730
- """Asynchronous wrapper for execute tool."""
731
- resolved_backend = _get_backend(backend, runtime)
732
-
733
- # Runtime check - fail gracefully if not supported
734
- if not _supports_execution(resolved_backend):
735
- return (
736
- "Error: Execution not available. This agent's backend "
737
- "does not support command execution (SandboxBackendProtocol). "
738
- "To use the execute tool, provide a backend that implements SandboxBackendProtocol."
739
- )
740
-
741
- try:
742
- result = await resolved_backend.aexecute(command)
743
- except NotImplementedError as e:
744
- # Handle case where execute() exists but raises NotImplementedError
745
- return f"Error: Execution not available. {e}"
746
-
747
- # Format output for LLM consumption
748
- parts = [result.output]
749
-
750
- if result.exit_code is not None:
751
- status = "succeeded" if result.exit_code == 0 else "failed"
752
- parts.append(f"\n[Command {status} with exit code {result.exit_code}]")
753
-
754
- if result.truncated:
755
- parts.append("\n[Output was truncated due to size limits]")
756
-
757
- return "".join(parts)
758
-
759
- return StructuredTool.from_function(
760
- name="execute",
761
- description=tool_description,
762
- func=sync_execute,
763
- coroutine=async_execute,
764
- )
765
-
766
-
767
312
  # Tools that should be excluded from the large result eviction logic.
768
313
  #
769
314
  # This tuple contains tools that should NOT have their results evicted to the filesystem
@@ -795,48 +340,46 @@ TOOLS_EXCLUDED_FROM_EVICTION = (
795
340
  )
796
341
 
797
342
 
798
- TOOL_GENERATORS = {
799
- "ls": _ls_tool_generator,
800
- "read_file": _read_file_tool_generator,
801
- "write_file": _write_file_tool_generator,
802
- "edit_file": _edit_file_tool_generator,
803
- "glob": _glob_tool_generator,
804
- "grep": _grep_tool_generator,
805
- "execute": _execute_tool_generator,
806
- }
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.
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):
351
+
352
+ {content_sample}
353
+ """
807
354
 
808
355
 
809
- def _get_filesystem_tools(
810
- backend: BackendProtocol,
811
- custom_tool_descriptions: dict[str, str] | None = None,
812
- ) -> list[BaseTool]:
813
- """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.
814
358
 
815
359
  Args:
816
- backend: Backend to use for file storage and optional execution, or a factory function that takes runtime and returns a backend.
817
- custom_tool_descriptions: Optional custom descriptions for tools.
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.
818
363
 
819
364
  Returns:
820
- List of configured tools: ls, read_file, write_file, edit_file, glob, grep, execute.
365
+ Formatted preview string with line numbers.
821
366
  """
822
- if custom_tool_descriptions is None:
823
- custom_tool_descriptions = {}
824
- tools = []
367
+ lines = content_str.splitlines()
825
368
 
826
- for tool_name, tool_generator in TOOL_GENERATORS.items():
827
- tool = tool_generator(backend, custom_tool_descriptions.get(tool_name))
828
- tools.append(tool)
829
- return tools
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)
830
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:]]
831
377
 
832
- 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}
833
- 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.
834
- You can do this by specifying an offset and limit in the read_file tool call.
835
- 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)
836
381
 
837
- Here are the first 10 lines of the result:
838
- {content_sample}
839
- """
382
+ return head_sample + truncation_notice + tail_sample
840
383
 
841
384
 
842
385
  class FilesystemMiddleware(AgentMiddleware):
@@ -909,15 +452,23 @@ class FilesystemMiddleware(AgentMiddleware):
909
452
  custom_tool_descriptions: Optional custom tool descriptions override.
910
453
  tool_token_limit_before_evict: Optional token limit before evicting a tool result to the filesystem.
911
454
  """
912
- self.tool_token_limit_before_evict = tool_token_limit_before_evict
913
-
914
455
  # Use provided backend or default to StateBackend factory
915
456
  self.backend = backend if backend is not None else (lambda rt: StateBackend(rt))
916
457
 
917
- # Set system prompt (allow full override or None to generate dynamically)
458
+ # Store configuration (private - internal implementation details)
918
459
  self._custom_system_prompt = system_prompt
919
-
920
- self.tools = _get_filesystem_tools(self.backend, custom_tool_descriptions)
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
+ ]
921
472
 
922
473
  def _get_backend(self, runtime: ToolRuntime) -> BackendProtocol:
923
474
  """Get the resolved backend instance from backend or factory.
@@ -932,6 +483,394 @@ class FilesystemMiddleware(AgentMiddleware):
932
483
  return self.backend(runtime)
933
484
  return self.backend
934
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
+
935
874
  def wrap_model_call(
936
875
  self,
937
876
  request: ModelRequest,
@@ -1054,7 +993,7 @@ class FilesystemMiddleware(AgentMiddleware):
1054
993
  The model can recover by reading the offloaded file from the backend.
1055
994
  """
1056
995
  # Early exit if eviction not configured
1057
- if not self.tool_token_limit_before_evict:
996
+ if not self._tool_token_limit_before_evict:
1058
997
  return message, None
1059
998
 
1060
999
  # Convert content to string once for both size check and eviction
@@ -1074,9 +1013,7 @@ class FilesystemMiddleware(AgentMiddleware):
1074
1013
  content_str = str(message.content)
1075
1014
 
1076
1015
  # Check if content exceeds eviction threshold
1077
- # Using 4 chars per token as a conservative approximation (actual ratio varies by content)
1078
- # This errs on the high side to avoid premature eviction of content that might fit
1079
- 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:
1080
1017
  return message, None
1081
1018
 
1082
1019
  # Write content to filesystem
@@ -1086,8 +1023,8 @@ class FilesystemMiddleware(AgentMiddleware):
1086
1023
  if result.error:
1087
1024
  return message, None
1088
1025
 
1089
- # Create truncated preview for the replacement message
1090
- content_sample = format_content_with_line_numbers([line[:1000] for line in content_str.splitlines()[:10]], start_line=1)
1026
+ # Create preview showing head and tail of the result
1027
+ content_sample = _create_content_preview(content_str)
1091
1028
  replacement_text = TOO_LARGE_TOOL_MSG.format(
1092
1029
  tool_call_id=message.tool_call_id,
1093
1030
  file_path=file_path,
@@ -1113,7 +1050,7 @@ class FilesystemMiddleware(AgentMiddleware):
1113
1050
  See _process_large_message for full documentation.
1114
1051
  """
1115
1052
  # Early exit if eviction not configured
1116
- if not self.tool_token_limit_before_evict:
1053
+ if not self._tool_token_limit_before_evict:
1117
1054
  return message, None
1118
1055
 
1119
1056
  # Convert content to string once for both size check and eviction
@@ -1132,10 +1069,7 @@ class FilesystemMiddleware(AgentMiddleware):
1132
1069
  # Multiple blocks or non-text content - stringify entire structure
1133
1070
  content_str = str(message.content)
1134
1071
 
1135
- # Check if content exceeds eviction threshold
1136
- # Using 4 chars per token as a conservative approximation (actual ratio varies by content)
1137
- # This errs on the high side to avoid premature eviction of content that might fit
1138
- 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:
1139
1073
  return message, None
1140
1074
 
1141
1075
  # Write content to filesystem using async method
@@ -1145,8 +1079,8 @@ class FilesystemMiddleware(AgentMiddleware):
1145
1079
  if result.error:
1146
1080
  return message, None
1147
1081
 
1148
- # Create truncated preview for the replacement message
1149
- content_sample = format_content_with_line_numbers([line[:1000] for line in content_str.splitlines()[:10]], start_line=1)
1082
+ # Create preview showing head and tail of the result
1083
+ content_sample = _create_content_preview(content_str)
1150
1084
  replacement_text = TOO_LARGE_TOOL_MSG.format(
1151
1085
  tool_call_id=message.tool_call_id,
1152
1086
  file_path=file_path,
@@ -1277,7 +1211,7 @@ class FilesystemMiddleware(AgentMiddleware):
1277
1211
  Returns:
1278
1212
  The raw ToolMessage, or a pseudo tool message with the ToolResult in state.
1279
1213
  """
1280
- if self.tool_token_limit_before_evict is None or request.tool_call["name"] in TOOLS_EXCLUDED_FROM_EVICTION:
1214
+ if self._tool_token_limit_before_evict is None or request.tool_call["name"] in TOOLS_EXCLUDED_FROM_EVICTION:
1281
1215
  return handler(request)
1282
1216
 
1283
1217
  tool_result = handler(request)
@@ -1297,7 +1231,7 @@ class FilesystemMiddleware(AgentMiddleware):
1297
1231
  Returns:
1298
1232
  The raw ToolMessage, or a pseudo tool message with the ToolResult in state.
1299
1233
  """
1300
- if self.tool_token_limit_before_evict is None or request.tool_call["name"] in TOOLS_EXCLUDED_FROM_EVICTION:
1234
+ if self._tool_token_limit_before_evict is None or request.tool_call["name"] in TOOLS_EXCLUDED_FROM_EVICTION:
1301
1235
  return await handler(request)
1302
1236
 
1303
1237
  tool_result = await handler(request)