add a new tool called terminal

This commit is contained in:
bubble65 2025-03-12 21:21:01 +08:00
parent 067c59e39c
commit 7b48da0c59

182
app/tool/terminal.py Normal file
View File

@ -0,0 +1,182 @@
import asyncio
import os
import shlex
from typing import Optional
from app.tool.base import BaseTool, CLIResult
class Terminal(BaseTool):
name: str = "execute_command"
description: str = """Request to execute a CLI command on the system.
Use this when you need to perform system operations or run specific commands to accomplish any step in the user's task.
You must tailor your command to the user's system and provide a clear explanation of what the command does.
Prefer to execute complex CLI commands over creating executable scripts, as they are more flexible and easier to run.
Commands will be executed in the current working directory.
Note: You MUST append a `sleep 0.05` to the end of the command for commands that will complete in under 50ms, as this will circumvent a known issue with the terminal tool where it will sometimes not return the output when the command completes too quickly.
"""
parameters: dict = {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "(required) The CLI command to execute. This should be valid for the current operating system. Ensure the command is properly formatted and does not contain any harmful instructions.",
}
},
"required": ["command"],
}
process: Optional[asyncio.subprocess.Process] = None
current_path: str = os.getcwd()
lock: asyncio.Lock = asyncio.Lock()
async def execute(self, command: str) -> CLIResult:
"""
Execute a terminal command asynchronously with persistent context.
Args:
command (str): The terminal command to execute.
Returns:
str: The output, and error of the command execution.
"""
# Split the command by & to handle multiple commands
commands = [cmd.strip() for cmd in command.split('&') if cmd.strip()]
final_output = CLIResult(output="", error="")
for cmd in commands:
sanitized_command = self._sanitize_command(cmd)
# Handle 'cd' command internally
if sanitized_command.lstrip().startswith("cd "):
result = await self._handle_cd_command(sanitized_command)
else:
async with self.lock:
try:
self.process = await asyncio.create_subprocess_shell(
sanitized_command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=self.current_path,
)
stdout, stderr = await self.process.communicate()
result = CLIResult(
output=stdout.decode().strip(),
error=stderr.decode().strip()
)
except Exception as e:
result = CLIResult(output="", error=str(e))
finally:
self.process = None
# Combine outputs
if result.output:
final_output.output += (result.output + "\n") if final_output.output else result.output
if result.error:
final_output.error += (result.error + "\n") if final_output.error else result.error
# Remove trailing newlines
final_output.output = final_output.output.rstrip()
final_output.error = final_output.error.rstrip()
return final_output
async def execute_in_env(self, env_name: str, command: str) -> CLIResult:
"""
Execute a terminal command asynchronously within a specified Conda environment.
Args:
env_name (str): The name of the Conda environment.
command (str): The terminal command to execute within the environment.
Returns:
str: The output, and error of the command execution.
"""
sanitized_command = self._sanitize_command(command)
# Construct the command to run within the Conda environment
# Using 'conda run -n env_name command' to execute without activating
conda_command = f"conda run -n {shlex.quote(env_name)} {sanitized_command}"
return await self.execute(conda_command)
async def _handle_cd_command(self, command: str) -> CLIResult:
"""
Handle 'cd' commands to change the current path.
Args:
command (str): The 'cd' command to process.
Returns:
TerminalOutput: The result of the 'cd' command.
"""
try:
parts = shlex.split(command)
if len(parts) < 2:
new_path = os.path.expanduser("~")
else:
new_path = os.path.expanduser(parts[1])
# Handle relative paths
if not os.path.isabs(new_path):
new_path = os.path.join(self.current_path, new_path)
new_path = os.path.abspath(new_path)
if os.path.isdir(new_path):
self.current_path = new_path
return CLIResult(
output=f"Changed directory to {self.current_path}",
error=""
)
else:
return CLIResult(
output="",
error=f"No such directory: {new_path}"
)
except Exception as e:
return CLIResult(output="", error=str(e))
@staticmethod
def _sanitize_command(command: str) -> str:
"""
Sanitize the command for safe execution.
Args:
command (str): The command to sanitize.
Returns:
str: The sanitized command.
"""
# Example sanitization: restrict certain dangerous commands
dangerous_commands = ["rm", "sudo", "shutdown", "reboot"]
try:
parts = shlex.split(command)
if any(cmd in dangerous_commands for cmd in parts):
raise ValueError("Use of dangerous commands is restricted.")
except Exception as e:
# If shlex.split fails, try basic string comparison
if any(cmd in command for cmd in dangerous_commands):
raise ValueError("Use of dangerous commands is restricted.")
# Additional sanitization logic can be added here
return command
async def close(self):
"""Close the persistent shell process if it exists."""
async with self.lock:
if self.process:
self.process.terminate()
try:
await asyncio.wait_for(self.process.wait(), timeout=5)
except asyncio.TimeoutError:
self.process.kill()
await self.process.wait()
finally:
self.process = None
async def __aenter__(self):
"""Enter the asynchronous context manager."""
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Exit the asynchronous context manager and close the process."""
await self.close()