ripperdoc 0.2.3__py3-none-any.whl → 0.2.5__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.
Files changed (76) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/__main__.py +0 -5
  3. ripperdoc/cli/cli.py +37 -16
  4. ripperdoc/cli/commands/__init__.py +2 -0
  5. ripperdoc/cli/commands/agents_cmd.py +12 -9
  6. ripperdoc/cli/commands/compact_cmd.py +7 -3
  7. ripperdoc/cli/commands/context_cmd.py +35 -15
  8. ripperdoc/cli/commands/doctor_cmd.py +27 -14
  9. ripperdoc/cli/commands/exit_cmd.py +1 -1
  10. ripperdoc/cli/commands/mcp_cmd.py +13 -8
  11. ripperdoc/cli/commands/memory_cmd.py +5 -5
  12. ripperdoc/cli/commands/models_cmd.py +47 -16
  13. ripperdoc/cli/commands/permissions_cmd.py +302 -0
  14. ripperdoc/cli/commands/resume_cmd.py +1 -2
  15. ripperdoc/cli/commands/tasks_cmd.py +24 -13
  16. ripperdoc/cli/ui/rich_ui.py +523 -396
  17. ripperdoc/cli/ui/tool_renderers.py +298 -0
  18. ripperdoc/core/agents.py +172 -4
  19. ripperdoc/core/config.py +130 -6
  20. ripperdoc/core/default_tools.py +13 -2
  21. ripperdoc/core/permissions.py +20 -14
  22. ripperdoc/core/providers/__init__.py +31 -15
  23. ripperdoc/core/providers/anthropic.py +122 -8
  24. ripperdoc/core/providers/base.py +93 -15
  25. ripperdoc/core/providers/gemini.py +539 -96
  26. ripperdoc/core/providers/openai.py +371 -26
  27. ripperdoc/core/query.py +301 -62
  28. ripperdoc/core/query_utils.py +51 -7
  29. ripperdoc/core/skills.py +295 -0
  30. ripperdoc/core/system_prompt.py +79 -67
  31. ripperdoc/core/tool.py +15 -6
  32. ripperdoc/sdk/client.py +14 -1
  33. ripperdoc/tools/ask_user_question_tool.py +431 -0
  34. ripperdoc/tools/background_shell.py +82 -26
  35. ripperdoc/tools/bash_tool.py +356 -209
  36. ripperdoc/tools/dynamic_mcp_tool.py +428 -0
  37. ripperdoc/tools/enter_plan_mode_tool.py +226 -0
  38. ripperdoc/tools/exit_plan_mode_tool.py +153 -0
  39. ripperdoc/tools/file_edit_tool.py +53 -10
  40. ripperdoc/tools/file_read_tool.py +17 -7
  41. ripperdoc/tools/file_write_tool.py +49 -13
  42. ripperdoc/tools/glob_tool.py +10 -9
  43. ripperdoc/tools/grep_tool.py +182 -51
  44. ripperdoc/tools/ls_tool.py +6 -6
  45. ripperdoc/tools/mcp_tools.py +172 -413
  46. ripperdoc/tools/multi_edit_tool.py +49 -9
  47. ripperdoc/tools/notebook_edit_tool.py +57 -13
  48. ripperdoc/tools/skill_tool.py +205 -0
  49. ripperdoc/tools/task_tool.py +91 -9
  50. ripperdoc/tools/todo_tool.py +12 -12
  51. ripperdoc/tools/tool_search_tool.py +5 -6
  52. ripperdoc/utils/coerce.py +34 -0
  53. ripperdoc/utils/context_length_errors.py +252 -0
  54. ripperdoc/utils/file_watch.py +5 -4
  55. ripperdoc/utils/json_utils.py +4 -4
  56. ripperdoc/utils/log.py +3 -3
  57. ripperdoc/utils/mcp.py +82 -22
  58. ripperdoc/utils/memory.py +9 -6
  59. ripperdoc/utils/message_compaction.py +19 -16
  60. ripperdoc/utils/messages.py +73 -8
  61. ripperdoc/utils/path_ignore.py +677 -0
  62. ripperdoc/utils/permissions/__init__.py +7 -1
  63. ripperdoc/utils/permissions/path_validation_utils.py +5 -3
  64. ripperdoc/utils/permissions/shell_command_validation.py +496 -18
  65. ripperdoc/utils/prompt.py +1 -1
  66. ripperdoc/utils/safe_get_cwd.py +5 -2
  67. ripperdoc/utils/session_history.py +38 -19
  68. ripperdoc/utils/todo.py +6 -2
  69. ripperdoc/utils/token_estimation.py +34 -0
  70. {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/METADATA +14 -1
  71. ripperdoc-0.2.5.dist-info/RECORD +107 -0
  72. ripperdoc-0.2.3.dist-info/RECORD +0 -95
  73. {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/WHEEL +0 -0
  74. {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/entry_points.txt +0 -0
  75. {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/licenses/LICENSE +0 -0
  76. {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/top_level.txt +0 -0
@@ -154,7 +154,8 @@ build projects, run tests, and interact with the file system."""
154
154
  sandbox_available = is_sandbox_available()
155
155
  try:
156
156
  current_shell = find_suitable_shell()
157
- except Exception as exc: # pragma: no cover - defensive guard
157
+ except (OSError, FileNotFoundError, RuntimeError) as exc:
158
+ # pragma: no cover - defensive guard
158
159
  current_shell = f"Unavailable ({exc})"
159
160
 
160
161
  shell_info = (
@@ -492,29 +493,307 @@ build projects, run tests, and interact with the file system."""
492
493
 
493
494
  return command, False
494
495
 
496
+ def _create_error_output(
497
+ self, command: str, stderr: str, sandbox: bool
498
+ ) -> BashToolOutput:
499
+ """Create a standardized error output."""
500
+ return BashToolOutput(
501
+ stdout="",
502
+ stderr=stderr,
503
+ exit_code=-1,
504
+ command=command,
505
+ sandbox=sandbox,
506
+ is_error=True,
507
+ )
508
+
509
+ def _setup_sandbox(
510
+ self, command: str, sandbox_requested: bool
511
+ ) -> tuple[Optional[str], Optional[BashToolOutput], Optional[Any]]:
512
+ """Setup sandbox environment if requested.
513
+
514
+ Returns:
515
+ Tuple of (final_command, error_output, cleanup_fn).
516
+ If error_output is not None, sandbox setup failed.
517
+ """
518
+ if not sandbox_requested:
519
+ return command, None, None
520
+
521
+ if not is_sandbox_available():
522
+ return None, self._create_error_output(
523
+ command, "Sandbox mode requested but not available on this system", True
524
+ ), None
525
+
526
+ try:
527
+ wrapper = create_sandbox_wrapper(command)
528
+ return wrapper.final_command, None, wrapper.cleanup
529
+ except (OSError, RuntimeError, ValueError) as exc:
530
+ logger.warning(
531
+ "[bash_tool] Failed to enable sandbox: %s: %s",
532
+ type(exc).__name__, exc,
533
+ extra={"command": command},
534
+ )
535
+ return None, self._create_error_output(
536
+ command, f"Failed to enable sandbox: {exc}", True
537
+ ), None
538
+
539
+ async def _run_background_command(
540
+ self,
541
+ final_command: str,
542
+ effective_command: str,
543
+ resolved_shell: str,
544
+ timeout_seconds: float,
545
+ timeout_ms: int,
546
+ sandbox_requested: bool,
547
+ start_time: float,
548
+ input_data: BashToolInput,
549
+ ) -> Optional[BashToolOutput]:
550
+ """Run a command in background mode.
551
+
552
+ Returns:
553
+ BashToolOutput on success or error, None if background mode not available.
554
+ """
555
+ try:
556
+ from ripperdoc.tools.background_shell import start_background_command
557
+ except (ImportError, ModuleNotFoundError) as e:
558
+ # pragma: no cover - defensive import
559
+ logger.warning(
560
+ "[bash_tool] Failed to import background shell runner: %s: %s",
561
+ type(e).__name__, e,
562
+ extra={"command": effective_command},
563
+ )
564
+ return self._create_error_output(
565
+ effective_command, f"Failed to start background task: {str(e)}", sandbox_requested
566
+ )
567
+
568
+ bg_timeout = (
569
+ None
570
+ if input_data.timeout is None
571
+ else (timeout_seconds if timeout_seconds > 0 else None)
572
+ )
573
+ task_id = await start_background_command(
574
+ final_command, timeout=bg_timeout, shell_executable=resolved_shell
575
+ )
576
+
577
+ return BashToolOutput(
578
+ stdout="",
579
+ stderr=f"Started background task: {task_id}",
580
+ exit_code=0,
581
+ command=effective_command,
582
+ duration_ms=(asyncio.get_running_loop().time() - start_time) * 1000.0,
583
+ timeout_ms=timeout_ms if bg_timeout is not None else 0,
584
+ background_task_id=task_id,
585
+ sandbox=sandbox_requested,
586
+ return_code_interpretation=None,
587
+ summary=f"Command running in background with ID: {task_id}",
588
+ interrupted=False,
589
+ is_image=False,
590
+ )
591
+
592
+ async def _execute_foreground_process(
593
+ self,
594
+ process: asyncio.subprocess.Process,
595
+ start_time: float,
596
+ timeout_seconds: float,
597
+ ) -> AsyncGenerator[tuple[bool, list[str], list[str], bool], ToolProgress]:
598
+ """Execute process and yield progress updates.
599
+
600
+ Yields:
601
+ ToolProgress for output updates.
602
+ Returns (via send):
603
+ Tuple of (completed, stdout_lines, stderr_lines, timed_out)
604
+ """
605
+ stdout_lines: list[str] = []
606
+ stderr_lines: list[str] = []
607
+ queue: asyncio.Queue[tuple[str, str]] = asyncio.Queue()
608
+ loop = asyncio.get_running_loop()
609
+ deadline = (
610
+ loop.time() + timeout_seconds if timeout_seconds and timeout_seconds > 0 else None
611
+ )
612
+ timed_out = False
613
+ last_progress_time = loop.time()
614
+
615
+ async def _pump_stream(
616
+ stream: Optional[asyncio.StreamReader], sink: list[str], label: str
617
+ ) -> None:
618
+ if not stream:
619
+ return
620
+ async for raw in stream:
621
+ text = raw.decode("utf-8", errors="replace")
622
+ sanitized_text = sanitize_output(text)
623
+ sink.append(sanitized_text)
624
+ await queue.put((label, sanitized_text.rstrip()))
625
+
626
+ pump_tasks = [
627
+ asyncio.create_task(_pump_stream(process.stdout, stdout_lines, "stdout")),
628
+ asyncio.create_task(_pump_stream(process.stderr, stderr_lines, "stderr")),
629
+ ]
630
+ wait_task = asyncio.create_task(process.wait())
631
+
632
+ # Main execution loop with progress reporting
633
+ while True:
634
+ done, _ = await asyncio.wait(
635
+ {wait_task, *pump_tasks}, timeout=0.1, return_when=asyncio.FIRST_COMPLETED
636
+ )
637
+
638
+ now = loop.time()
639
+
640
+ # Emit progress updates for newly received output chunks
641
+ while not queue.empty():
642
+ label, text = queue.get_nowait()
643
+ yield ToolProgress(content=f"{label}: {text}")
644
+
645
+ # Report progress at intervals
646
+ if now - last_progress_time >= PROGRESS_INTERVAL_SECONDS:
647
+ combined_output = "".join(stdout_lines + stderr_lines)
648
+ if combined_output:
649
+ preview = get_last_n_lines(combined_output, 5)
650
+ elapsed = format_duration((now - start_time) * 1000)
651
+ yield ToolProgress(content=f"Running... ({elapsed})\n{preview}")
652
+ last_progress_time = now
653
+
654
+ # Check timeout
655
+ if deadline is not None and now >= deadline:
656
+ timed_out = True
657
+ await self._force_kill_process(process)
658
+ if not wait_task.done():
659
+ try:
660
+ await asyncio.wait_for(wait_task, timeout=1.0)
661
+ except asyncio.TimeoutError:
662
+ wait_task.cancel()
663
+ with contextlib.suppress(asyncio.CancelledError):
664
+ await wait_task
665
+ break
666
+
667
+ if wait_task in done:
668
+ break
669
+
670
+ # Let stream pumps finish draining
671
+ try:
672
+ await asyncio.wait_for(asyncio.gather(*pump_tasks), timeout=1.0)
673
+ except asyncio.TimeoutError:
674
+ for task in pump_tasks:
675
+ if not task.done():
676
+ task.cancel()
677
+ with contextlib.suppress(asyncio.CancelledError):
678
+ await task
679
+
680
+ # Drain remaining data
681
+ await self._drain_stream(process.stdout, stdout_lines)
682
+ await self._drain_stream(process.stderr, stderr_lines)
683
+
684
+ # Store results in a way that the caller can access
685
+ self._last_execution_result = (stdout_lines, stderr_lines, timed_out)
686
+
687
+ async def _drain_stream(
688
+ self, stream: Optional[asyncio.StreamReader], sink: list[str]
689
+ ) -> None:
690
+ """Drain any remaining data from a stream."""
691
+ if not stream:
692
+ return
693
+ try:
694
+ remaining = await asyncio.wait_for(stream.read(), timeout=0.5)
695
+ except asyncio.TimeoutError:
696
+ return
697
+ if remaining:
698
+ sink.append(remaining.decode("utf-8", errors="replace"))
699
+
700
+ def _build_final_output(
701
+ self,
702
+ command: str,
703
+ stdout_lines: list[str],
704
+ stderr_lines: list[str],
705
+ exit_code: int,
706
+ duration_ms: float,
707
+ timeout_ms: int,
708
+ timeout_seconds: float,
709
+ timed_out: bool,
710
+ sandbox_requested: bool,
711
+ original_command: str,
712
+ ) -> BashToolOutput:
713
+ """Build the final output from execution results."""
714
+ raw_stdout = "".join(stdout_lines)
715
+ raw_stderr = "".join(stderr_lines)
716
+
717
+ # Apply timeout message if needed
718
+ if timed_out:
719
+ timeout_msg = f"Command timed out after {timeout_seconds} seconds"
720
+ raw_stderr = f"{raw_stderr.rstrip()}\n{timeout_msg}" if raw_stderr else timeout_msg
721
+ exit_code = -1
722
+
723
+ # Sanitize and trim outputs
724
+ raw_stdout = sanitize_output(raw_stdout)
725
+ raw_stderr = sanitize_output(raw_stderr)
726
+ trimmed_stdout = trim_blank_lines(raw_stdout)
727
+ trimmed_stderr = trim_blank_lines(raw_stderr)
728
+
729
+ # Interpret exit code
730
+ exit_result = interpret_exit_code(
731
+ original_command, exit_code, trimmed_stdout, trimmed_stderr
732
+ )
733
+
734
+ # Build summary for large outputs
735
+ summary = None
736
+ combined_output = "\n".join([part for part in (trimmed_stdout, trimmed_stderr) if part])
737
+ if combined_output and is_output_large(combined_output):
738
+ summary = get_last_n_lines(combined_output, 20)
739
+
740
+ # Truncate outputs if needed
741
+ stdout_result = truncate_output(trimmed_stdout, max_chars=MAX_OUTPUT_CHARS)
742
+ stderr_result = truncate_output(trimmed_stderr, max_chars=MAX_OUTPUT_CHARS)
743
+ is_image = stdout_result.get("is_image", False) or stderr_result.get("is_image", False)
744
+
745
+ # Determine if truncated
746
+ is_truncated = stdout_result["is_truncated"] or stderr_result["is_truncated"]
747
+ original_length = None
748
+ if is_truncated:
749
+ original_length = stdout_result.get("original_length", 0) + stderr_result.get(
750
+ "original_length", 0
751
+ )
752
+
753
+ return BashToolOutput(
754
+ stdout=stdout_result["truncated_content"],
755
+ stderr=stderr_result["truncated_content"],
756
+ exit_code=exit_code,
757
+ command=command,
758
+ duration_ms=duration_ms,
759
+ timeout_ms=timeout_ms,
760
+ is_truncated=is_truncated,
761
+ original_length=original_length,
762
+ exit_code_meaning=exit_result.semantic_meaning,
763
+ return_code_interpretation=exit_result.semantic_meaning,
764
+ summary=summary,
765
+ interrupted=timed_out,
766
+ is_image=is_image,
767
+ sandbox=sandbox_requested,
768
+ is_error=exit_result.is_error or timed_out,
769
+ )
770
+
495
771
  async def call(
496
772
  self, input_data: BashToolInput, context: ToolUseContext
497
773
  ) -> AsyncGenerator[ToolOutput, None]:
498
774
  """Execute the bash command."""
499
-
500
775
  effective_command, auto_background = self._detect_auto_background(input_data.command)
776
+
777
+ # Resolve shell
501
778
  try:
502
779
  resolved_shell = input_data.shell_executable or find_suitable_shell()
503
- except Exception as exc: # pragma: no cover - defensive guard
504
- error_output = BashToolOutput(
505
- stdout="",
506
- stderr=f"Failed to select shell: {exc}",
507
- exit_code=-1,
508
- command=effective_command,
509
- sandbox=bool(input_data.sandbox),
510
- is_error=True,
511
- )
780
+ except (OSError, FileNotFoundError, RuntimeError) as exc:
781
+ # pragma: no cover - defensive guard
512
782
  yield ToolResult(
513
- data=error_output,
514
- result_for_assistant=self.render_result_for_assistant(error_output),
783
+ data=self._create_error_output(
784
+ effective_command, f"Failed to select shell: {exc}", bool(input_data.sandbox)
785
+ ),
786
+ result_for_assistant=self.render_result_for_assistant(
787
+ self._create_error_output(
788
+ effective_command,
789
+ f"Failed to select shell: {exc}",
790
+ bool(input_data.sandbox),
791
+ )
792
+ ),
515
793
  )
516
794
  return
517
795
 
796
+ # Calculate timeout
518
797
  timeout_ms = input_data.timeout or DEFAULT_TIMEOUT_MS
519
798
  if MAX_BASH_TIMEOUT_MS:
520
799
  timeout_ms = min(timeout_ms, MAX_BASH_TIMEOUT_MS)
@@ -522,59 +801,55 @@ build projects, run tests, and interact with the file system."""
522
801
  start = asyncio.get_running_loop().time()
523
802
  sandbox_requested = bool(input_data.sandbox)
524
803
  should_background = bool(input_data.run_in_background or auto_background)
804
+
805
+ # Track read-only state
525
806
  previous_read_only = getattr(self, "_current_is_read_only", False)
526
807
  self._current_is_read_only = sandbox_requested or is_command_read_only(input_data.command)
527
808
 
528
- sandbox_cleanup = None
529
- final_command = effective_command
530
-
531
- if sandbox_requested:
532
- if not is_sandbox_available():
533
- error_output = BashToolOutput(
534
- stdout="",
535
- stderr="Sandbox mode requested but not available on this system",
536
- exit_code=-1,
537
- command=effective_command,
538
- sandbox=sandbox_requested,
539
- is_error=True,
540
- )
541
- yield ToolResult(
542
- data=error_output,
543
- result_for_assistant=self.render_result_for_assistant(error_output),
544
- )
545
- return
546
- try:
547
- wrapper = create_sandbox_wrapper(effective_command)
548
- final_command = wrapper.final_command
549
- sandbox_cleanup = wrapper.cleanup
550
- except Exception as exc:
551
- logger.exception(
552
- "[bash_tool] Failed to enable sandbox",
553
- extra={"command": effective_command, "error": str(exc)},
554
- )
555
- error_output = BashToolOutput(
556
- stdout="",
557
- stderr=f"Failed to enable sandbox: {exc}",
558
- exit_code=-1,
559
- command=effective_command,
560
- sandbox=sandbox_requested,
561
- is_error=True,
562
- )
563
- yield ToolResult(
564
- data=error_output,
565
- result_for_assistant=self.render_result_for_assistant(error_output),
566
- )
567
- return
809
+ # Setup sandbox
810
+ final_command, sandbox_error, sandbox_cleanup = self._setup_sandbox(
811
+ effective_command, sandbox_requested
812
+ )
813
+ if sandbox_error:
814
+ yield ToolResult(
815
+ data=sandbox_error,
816
+ result_for_assistant=self.render_result_for_assistant(sandbox_error),
817
+ )
818
+ return
819
+
820
+ final_command = final_command or effective_command
568
821
 
822
+ # Adjust CWD for sandbox
569
823
  if sandbox_requested and Path(safe_get_cwd()) != ORIGINAL_CWD:
570
824
  os.chdir(ORIGINAL_CWD)
571
825
 
826
+ # Check if background is allowed
572
827
  if should_background and not self._is_background_allowed(input_data.command):
573
828
  should_background = False
574
829
 
575
- async def _spawn_process() -> asyncio.subprocess.Process:
830
+ try:
831
+ # Background execution
832
+ if should_background:
833
+ output = await self._run_background_command(
834
+ final_command,
835
+ effective_command,
836
+ resolved_shell,
837
+ timeout_seconds,
838
+ timeout_ms,
839
+ sandbox_requested,
840
+ start,
841
+ input_data,
842
+ )
843
+ if output:
844
+ yield ToolResult(
845
+ data=output,
846
+ result_for_assistant=self.render_result_for_assistant(output),
847
+ )
848
+ return
849
+
850
+ # Spawn foreground process
576
851
  argv = build_shell_command(resolved_shell, final_command)
577
- return await asyncio.create_subprocess_exec(
852
+ process = await asyncio.create_subprocess_exec(
578
853
  *argv,
579
854
  stdout=asyncio.subprocess.PIPE,
580
855
  stderr=asyncio.subprocess.PIPE,
@@ -582,62 +857,7 @@ build projects, run tests, and interact with the file system."""
582
857
  start_new_session=False,
583
858
  )
584
859
 
585
- try:
586
- # Background execution: start and return immediately.
587
- if should_background:
588
- try:
589
- from ripperdoc.tools.background_shell import start_background_command
590
- except Exception as e: # pragma: no cover - defensive import
591
- logger.exception(
592
- "[bash_tool] Failed to import background shell runner",
593
- extra={"command": effective_command},
594
- )
595
- error_output = BashToolOutput(
596
- stdout="",
597
- stderr=f"Failed to start background task: {str(e)}",
598
- exit_code=-1,
599
- command=effective_command,
600
- sandbox=sandbox_requested,
601
- is_error=True,
602
- )
603
- yield ToolResult(
604
- data=error_output,
605
- result_for_assistant=self.render_result_for_assistant(error_output),
606
- )
607
- return
608
-
609
- bg_timeout = (
610
- None
611
- if input_data.timeout is None
612
- else (timeout_seconds if timeout_seconds > 0 else None)
613
- )
614
- task_id = await start_background_command(
615
- final_command, timeout=bg_timeout, shell_executable=resolved_shell
616
- )
617
-
618
- output = BashToolOutput(
619
- stdout="",
620
- stderr=f"Started background task: {task_id}",
621
- exit_code=0,
622
- command=effective_command,
623
- duration_ms=(asyncio.get_running_loop().time() - start) * 1000.0,
624
- timeout_ms=timeout_ms if bg_timeout is not None else 0,
625
- background_task_id=task_id,
626
- sandbox=sandbox_requested,
627
- return_code_interpretation=None,
628
- summary=f"Command running in background with ID: {task_id}",
629
- interrupted=False,
630
- is_image=False,
631
- )
632
-
633
- yield ToolResult(
634
- data=output, result_for_assistant=self.render_result_for_assistant(output)
635
- )
636
- return
637
-
638
- # Run the command
639
- process = await _spawn_process()
640
-
860
+ # Execute and collect output with progress
641
861
  stdout_lines: list[str] = []
642
862
  stderr_lines: list[str] = []
643
863
  queue: asyncio.Queue[tuple[str, str]] = asyncio.Queue()
@@ -655,7 +875,6 @@ build projects, run tests, and interact with the file system."""
655
875
  return
656
876
  async for raw in stream:
657
877
  text = raw.decode("utf-8", errors="replace")
658
- # Strip escape/control sequences early so progress updates can't garble the UI
659
878
  sanitized_text = sanitize_output(text)
660
879
  sink.append(sanitized_text)
661
880
  await queue.put((label, sanitized_text.rstrip()))
@@ -666,7 +885,7 @@ build projects, run tests, and interact with the file system."""
666
885
  ]
667
886
  wait_task = asyncio.create_task(process.wait())
668
887
 
669
- # Main execution loop with progress reporting
888
+ # Main execution loop
670
889
  while True:
671
890
  done, _ = await asyncio.wait(
672
891
  {wait_task, *pump_tasks}, timeout=0.1, return_when=asyncio.FIRST_COMPLETED
@@ -674,22 +893,18 @@ build projects, run tests, and interact with the file system."""
674
893
 
675
894
  now = loop.time()
676
895
 
677
- # Emit progress updates for newly received output chunks immediately.
678
896
  while not queue.empty():
679
897
  label, text = queue.get_nowait()
680
898
  yield ToolProgress(content=f"{label}: {text}")
681
899
 
682
- # Report progress at intervals
683
900
  if now - last_progress_time >= PROGRESS_INTERVAL_SECONDS:
684
901
  combined_output = "".join(stdout_lines + stderr_lines)
685
902
  if combined_output:
686
- # Show last few lines as progress
687
903
  preview = get_last_n_lines(combined_output, 5)
688
904
  elapsed = format_duration((now - start) * 1000)
689
905
  yield ToolProgress(content=f"Running... ({elapsed})\n{preview}")
690
906
  last_progress_time = now
691
907
 
692
- # Check timeout
693
908
  if deadline is not None and now >= deadline:
694
909
  timed_out = True
695
910
  await self._force_kill_process(process)
@@ -705,7 +920,7 @@ build projects, run tests, and interact with the file system."""
705
920
  if wait_task in done:
706
921
  break
707
922
 
708
- # Let stream pumps finish draining after the process exits/gets killed.
923
+ # Drain streams
709
924
  try:
710
925
  await asyncio.wait_for(asyncio.gather(*pump_tasks), timeout=1.0)
711
926
  except asyncio.TimeoutError:
@@ -715,112 +930,44 @@ build projects, run tests, and interact with the file system."""
715
930
  with contextlib.suppress(asyncio.CancelledError):
716
931
  await task
717
932
 
718
- # Drain any remaining data from streams
719
- async def _drain_remaining(
720
- stream: Optional[asyncio.StreamReader], sink: list[str]
721
- ) -> None:
722
- if not stream:
723
- return
724
- try:
725
- remaining = await asyncio.wait_for(stream.read(), timeout=0.5)
726
- except asyncio.TimeoutError:
727
- return
728
- if remaining:
729
- sink.append(remaining.decode("utf-8", errors="replace"))
730
-
731
- await _drain_remaining(process.stdout, stdout_lines)
732
- await _drain_remaining(process.stderr, stderr_lines)
933
+ await self._drain_stream(process.stdout, stdout_lines)
934
+ await self._drain_stream(process.stderr, stderr_lines)
733
935
 
936
+ # Build final output
734
937
  duration_ms = (asyncio.get_running_loop().time() - start) * 1000.0
735
- raw_stdout = "".join(stdout_lines)
736
- raw_stderr = "".join(stderr_lines)
737
- exit_code = process.returncode or 0
738
-
739
- # Apply timeout message if needed
740
- if timed_out:
741
- timeout_msg = f"Command timed out after {timeout_seconds} seconds"
742
- raw_stderr = f"{raw_stderr.rstrip()}\n{timeout_msg}" if raw_stderr else timeout_msg
743
- exit_code = -1
744
-
745
- # Sanitize outputs
746
- raw_stdout = sanitize_output(raw_stdout)
747
- raw_stderr = sanitize_output(raw_stderr)
748
-
749
- # Trim blank lines
750
- trimmed_stdout = trim_blank_lines(raw_stdout)
751
- trimmed_stderr = trim_blank_lines(raw_stderr)
752
-
753
- # Interpret exit code
754
- exit_result = interpret_exit_code(
755
- input_data.command, exit_code, trimmed_stdout, trimmed_stderr
756
- )
757
-
758
- summary = None
759
- combined_output_for_summary = "\n".join(
760
- [part for part in (trimmed_stdout, trimmed_stderr) if part]
761
- )
762
- if combined_output_for_summary and is_output_large(combined_output_for_summary):
763
- summary = get_last_n_lines(combined_output_for_summary, 20)
764
-
765
- # Truncate outputs if needed
766
- stdout_result = truncate_output(trimmed_stdout, max_chars=MAX_OUTPUT_CHARS)
767
- stderr_result = truncate_output(trimmed_stderr, max_chars=MAX_OUTPUT_CHARS)
768
- is_image = stdout_result.get("is_image", False) or stderr_result.get("is_image", False)
769
-
770
- # Determine if truncated
771
- is_truncated = stdout_result["is_truncated"] or stderr_result["is_truncated"]
772
- original_length = None
773
- if is_truncated:
774
- original_length = stdout_result.get("original_length", 0) + stderr_result.get(
775
- "original_length", 0
776
- )
777
-
778
- output = BashToolOutput(
779
- stdout=stdout_result["truncated_content"],
780
- stderr=stderr_result["truncated_content"],
781
- exit_code=exit_code,
782
- command=effective_command,
783
- duration_ms=duration_ms,
784
- timeout_ms=timeout_ms,
785
- is_truncated=is_truncated,
786
- original_length=original_length,
787
- exit_code_meaning=exit_result.semantic_meaning,
788
- return_code_interpretation=exit_result.semantic_meaning,
789
- summary=summary,
790
- interrupted=timed_out,
791
- is_image=is_image,
792
- sandbox=sandbox_requested,
793
- is_error=exit_result.is_error or timed_out,
938
+ output = self._build_final_output(
939
+ effective_command,
940
+ stdout_lines,
941
+ stderr_lines,
942
+ process.returncode or 0,
943
+ duration_ms,
944
+ timeout_ms,
945
+ timeout_seconds,
946
+ timed_out,
947
+ sandbox_requested,
948
+ input_data.command,
794
949
  )
795
950
 
796
951
  yield ToolResult(
797
952
  data=output, result_for_assistant=self.render_result_for_assistant(output)
798
953
  )
799
954
 
800
- except Exception as e:
801
- logger.exception(
802
- "[bash_tool] Error executing command",
803
- extra={"command": effective_command, "error": str(e)},
955
+ except (OSError, RuntimeError, ValueError, asyncio.CancelledError) as e:
956
+ if isinstance(e, asyncio.CancelledError):
957
+ raise # Re-raise cancellation
958
+ logger.warning(
959
+ "[bash_tool] Error executing command: %s: %s",
960
+ type(e).__name__, e,
961
+ extra={"command": effective_command},
804
962
  )
805
- error_output = BashToolOutput(
806
- stdout="",
807
- stderr=f"Error executing command: {str(e)}",
808
- exit_code=-1,
809
- command=effective_command,
810
- sandbox=sandbox_requested,
811
- summary=None,
812
- return_code_interpretation=None,
813
- interrupted=False,
814
- is_image=False,
815
- is_error=True,
963
+ error_output = self._create_error_output(
964
+ effective_command, f"Error executing command: {str(e)}", sandbox_requested
816
965
  )
817
-
818
966
  yield ToolResult(
819
967
  data=error_output,
820
968
  result_for_assistant=self.render_result_for_assistant(error_output),
821
969
  )
822
970
  finally:
823
- # Restore read-only flag to prior state.
824
971
  self._current_is_read_only = previous_read_only
825
972
  if sandbox_cleanup:
826
973
  with contextlib.suppress(Exception):