mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
283 lines
No EOL
9.3 KiB
Python
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)}",
|
|
) |