hermes-agent/atropos/tools/basic_tools.py

283 lines
No EOL
9.3 KiB
Python

"""
Basic tool implementations for atropos-agent.
These tools provide simple sandbox operations:
- BashTool: Execute shell commands
- ReadFileTool: Read file contents
- WriteFileTool: Write content to files
For PoC, these run via subprocess in the local environment.
Production usage should use proper sandbox isolation.
"""
import asyncio
import os
from pathlib import Path
from typing import Optional
from .base import Tool, ToolResult, ToolSchema
class BashTool(Tool):
"""
Execute bash commands in a sandboxed environment.
TODO: Nomad slot execution
"""
def __init__(
self,
working_dir: Optional[str] = None,
timeout: float = 30.0,
max_output_size: int = 10000,
):
self.working_dir = working_dir or os.getcwd()
self.timeout = timeout
self.max_output_size = max_output_size
@property
def schema(self) -> ToolSchema:
return ToolSchema(
name="bash",
description="Execute a bash command and return stdout/stderr. Use for running shell commands, scripts, or system operations.",
parameters={
"command": {
"type": "string",
"description": "The bash command to execute",
},
},
required=["command"],
)
async def execute(self, command: str) -> ToolResult:
"""Execute a bash command."""
try:
process = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=self.working_dir,
)
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=self.timeout,
)
except asyncio.TimeoutError:
process.kill()
await process.wait()
return ToolResult(
success=False,
error=f"Command timed out after {self.timeout}s",
metadata={"exit_code": -1, "timeout": True},
)
stdout_str = stdout.decode("utf-8", errors="replace")
stderr_str = stderr.decode("utf-8", errors="replace")
# Truncate if too long
if len(stdout_str) > self.max_output_size:
stdout_str = stdout_str[:self.max_output_size] + "\n... (output truncated)"
if len(stderr_str) > self.max_output_size:
stderr_str = stderr_str[:self.max_output_size] + "\n... (output truncated)"
exit_code = process.returncode
success = exit_code == 0
output = stdout_str
if stderr_str:
output = f"{stdout_str}\n[stderr]\n{stderr_str}" if stdout_str else stderr_str
return ToolResult(
success=success,
output=output.strip(),
error="" if success else f"Exit code: {exit_code}",
metadata={"exit_code": exit_code},
)
except Exception as e:
return ToolResult(
success=False,
error=f"Failed to execute command: {str(e)}",
)
class ReadFileTool(Tool):
"""Read the contents of a file."""
def __init__(
self,
working_dir: Optional[str] = None,
max_file_size: int = 100000,
):
self.working_dir = Path(working_dir) if working_dir else Path.cwd()
self.max_file_size = max_file_size
@property
def schema(self) -> ToolSchema:
return ToolSchema(
name="read_file",
description="Read the contents of a file at the given path.",
parameters={
"path": {
"type": "string",
"description": "Path to the file (relative to working directory)",
},
},
required=["path"],
)
async def execute(self, path: str) -> ToolResult:
"""Read a file's contents."""
try:
file_path = self.working_dir / path
# Security: prevent path traversal outside working dir
resolved = file_path.resolve()
if not str(resolved).startswith(str(self.working_dir.resolve())):
return ToolResult(
success=False,
error="Access denied: path outside working directory",
)
if not resolved.exists():
return ToolResult(
success=False,
error=f"File not found: {path}",
)
if not resolved.is_file():
return ToolResult(
success=False,
error=f"Not a file: {path}",
)
size = resolved.stat().st_size
if size > self.max_file_size:
return ToolResult(
success=False,
error=f"File too large: {size} bytes (max {self.max_file_size})",
)
content = resolved.read_text(encoding="utf-8", errors="replace")
return ToolResult(
success=True,
output=content,
metadata={"path": str(resolved), "size": size},
)
except Exception as e:
return ToolResult(
success=False,
error=f"Failed to read file: {str(e)}",
)
class WriteFileTool(Tool):
"""Write content to a file."""
def __init__(
self,
working_dir: Optional[str] = None,
max_file_size: int = 100000,
):
self.working_dir = Path(working_dir) if working_dir else Path.cwd()
self.max_file_size = max_file_size
@property
def schema(self) -> ToolSchema:
return ToolSchema(
name="write_file",
description="Write content to a file at the given path. Creates parent directories if needed.",
parameters={
"path": {
"type": "string",
"description": "Path to the file (relative to working directory)",
},
"content": {
"type": "string",
"description": "Content to write to the file",
},
},
required=["path", "content"],
)
async def execute(self, path: str, content: str) -> ToolResult:
"""Write content to a file."""
try:
if len(content) > self.max_file_size:
return ToolResult(
success=False,
error=f"Content too large: {len(content)} bytes (max {self.max_file_size})",
)
file_path = self.working_dir / path
# Security: prevent path traversal outside working dir
resolved = file_path.resolve()
if not str(resolved).startswith(str(self.working_dir.resolve())):
return ToolResult(
success=False,
error="Access denied: path outside working directory",
)
# Create parent directories
resolved.parent.mkdir(parents=True, exist_ok=True)
resolved.write_text(content, encoding="utf-8")
return ToolResult(
success=True,
output=f"Successfully wrote {len(content)} bytes to {path}",
metadata={"path": str(resolved), "size": len(content)},
)
except Exception as e:
return ToolResult(
success=False,
error=f"Failed to write file: {str(e)}",
)
class FireCrawl(Tool):
"""Perform a web crawl using FireCrawl tool."""
def __init__(
self,
working_dir: Optional[str] = None,
timeout: float = 60.0,
):
self.working_dir = working_dir or os.getcwd()
self.timeout = timeout
@property
def schema(self) -> ToolSchema:
return ToolSchema(
name="firecrawl",
description="Perform a web crawl starting from a given URL using FireCrawl.",
parameters={
"start_url": {
"type": "string",
"description": "The starting URL for the web crawl",
},
"max_pages": {
"type": "integer",
"description": "Maximum number of pages to crawl",
},
},
required=["start_url"],
)
async def execute(self, start_url: str, max_pages: int = 100) -> ToolResult:
"""Execute a web crawl using FireCrawl."""
try:
command = f"firecrawl --start-url {start_url} --max-pages {max_pages}"
bash_tool = BashTool(working_dir=self.working_dir, timeout=self.timeout)
result = await bash_tool.execute(command)
return result
except Exception as e:
return ToolResult(
success=False,
error=f"Failed to execute FireCrawl: {str(e)}",
)