mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-09-14 23:21:04 +00:00
191 lines
6.8 KiB
Python
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
|