ripperdoc 0.2.4__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.
- ripperdoc/__init__.py +1 -1
- ripperdoc/__main__.py +0 -5
- ripperdoc/cli/cli.py +37 -16
- ripperdoc/cli/commands/__init__.py +2 -0
- ripperdoc/cli/commands/agents_cmd.py +12 -9
- ripperdoc/cli/commands/compact_cmd.py +7 -3
- ripperdoc/cli/commands/context_cmd.py +33 -13
- ripperdoc/cli/commands/doctor_cmd.py +27 -14
- ripperdoc/cli/commands/exit_cmd.py +1 -1
- ripperdoc/cli/commands/mcp_cmd.py +13 -8
- ripperdoc/cli/commands/memory_cmd.py +5 -5
- ripperdoc/cli/commands/models_cmd.py +47 -16
- ripperdoc/cli/commands/permissions_cmd.py +302 -0
- ripperdoc/cli/commands/resume_cmd.py +1 -2
- ripperdoc/cli/commands/tasks_cmd.py +24 -13
- ripperdoc/cli/ui/rich_ui.py +500 -406
- ripperdoc/cli/ui/tool_renderers.py +298 -0
- ripperdoc/core/agents.py +17 -9
- ripperdoc/core/config.py +130 -6
- ripperdoc/core/default_tools.py +7 -2
- ripperdoc/core/permissions.py +20 -14
- ripperdoc/core/providers/anthropic.py +107 -4
- ripperdoc/core/providers/base.py +33 -4
- ripperdoc/core/providers/gemini.py +169 -50
- ripperdoc/core/providers/openai.py +257 -23
- ripperdoc/core/query.py +294 -61
- ripperdoc/core/query_utils.py +50 -6
- ripperdoc/core/skills.py +295 -0
- ripperdoc/core/system_prompt.py +13 -7
- ripperdoc/core/tool.py +8 -6
- ripperdoc/sdk/client.py +14 -1
- ripperdoc/tools/ask_user_question_tool.py +20 -22
- ripperdoc/tools/background_shell.py +19 -13
- ripperdoc/tools/bash_tool.py +356 -209
- ripperdoc/tools/dynamic_mcp_tool.py +428 -0
- ripperdoc/tools/enter_plan_mode_tool.py +5 -2
- ripperdoc/tools/exit_plan_mode_tool.py +6 -3
- ripperdoc/tools/file_edit_tool.py +53 -10
- ripperdoc/tools/file_read_tool.py +17 -7
- ripperdoc/tools/file_write_tool.py +49 -13
- ripperdoc/tools/glob_tool.py +10 -9
- ripperdoc/tools/grep_tool.py +182 -51
- ripperdoc/tools/ls_tool.py +6 -6
- ripperdoc/tools/mcp_tools.py +106 -456
- ripperdoc/tools/multi_edit_tool.py +49 -9
- ripperdoc/tools/notebook_edit_tool.py +57 -13
- ripperdoc/tools/skill_tool.py +205 -0
- ripperdoc/tools/task_tool.py +7 -8
- ripperdoc/tools/todo_tool.py +12 -12
- ripperdoc/tools/tool_search_tool.py +5 -6
- ripperdoc/utils/coerce.py +34 -0
- ripperdoc/utils/context_length_errors.py +252 -0
- ripperdoc/utils/file_watch.py +5 -4
- ripperdoc/utils/json_utils.py +4 -4
- ripperdoc/utils/log.py +3 -3
- ripperdoc/utils/mcp.py +36 -15
- ripperdoc/utils/memory.py +9 -6
- ripperdoc/utils/message_compaction.py +16 -11
- ripperdoc/utils/messages.py +73 -8
- ripperdoc/utils/path_ignore.py +677 -0
- ripperdoc/utils/permissions/__init__.py +7 -1
- ripperdoc/utils/permissions/path_validation_utils.py +5 -3
- ripperdoc/utils/permissions/shell_command_validation.py +496 -18
- ripperdoc/utils/prompt.py +1 -1
- ripperdoc/utils/safe_get_cwd.py +5 -2
- ripperdoc/utils/session_history.py +38 -19
- ripperdoc/utils/todo.py +6 -2
- ripperdoc/utils/token_estimation.py +4 -3
- {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/METADATA +12 -1
- ripperdoc-0.2.5.dist-info/RECORD +107 -0
- ripperdoc-0.2.4.dist-info/RECORD +0 -99
- {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/WHEEL +0 -0
- {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/top_level.txt +0 -0
ripperdoc/tools/bash_tool.py
CHANGED
|
@@ -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
|
|
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
|
|
504
|
-
|
|
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=
|
|
514
|
-
|
|
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
|
-
|
|
529
|
-
final_command =
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
719
|
-
|
|
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
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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 =
|
|
806
|
-
|
|
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):
|