ReMe/reme/tool/fs/bash_tool.py

191 lines
6.8 KiB
Python

"""Bash command execution tool with production-grade features.
This module provides a production-grade tool for executing bash commands with:
- Smart output truncation (keeps last N lines/bytes to prevent memory issues)
- Process tree termination (prevents orphan processes)
"""
import asyncio
import os
import platform
import signal
from pathlib import Path
from .truncate import DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, truncate_tail
from ...core.op import BaseTool
from ...core.schema import ToolCall, TruncationResult
def get_shell_config() -> tuple[str, list[str]]:
"""Get the appropriate shell and arguments for the current platform.
Returns:
Tuple of (shell_path, args) for subprocess execution
"""
system = platform.system()
if system == "Windows":
# Use PowerShell on Windows
return "powershell.exe", ["-Command"]
else:
# Use bash on Unix-like systems
shell = os.environ.get("SHELL", "/bin/bash")
return shell, ["-c"]
def kill_process_tree(pid: int) -> None:
"""Kill a process and all its children.
Args:
pid: Process ID to kill
"""
try:
if platform.system() == "Windows":
# Windows: use taskkill
os.system(f"taskkill /F /T /PID {pid}")
else:
# Unix: kill process group
try:
os.killpg(os.getpgid(pid), signal.SIGTERM)
except ProcessLookupError:
pass # Process already dead
except Exception:
pass # Best effort
class BashTool(BaseTool):
"""Production-grade tool for executing bash commands.
Features:
- Smart output truncation (preserves last N lines or M bytes)
- Kills entire process tree on timeout (prevents orphan processes)
"""
def __init__(self, cwd: str | None = None, command_prefix: str | None = None):
"""Initialize bash tool.
Args:
cwd: Working directory (defaults to current directory)
command_prefix: Optional prefix prepended to every command
"""
super().__init__()
self.cwd = cwd or os.getcwd()
self.command_prefix = command_prefix
def _build_tool_call(self) -> ToolCall:
max_kb = DEFAULT_MAX_BYTES // 1024
return ToolCall(
**{
"description": (
f"Execute a bash command in the current working directory. "
f"Returns stdout and stderr. Output is truncated to last "
f"{DEFAULT_MAX_LINES} lines or {max_kb}KB (whichever is hit first). "
f"Optionally provide a timeout in seconds."
),
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "Bash command to execute",
},
"timeout": {
"type": "number",
"description": "Timeout in seconds (optional, no default timeout)",
},
},
"required": ["command"],
},
},
)
async def execute(self) -> str:
"""Execute the bash command with production-grade features."""
command: str = self.context.command
timeout: float | None = self.context.get("timeout", None)
# Apply command prefix if configured
if self.command_prefix:
command = f"{self.command_prefix}\n{command}"
# Verify working directory exists
if not Path(self.cwd).exists():
raise FileNotFoundError(
f"Working directory does not exist: {self.cwd}\n" f"Cannot execute bash commands.",
)
# Get shell configuration
shell, shell_args = get_shell_config()
# Start process
try:
process = await asyncio.create_subprocess_exec(
shell,
*shell_args,
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=self.cwd,
# Create process group for clean termination
preexec_fn=os.setpgrp if platform.system() != "Windows" else None,
)
except Exception as e:
raise RuntimeError(f"Failed to start process: {e}") from e
# Execute command with optional timeout
try:
if timeout and timeout > 0:
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=timeout,
)
except asyncio.TimeoutError as e:
# Kill process tree on timeout
if process.pid:
kill_process_tree(process.pid)
try:
await asyncio.wait_for(process.wait(), timeout=1.0)
except asyncio.TimeoutError:
process.kill()
raise TimeoutError(f"Command timed out after {timeout} seconds") from e
else:
stdout, stderr = await process.communicate()
except TimeoutError as e:
raise RuntimeError(str(e)) from e
# Decode output
full_output = stdout.decode("utf-8", errors="ignore")
if stderr:
stderr_text = stderr.decode("utf-8", errors="ignore")
if full_output:
full_output += "\n"
full_output += stderr_text
# Apply tail truncation_result to prevent memory issues
truncation_result: TruncationResult = truncate_tail(full_output)
output_text = truncation_result.content or "(no output)"
# Build truncation_result notice if needed
if truncation_result.truncated:
start_line = truncation_result.total_lines - truncation_result.output_lines + 1
end_line = truncation_result.total_lines
if truncation_result.truncated_by == "lines":
output_text += (
f"\n\n[Output truncated: showing lines {start_line}-{end_line} "
f"of {truncation_result.total_lines} total lines]"
)
else:
max_kb = DEFAULT_MAX_BYTES // 1024
output_text += (
f"\n\n[Output truncated: showing lines {start_line}-{end_line} "
f"of {truncation_result.total_lines} ({max_kb}KB limit reached)]"
)
# Handle non-zero exit code
if process.returncode != 0:
output_text += f"\n\nCommand exited with code {process.returncode}"
raise RuntimeError(output_text)
return output_text