Update environment configuration and enhance terminal tool integration

- Updated `.env.example` to include new API keys and configuration options for the mini-swe-agent backend, including support for local, Docker, and Modal environments.
- Added `.gitmodules` to include mini-swe-agent as a submodule for easier integration.
- Refactored `mini_swe_runner.py` to use the updated model format and default to OpenRouter for API calls.
- Enhanced `model_tools.py` to support the new terminal tool definitions and ensure compatibility with the mini-swe-agent backend.
- Updated `README.md` to reflect changes in setup instructions and environment variable configurations.
- Improved `terminal_tool.py` to manage execution environments and lifecycle, ensuring proper cleanup and error handling.
- Introduced `terminal_hecate.py` for executing commands on MorphCloud VMs, providing an alternative backend for terminal operations.
This commit is contained in:
teknium 2026-01-23 12:26:53 +00:00
parent 47555602d7
commit ba19d530ad
11 changed files with 548 additions and 473 deletions

View file

@ -1,14 +1,20 @@
# Hermes Agent Environment Configuration # Hermes Agent Environment Configuration
# Copy this file to .env and fill in your API keys # Copy this file to .env and fill in your API keys
# Get API keys from the URLs listed below
# ============================================================================= # =============================================================================
# REQUIRED API KEYS # LLM PROVIDER (OpenRouter - Primary)
# ============================================================================= # =============================================================================
# OpenRouter provides access to many models through one API
# Get at: https://openrouter.ai/keys
OPENROUTER_API_KEY=
# Anthropic API Key - Main agent model # Default model to use (OpenRouter format: provider/model)
# Get at: https://console.anthropic.com/ # Examples: anthropic/claude-sonnet-4, openai/gpt-4o, google/gemini-2.0-flash
ANTHROPIC_API_KEY= LLM_MODEL=anthropic/claude-sonnet-4
# =============================================================================
# TOOL API KEYS
# =============================================================================
# Firecrawl API Key - Web search, extract, and crawl # Firecrawl API Key - Web search, extract, and crawl
# Get at: https://firecrawl.dev/ # Get at: https://firecrawl.dev/
@ -18,31 +24,58 @@ FIRECRAWL_API_KEY=
# Get at: https://inference-api.nousresearch.com/ # Get at: https://inference-api.nousresearch.com/
NOUS_API_KEY= NOUS_API_KEY=
# Morph API Key - Terminal/command execution tools
# Get at: https://morph.so/
MORPH_API_KEY=
# FAL.ai API Key - Image generation # FAL.ai API Key - Image generation
# Get at: https://fal.ai/ # Get at: https://fal.ai/
FAL_KEY= FAL_KEY=
# ============================================================================= # =============================================================================
# OPTIONAL API KEYS # TERMINAL TOOL CONFIGURATION (mini-swe-agent backend)
# ============================================================================= # =============================================================================
# Backend type: "local", "docker", or "modal"
# - local: Runs directly on your machine (fastest, no isolation)
# - docker: Runs in Docker containers (isolated, requires Docker installed)
# - modal: Runs in Modal cloud sandboxes (scalable, requires Modal account)
TERMINAL_ENV=docker
# OpenAI API Key - Optional, for enhanced Hecate features # Docker image to use (for docker and modal backends)
# Get at: https://platform.openai.com/ TERMINAL_DOCKER_IMAGE=python:3.11-slim
OPENAI_API_KEY=
# Working directory inside the container
TERMINAL_CWD=/tmp
# Default command timeout in seconds
TERMINAL_TIMEOUT=60
# Cleanup inactive environments after this many seconds
TERMINAL_LIFETIME_SECONDS=300
# ============================================================================= # =============================================================================
# OPTIONAL CONFIGURATION # MODAL CLOUD BACKEND (Optional - for TERMINAL_ENV=modal)
# =============================================================================
# Modal uses CLI authentication, not environment variables.
# Run: pip install modal && modal setup
# This will authenticate via browser and store credentials locally.
# No API key needed in .env - Modal handles auth automatically.
# =============================================================================
# LEGACY/OPTIONAL API KEYS
# ============================================================================= # =============================================================================
# Terminal Tool Settings # Morph API Key - For legacy Hecate terminal backend (terminal-hecate tool)
# Get at: https://morph.so/
MORPH_API_KEY=
# Hecate VM Settings (only if using terminal-hecate tool)
HECATE_VM_LIFETIME_SECONDS=300 HECATE_VM_LIFETIME_SECONDS=300
HECATE_DEFAULT_SNAPSHOT_ID=snapshot_p5294qxt HECATE_DEFAULT_SNAPSHOT_ID=snapshot_p5294qxt
# Debug Logging (set to "true" to enable, logs saved to ./logs/) # Direct provider keys (optional - OpenRouter is preferred)
ANTHROPIC_API_KEY=
OPENAI_API_KEY=
# =============================================================================
# DEBUG OPTIONS
# =============================================================================
WEB_TOOLS_DEBUG=false WEB_TOOLS_DEBUG=false
VISION_TOOLS_DEBUG=false VISION_TOOLS_DEBUG=false
MOA_TOOLS_DEBUG=false MOA_TOOLS_DEBUG=false

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "mini-swe-agent"]
path = mini-swe-agent
url = https://github.com/SWE-agent/mini-swe-agent

108
README.md
View file

@ -5,7 +5,7 @@ An AI agent with advanced tool-calling capabilities, featuring a flexible toolse
## Features ## Features
- **Web Tools**: Search, extract content, and crawl websites - **Web Tools**: Search, extract content, and crawl websites
- **Terminal Tools**: Execute commands with interactive session support - **Terminal Tools**: Execute commands via mini-swe-agent (local, Docker, or Modal backends)
- **Vision Tools**: Analyze images from URLs - **Vision Tools**: Analyze images from URLs
- **Reasoning Tools**: Advanced multi-model reasoning (Mixture of Agents) - **Reasoning Tools**: Advanced multi-model reasoning (Mixture of Agents)
- **Creative Tools**: Generate images from text prompts - **Creative Tools**: Generate images from text prompts
@ -15,7 +15,17 @@ An AI agent with advanced tool-calling capabilities, featuring a flexible toolse
## Setup ## Setup
### 1. Install Dependencies ### 1. Clone the Repository
```bash
# Clone with submodules (recommended)
git clone --recurse-submodules https://github.com/NousResearch/Hermes-Agent.git
cd Hermes-Agent
# Or if already cloned without submodules:
git submodule update --init --recursive
```
### 2. Install Dependencies
```bash ```bash
# Create and activate virtual environment (recommended) # Create and activate virtual environment (recommended)
python3 -m venv venv python3 -m venv venv
@ -24,14 +34,11 @@ source venv/bin/activate # On Windows: venv\Scripts\activate
# Install required packages # Install required packages
pip install -r requirements.txt pip install -r requirements.txt
# Install Hecate for terminal tools # Install mini-swe-agent for terminal tools
git clone git@github.com:NousResearch/hecate.git pip install -e ./mini-swe-agent
cd hecate
pip install -e .
cd ..
``` ```
### 2. Configure Environment Variables ### 3. Configure Environment Variables
```bash ```bash
# Copy the example environment file # Copy the example environment file
cp .env.example .env cp .env.example .env
@ -41,14 +48,54 @@ nano .env # or use your preferred editor
``` ```
**Required API Keys:** **Required API Keys:**
- `ANTHROPIC_API_KEY` - Main agent model (get at: https://console.anthropic.com/) - `OPENROUTER_API_KEY` - LLM access via OpenRouter (get at: https://openrouter.ai/keys)
- `FIRECRAWL_API_KEY` - Web tools (get at: https://firecrawl.dev/) - `FIRECRAWL_API_KEY` - Web tools (get at: https://firecrawl.dev/)
- `NOUS_API_KEY` - Vision & reasoning tools (get at: https://inference-api.nousresearch.com/) - `NOUS_API_KEY` - Vision & reasoning tools (get at: https://inference-api.nousresearch.com/)
- `MORPH_API_KEY` - Terminal tools (get at: https://morph.so/)
- `FAL_KEY` - Image generation (get at: https://fal.ai/) - `FAL_KEY` - Image generation (get at: https://fal.ai/)
- `OPENAI_API_KEY` - Optional, for some Hecate features
See `.env.example` for all available configuration options including debug settings and terminal tool configuration. **Optional API Keys:**
- `ANTHROPIC_API_KEY` - Direct Anthropic access (if not using OpenRouter)
- `OPENAI_API_KEY` - Direct OpenAI access (if not using OpenRouter)
- `MORPH_API_KEY` - For legacy Hecate terminal backend (get at: https://morph.so/)
### 4. Configure Terminal Backend
The terminal tool uses **mini-swe-agent** environments. Configure in `.env`:
```bash
# Backend: "local" (host machine), "docker" (containers), or "modal" (cloud)
TERMINAL_ENV=local # Default: runs on host machine
TERMINAL_ENV=docker # Recommended: isolated Docker containers
TERMINAL_ENV=modal # Cloud execution via Modal
# Docker settings (for docker/modal backends)
TERMINAL_DOCKER_IMAGE=python:3.11-slim
TERMINAL_TIMEOUT=60
```
**Backend Requirements:**
- **local**: No extra setup (runs directly on your machine)
- **docker**: Requires Docker installed and running. User must be in `docker` group.
- **modal**: Requires Modal account (see setup below)
### Modal Cloud Backend Setup
[Modal](https://modal.com) provides serverless cloud compute for running sandboxed environments at scale.
```bash
# 1. Install Modal and dependencies
pip install modal boto3
# 2. Authenticate with Modal (opens browser)
modal setup
# 3. Set terminal backend to modal in .env
TERMINAL_ENV=modal
```
Modal uses CLI-based authentication (stored in `~/.modal/`), so no API key is needed in `.env`. After running `modal setup`, commands will automatically execute in Modal's cloud sandboxes.
See `.env.example` for all available configuration options including debug settings.
## Toolsets System ## Toolsets System
@ -94,12 +141,11 @@ For detailed documentation on toolsets, see `TOOLSETS_README.md`.
### Default (all tools enabled) ### Default (all tools enabled)
```bash ```bash
# Uses OpenRouter by default - just set OPENROUTER_API_KEY in .env
python run_agent.py \ python run_agent.py \
--query "search up the latest docs on jit in python 3.13 and write me basic example that's not in their docs. profile its perf" \ --query "search up the latest docs on jit in python 3.13 and write me basic example that's not in their docs. profile its perf" \
--max_turns 20 \ --max_turns 20 \
--model claude-sonnet-4-20250514 \ --model anthropic/claude-sonnet-4-20250514
--base_url https://api.anthropic.com/v1/ \
--api_key $ANTHROPIC_API_KEY
``` ```
### With specific toolset ### With specific toolset
@ -107,17 +153,16 @@ python run_agent.py \
python run_agent.py \ python run_agent.py \
--query "Debug this Python error" \ --query "Debug this Python error" \
--enabled_toolsets=debugging \ --enabled_toolsets=debugging \
--model claude-sonnet-4-20250514 \ --model anthropic/claude-sonnet-4-20250514
--api_key $ANTHROPIC_API_KEY
``` ```
### Python API ### Python API
```python ```python
from run_agent import AIAgent from run_agent import AIAgent
# Use a specific toolset # Uses OpenRouter by default (reads OPENROUTER_API_KEY from .env)
agent = AIAgent( agent = AIAgent(
model="claude-opus-4-20250514", model="anthropic/claude-sonnet-4-20250514",
enabled_toolsets=["research"] enabled_toolsets=["research"]
) )
response = agent.chat("Find information about quantum computing") response = agent.chat("Find information about quantum computing")
@ -213,17 +258,32 @@ The ephemeral prompt will influence the model's behavior during execution, but *
All environment variables can be configured in the `.env` file (copy from `.env.example`). All environment variables can be configured in the `.env` file (copy from `.env.example`).
**Core API Keys:** **LLM Provider (OpenRouter):**
- `ANTHROPIC_API_KEY`: Main agent model - `OPENROUTER_API_KEY`: Primary LLM access via OpenRouter (supports Claude, GPT-4, Gemini, etc.)
- `LLM_MODEL`: Default model (e.g., `anthropic/claude-sonnet-4`, `openai/gpt-4o`)
**Tool API Keys:**
- `FIRECRAWL_API_KEY`: Web tools (search, extract, crawl) - `FIRECRAWL_API_KEY`: Web tools (search, extract, crawl)
- `NOUS_API_KEY`: Vision and reasoning tools - `NOUS_API_KEY`: Vision and reasoning tools
- `MORPH_API_KEY`: Terminal tools
- `FAL_KEY`: Image generation tools - `FAL_KEY`: Image generation tools
- `OPENAI_API_KEY`: Optional, for some Hecate features
**Configuration Options:** **Optional Direct Provider Keys:**
- `ANTHROPIC_API_KEY`: Direct Anthropic access (fallback if OpenRouter not set)
- `OPENAI_API_KEY`: Direct OpenAI access (fallback if OpenRouter not set)
**Terminal Tool Configuration (mini-swe-agent backend):**
- `TERMINAL_ENV`: Backend type - `local`, `docker`, or `modal` (default: `local`)
- `TERMINAL_DOCKER_IMAGE`: Docker image to use (default: `python:3.11-slim`)
- `TERMINAL_TIMEOUT`: Command timeout in seconds (default: `60`)
- `TERMINAL_LIFETIME_SECONDS`: Cleanup inactive environments after this time (default: `300`)
- `TERMINAL_CWD`: Working directory inside containers (default: `/tmp`)
**Legacy Hecate Terminal Backend (optional):**
- `MORPH_API_KEY`: For Hecate/MorphCloud terminal backend
- `HECATE_VM_LIFETIME_SECONDS`: VM lifetime (default: 300) - `HECATE_VM_LIFETIME_SECONDS`: VM lifetime (default: 300)
- `HECATE_DEFAULT_SNAPSHOT_ID`: Default snapshot (default: snapshot_p5294qxt) - `HECATE_DEFAULT_SNAPSHOT_ID`: Default snapshot (default: snapshot_p5294qxt)
**Debug Options:**
- `WEB_TOOLS_DEBUG`, `VISION_TOOLS_DEBUG`, `MOA_TOOLS_DEBUG`, `IMAGE_TOOLS_DEBUG`: Enable debug logging - `WEB_TOOLS_DEBUG`, `VISION_TOOLS_DEBUG`, `MOA_TOOLS_DEBUG`, `IMAGE_TOOLS_DEBUG`: Enable debug logging
## Documentation ## Documentation

1
mini-swe-agent Submodule

@ -0,0 +1 @@
Subproject commit 07aa6a738556e44b30d7b5c3bbd5063dac871d25

View file

@ -149,7 +149,7 @@ class MiniSWERunner:
def __init__( def __init__(
self, self,
model: str = "claude-sonnet-4-20250514", model: str = "anthropic/claude-sonnet-4-20250514",
base_url: str = None, base_url: str = None,
api_key: str = None, api_key: str = None,
env_type: str = "local", env_type: str = "local",
@ -189,14 +189,18 @@ class MiniSWERunner:
) )
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
# Initialize OpenAI client # Initialize OpenAI client - defaults to OpenRouter
from openai import OpenAI from openai import OpenAI
client_kwargs = {} client_kwargs = {}
# Default to OpenRouter if no base_url provided
if base_url: if base_url:
client_kwargs["base_url"] = base_url client_kwargs["base_url"] = base_url
else:
client_kwargs["base_url"] = "https://openrouter.ai/api/v1"
# Handle API key with fallbacks # Handle API key - OpenRouter is the primary provider
if api_key: if api_key:
client_kwargs["api_key"] = api_key client_kwargs["api_key"] = api_key
else: else:

View file

@ -31,9 +31,9 @@ import asyncio
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
from tools.web_tools import web_search_tool, web_extract_tool, web_crawl_tool, check_firecrawl_api_key from tools.web_tools import web_search_tool, web_extract_tool, web_crawl_tool, check_firecrawl_api_key
from tools.simple_terminal_tool import simple_terminal_tool, check_requirements as check_simple_terminal_requirements, SIMPLE_TERMINAL_TOOL_DESCRIPTION from tools.terminal_tool import terminal_tool, check_terminal_requirements, TERMINAL_TOOL_DESCRIPTION, cleanup_vm
# Keep old terminal tool for backwards compatibility if needed # Hecate/MorphCloud terminal tool (cloud VMs) - available as alternative backend
# from tools.terminal_tool import terminal_tool, check_hecate_requirements, TERMINAL_TOOL_DESCRIPTION from tools.terminal_hecate import terminal_hecate_tool, check_hecate_requirements, TERMINAL_HECATE_DESCRIPTION
from tools.vision_tools import vision_analyze_tool, check_vision_requirements from tools.vision_tools import vision_analyze_tool, check_vision_requirements
from tools.mixture_of_agents_tool import mixture_of_agents_tool, check_moa_requirements from tools.mixture_of_agents_tool import mixture_of_agents_tool, check_moa_requirements
from tools.image_generation_tool import image_generate_tool, check_image_generation_requirements from tools.image_generation_tool import image_generate_tool, check_image_generation_requirements
@ -114,6 +114,8 @@ def get_terminal_tool_definitions() -> List[Dict[str, Any]]:
""" """
Get tool definitions for terminal tools in OpenAI's expected format. Get tool definitions for terminal tools in OpenAI's expected format.
Uses mini-swe-agent backend (local/docker/modal) by default.
Returns: Returns:
List[Dict]: List of terminal tool definitions compatible with OpenAI API List[Dict]: List of terminal tool definitions compatible with OpenAI API
""" """
@ -122,7 +124,7 @@ def get_terminal_tool_definitions() -> List[Dict[str, Any]]:
"type": "function", "type": "function",
"function": { "function": {
"name": "terminal", "name": "terminal",
"description": SIMPLE_TERMINAL_TOOL_DESCRIPTION, "description": TERMINAL_TOOL_DESCRIPTION,
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -255,8 +257,8 @@ def get_all_tool_names() -> List[str]:
if check_firecrawl_api_key(): if check_firecrawl_api_key():
tool_names.extend(["web_search", "web_extract", "web_crawl"]) tool_names.extend(["web_search", "web_extract", "web_crawl"])
# Terminal tools # Terminal tools (mini-swe-agent backend)
if check_simple_terminal_requirements(): if check_terminal_requirements():
tool_names.extend(["terminal"]) tool_names.extend(["terminal"])
# Vision tools # Vision tools
@ -339,7 +341,7 @@ def get_tool_definitions(
for tool in get_web_tool_definitions(): for tool in get_web_tool_definitions():
all_available_tools_map[tool["function"]["name"]] = tool all_available_tools_map[tool["function"]["name"]] = tool
if check_simple_terminal_requirements(): if check_terminal_requirements():
for tool in get_terminal_tool_definitions(): for tool in get_terminal_tool_definitions():
all_available_tools_map[tool["function"]["name"]] = tool all_available_tools_map[tool["function"]["name"]] = tool
@ -476,10 +478,12 @@ def handle_terminal_function_call(function_name: str, function_args: Dict[str, A
""" """
Handle function calls for terminal tools. Handle function calls for terminal tools.
Uses mini-swe-agent backend (local/docker/modal) by default.
Args: Args:
function_name (str): Name of the terminal function to call function_name (str): Name of the terminal function to call
function_args (Dict): Arguments for the function function_args (Dict): Arguments for the function
task_id (str): Unique identifier for this task to isolate VMs between concurrent tasks (optional) task_id (str): Unique identifier for this task to isolate environments between concurrent tasks (optional)
Returns: Returns:
str: Function result as JSON string str: Function result as JSON string
@ -489,7 +493,7 @@ def handle_terminal_function_call(function_name: str, function_args: Dict[str, A
background = function_args.get("background", False) background = function_args.get("background", False)
timeout = function_args.get("timeout") timeout = function_args.get("timeout")
return simple_terminal_tool(command=command, background=background, timeout=timeout, task_id=task_id) return terminal_tool(command=command, background=background, timeout=timeout, task_id=task_id)
else: else:
return json.dumps({"error": f"Unknown terminal function: {function_name}"}, ensure_ascii=False) return json.dumps({"error": f"Unknown terminal function: {function_name}"}, ensure_ascii=False)
@ -665,10 +669,10 @@ def get_available_toolsets() -> Dict[str, Dict[str, Any]]:
"requirements": ["FIRECRAWL_API_KEY environment variable"] "requirements": ["FIRECRAWL_API_KEY environment variable"]
}, },
"terminal_tools": { "terminal_tools": {
"available": check_simple_terminal_requirements(), "available": check_terminal_requirements(),
"tools": ["simple_terminal_tool"], "tools": ["terminal_tool"],
"description": "Execute commands on secure Linux VMs without session persistence", "description": "Execute commands using mini-swe-agent (local/docker/modal)",
"requirements": ["MORPH_API_KEY environment variable"] "requirements": ["mini-swe-agent package, TERMINAL_ENV to select backend"]
}, },
"vision_tools": { "vision_tools": {
"available": check_vision_requirements(), "available": check_vision_requirements(),
@ -701,7 +705,7 @@ def check_toolset_requirements() -> Dict[str, bool]:
""" """
return { return {
"web_tools": check_firecrawl_api_key(), "web_tools": check_firecrawl_api_key(),
"terminal_tools": check_simple_terminal_requirements(), "terminal_tools": check_terminal_requirements(),
"vision_tools": check_vision_requirements(), "vision_tools": check_vision_requirements(),
"moa_tools": check_moa_requirements(), "moa_tools": check_moa_requirements(),
"image_tools": check_image_generation_requirements() "image_tools": check_image_generation_requirements()

View file

@ -1,10 +1,33 @@
firecrawl-py # Core dependencies
openai openai
fal-client
fire
git@github.com:NousResearch/hecate.git
tenacity
python-dotenv python-dotenv
fire fire
httpx httpx
rich rich
tenacity
# Web tools
firecrawl-py
# Image generation
fal-client
# mini-swe-agent dependencies (for terminal tool)
# Note: Install mini-swe-agent itself with: pip install -e ./mini-swe-agent
pyyaml
requests
jinja2
pydantic>=2.0
litellm>=1.75.5
typer
platformdirs
# Optional: For Docker backend (recommended)
# Requires Docker installed and user in 'docker' group
# Optional: For Modal backend (cloud execution)
# modal
# boto3
# Optional: Legacy Hecate terminal backend
# git+ssh://git@github.com/NousResearch/hecate.git

View file

@ -43,7 +43,7 @@ else:
# Import our tool system # Import our tool system
from model_tools import get_tool_definitions, handle_function_call, check_toolset_requirements from model_tools import get_tool_definitions, handle_function_call, check_toolset_requirements
from tools.simple_terminal_tool import cleanup_vm from tools.terminal_tool import cleanup_vm
class AIAgent: class AIAgent:
@ -58,7 +58,7 @@ class AIAgent:
self, self,
base_url: str = None, base_url: str = None,
api_key: str = None, api_key: str = None,
model: str = "gpt-4", model: str = "anthropic/claude-sonnet-4-20250514",
max_iterations: int = 10, max_iterations: int = 10,
tool_delay: float = 1.0, tool_delay: float = 1.0,
enabled_toolsets: List[str] = None, enabled_toolsets: List[str] = None,
@ -142,22 +142,24 @@ class AIAgent:
logging.getLogger('httpx').setLevel(logging.ERROR) logging.getLogger('httpx').setLevel(logging.ERROR)
logging.getLogger('httpcore').setLevel(logging.ERROR) logging.getLogger('httpcore').setLevel(logging.ERROR)
# Initialize OpenAI client # Initialize OpenAI client - defaults to OpenRouter
client_kwargs = {} client_kwargs = {}
# Default to OpenRouter if no base_url provided
if base_url: if base_url:
client_kwargs["base_url"] = base_url client_kwargs["base_url"] = base_url
else:
client_kwargs["base_url"] = "https://openrouter.ai/api/v1"
# Handle API key with multiple fallbacks # Handle API key - OpenRouter is the primary provider
if api_key: if api_key:
client_kwargs["api_key"] = api_key client_kwargs["api_key"] = api_key
else: else:
# Try multiple common API key environment variables based on base_url # Primary: OPENROUTER_API_KEY, fallback to direct provider keys
if base_url and "openrouter" in base_url.lower(): client_kwargs["api_key"] = os.getenv(
client_kwargs["api_key"] = os.getenv("OPENROUTER_API_KEY", os.getenv("ANTHROPIC_API_KEY", "dummy-key")) "OPENROUTER_API_KEY",
elif base_url and "anthropic" in base_url.lower(): os.getenv("ANTHROPIC_API_KEY", os.getenv("OPENAI_API_KEY", ""))
client_kwargs["api_key"] = os.getenv("ANTHROPIC_API_KEY", os.getenv("OPENAI_API_KEY", "dummy-key")) )
else:
client_kwargs["api_key"] = os.getenv("ANTHROPIC_API_KEY", os.getenv("OPENAI_API_KEY", "dummy-key"))
try: try:
self.client = OpenAI(**client_kwargs) self.client = OpenAI(**client_kwargs)

View file

@ -6,7 +6,8 @@ This package contains all the specific tool implementations for the Hermes Agent
Each module provides specialized functionality for different capabilities: Each module provides specialized functionality for different capabilities:
- web_tools: Web search, content extraction, and crawling - web_tools: Web search, content extraction, and crawling
- simple_terminal_tool: Simple command execution on virtual machines (no session persistence) - terminal_tool: Command execution using mini-swe-agent (local/docker/modal backends)
- terminal_hecate: Command execution on MorphCloud/Hecate cloud VMs (alternative backend)
- vision_tools: Image analysis and understanding - vision_tools: Image analysis and understanding
- mixture_of_agents_tool: Multi-model collaborative reasoning - mixture_of_agents_tool: Multi-model collaborative reasoning
- image_generation_tool: Text-to-image generation with upscaling - image_generation_tool: Text-to-image generation with upscaling
@ -23,11 +24,19 @@ from .web_tools import (
check_firecrawl_api_key check_firecrawl_api_key
) )
from .simple_terminal_tool import ( # Primary terminal tool (mini-swe-agent backend: local/docker/modal)
simple_terminal_tool, from .terminal_tool import (
check_requirements as check_terminal_requirements, terminal_tool,
check_terminal_requirements,
cleanup_vm, cleanup_vm,
SIMPLE_TERMINAL_TOOL_DESCRIPTION TERMINAL_TOOL_DESCRIPTION
)
# Alternative terminal tool (Hecate/MorphCloud cloud VMs)
from .terminal_hecate import (
terminal_hecate_tool,
check_hecate_requirements,
TERMINAL_HECATE_DESCRIPTION
) )
from .vision_tools import ( from .vision_tools import (
@ -51,11 +60,15 @@ __all__ = [
'web_extract_tool', 'web_extract_tool',
'web_crawl_tool', 'web_crawl_tool',
'check_firecrawl_api_key', 'check_firecrawl_api_key',
# Terminal tools (simple - no session persistence) # Terminal tools (mini-swe-agent backend)
'simple_terminal_tool', 'terminal_tool',
'check_terminal_requirements', 'check_terminal_requirements',
'cleanup_vm', 'cleanup_vm',
'SIMPLE_TERMINAL_TOOL_DESCRIPTION', 'TERMINAL_TOOL_DESCRIPTION',
# Terminal tools (Hecate/MorphCloud backend)
'terminal_hecate_tool',
'check_hecate_requirements',
'TERMINAL_HECATE_DESCRIPTION',
# Vision tools # Vision tools
'vision_analyze_tool', 'vision_analyze_tool',
'check_vision_requirements', 'check_vision_requirements',

View file

@ -1,24 +1,24 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Simple Terminal Tool Module Terminal Hecate Tool Module
A simplified terminal tool that executes commands on MorphCloud VMs without tmux. A terminal tool that executes commands on MorphCloud/Hecate VMs.
No session persistence, no interactive app support - just simple command execution. Uses E2B-style cloud VMs for execution with automatic lifecycle management.
Features: Features:
- Direct SSH command execution - Direct SSH command execution on cloud VMs
- Background task support - Background task support
- VM lifecycle management with TTL - VM lifecycle management with TTL
- Automatic cleanup after inactivity - Automatic cleanup after inactivity
Usage: Usage:
from simple_terminal_tool import simple_terminal_tool from terminal_hecate import terminal_hecate_tool
# Execute a simple command # Execute a simple command
result = simple_terminal_tool("ls -la") result = terminal_hecate_tool("ls -la")
# Execute in background # Execute in background
result = simple_terminal_tool("python server.py", background=True) result = terminal_hecate_tool("python server.py", background=True)
""" """
import json import json
@ -29,7 +29,7 @@ import atexit
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
# Tool description for LLM # Tool description for LLM
SIMPLE_TERMINAL_TOOL_DESCRIPTION = """Execute commands on a secure Linux VM environment. TERMINAL_HECATE_DESCRIPTION = """Execute commands on a secure cloud Linux VM environment (Hecate/MorphCloud).
**Environment:** **Environment:**
- Minimal Debian-based OS with internet access - Minimal Debian-based OS with internet access
@ -223,14 +223,14 @@ def _execute_command(instance, command: str, timeout: Optional[int] = None) -> D
} }
def simple_terminal_tool( def terminal_hecate_tool(
command: str, command: str,
background: bool = False, background: bool = False,
timeout: Optional[int] = None, timeout: Optional[int] = None,
task_id: Optional[str] = None task_id: Optional[str] = None
) -> str: ) -> str:
""" """
Execute a command on a MorphCloud VM without session persistence. Execute a command on a MorphCloud/Hecate VM without session persistence.
Args: Args:
command: The command to execute command: The command to execute
@ -243,13 +243,13 @@ def simple_terminal_tool(
Examples: Examples:
# Execute a simple command # Execute a simple command
>>> result = simple_terminal_tool(command="ls -la /tmp") >>> result = terminal_hecate_tool(command="ls -la /tmp")
# Run a background task # Run a background task
>>> result = simple_terminal_tool(command="python server.py", background=True) >>> result = terminal_hecate_tool(command="python server.py", background=True)
# With custom timeout # With custom timeout
>>> result = simple_terminal_tool(command="long_task.sh", timeout=300) >>> result = terminal_hecate_tool(command="long_task.sh", timeout=300)
""" """
global _active_instances, _last_activity global _active_instances, _last_activity
@ -393,8 +393,8 @@ def simple_terminal_tool(
}, ensure_ascii=False) }, ensure_ascii=False)
def check_requirements() -> bool: def check_hecate_requirements() -> bool:
"""Check if all requirements for the simple terminal tool are met.""" """Check if all requirements for the Hecate terminal tool are met."""
required_vars = ["MORPH_API_KEY"] required_vars = ["MORPH_API_KEY"]
missing_required = [var for var in required_vars if not os.getenv(var)] missing_required = [var for var in required_vars if not os.getenv(var)]
@ -412,23 +412,23 @@ def check_requirements() -> bool:
if __name__ == "__main__": if __name__ == "__main__":
"""Simple test when run directly.""" """Simple test when run directly."""
print("Simple Terminal Tool Module") print("Terminal Hecate Tool Module (MorphCloud/E2B)")
print("=" * 40) print("=" * 40)
if not check_requirements(): if not check_hecate_requirements():
print("Requirements not met. Please check the messages above.") print("Requirements not met. Please check the messages above.")
exit(1) exit(1)
print("All requirements met!") print("All requirements met!")
print("\nAvailable Tool:") print("\nAvailable Tool:")
print(" - simple_terminal_tool: Execute commands without session persistence") print(" - terminal_hecate_tool: Execute commands on cloud VMs")
print("\nUsage Examples:") print("\nUsage Examples:")
print(" # Execute a command") print(" # Execute a command")
print(" result = simple_terminal_tool(command='ls -la')") print(" result = terminal_hecate_tool(command='ls -la')")
print(" ") print(" ")
print(" # Run a background task") print(" # Run a background task")
print(" result = simple_terminal_tool(command='python server.py', background=True)") print(" result = terminal_hecate_tool(command='python server.py', background=True)")
print("\nEnvironment Variables:") print("\nEnvironment Variables:")
print(f" MORPH_API_KEY: {'Set' if os.getenv('MORPH_API_KEY') else 'Not set'}") print(f" MORPH_API_KEY: {'Set' if os.getenv('MORPH_API_KEY') else 'Not set'}")

View file

@ -1,514 +1,446 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Terminal Tool Module Terminal Tool Module (mini-swe-agent backend)
This module provides a single terminal tool using Hecate's VM infrastructure. A terminal tool that executes commands using mini-swe-agent's execution environments.
It wraps Hecate's functionality to provide a simple interface for executing commands Supports local execution, Docker containers, and Modal cloud sandboxes.
on Morph VMs with automatic lifecycle management.
VM Lifecycle: Environment Selection (via TERMINAL_ENV environment variable):
- VMs have a TTL (time to live) set at creation (default: 20 minutes) - "local": Execute directly on the host machine (default, fastest)
- VMs are also cleaned up locally after 5 minutes of inactivity - "docker": Execute in Docker containers (isolated, requires Docker)
- Timer resets with each use - "modal": Execute in Modal cloud sandboxes (scalable, requires Modal account)
Available tool: Features:
- terminal_tool: Execute commands with optional interactive session support - Multiple execution backends (local, docker, modal)
- Background task support
- VM/container lifecycle management
- Automatic cleanup after inactivity
Usage: Usage:
from terminal_tool import terminal_tool from terminal_tool import terminal_tool
# Execute a single command # Execute a simple command
result = terminal_tool("ls -la") result = terminal_tool("ls -la")
# Execute in an interactive session # Execute in background
result = terminal_tool("python", input_keys="print('hello')\\nexit()\\n") result = terminal_tool("python server.py", background=True)
""" """
import json import json
import os import os
import uuid import sys
import threading
import time import time
import threading
import atexit import atexit
from pathlib import Path
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
# Detailed description for the terminal tool based on Hermes Terminal system prompt # Add mini-swe-agent to path if not installed
TERMINAL_TOOL_DESCRIPTION = """Execute commands on a secure, persistent Linux VM environment with full interactive application support. mini_swe_path = Path(__file__).parent.parent / "mini-swe-agent" / "src"
if mini_swe_path.exists():
sys.path.insert(0, str(mini_swe_path))
# Tool description for LLM
TERMINAL_TOOL_DESCRIPTION = """Execute commands on a secure Linux environment.
**Environment:** **Environment:**
- Minimal Debian-based OS with internet access - Isolated execution environment (local, Docker, or Modal cloud based on configuration)
- Automatic VM lifecycle management (creates on-demand, reuses, cleans up) - Filesystem persists between tool calls within the same task
- **Full state persistence across tool calls**: current directory (pwd), environment variables, activated virtual environments (conda/venv), running processes, and command history all persist between consecutive tool calls - Internet access available
- Session state managed automatically via tmux
**Command Execution:** **Command Execution:**
- Simple commands: Just provide the 'command' parameter - Simple commands: Just provide the 'command' parameter
- Background processes: Set 'background': True for servers/long-running tasks - Background processes: Set 'background': True for servers/long-running tasks
- Interactive applications automatically detected and handled - Command timeout: Optional 'timeout' parameter in seconds
**Interactive Applications (TUIs/Pagers/Prompts):**
When commands enter interactive mode (vim, nano, less, git prompts, package managers, etc.), you'll receive screen content with "frozen" status. This is NORMAL - the session is still active and waiting for input.
**To interact with frozen sessions:**
1. Use 'input_keys' parameter with keystrokes to send
2. System auto-detects and uses the active session
3. Session stays active until application exits
**Special Key Syntax for input_keys:**
- `<ESC>`: Escape key
- `<ENTER>`: Enter/Return
- `<CTRL+C>`, `<CTRL+D>`, `<CTRL+Z>`: Control combinations
- `<UP>`, `<DOWN>`, `<LEFT>`, `<RIGHT>`: Arrow keys
- `<TAB>`, `<BACKSPACE>`: Tab and Backspace
- `<F1>` through `<F12>`: Function keys
- `<SHIFT+TAB>`: Shift+Tab
- Uppercase letters for Shift+letter (e.g., 'V' for Shift+V)
- Symbols for Shift+number (e.g., '!' for Shift+1, ':' for Shift+;)
**Examples:** **Examples:**
- Start vim: `{"command": "vim file.txt"}` - Run command: `{"command": "ls -la"}`
- Type in vim: `{"input_keys": "iHello World<ESC>"}` - Background task: `{"command": "source venv/bin/activate && python server.py", "background": True}`
- Save and quit: `{"input_keys": ":wq<ENTER>"}` - With timeout: `{"command": "long_task.sh", "timeout": 300}`
- Navigate in less: `{"input_keys": "j"}`
- Quit less: `{"input_keys": "q"}`
**Best Practices:** **Best Practices:**
- Run servers/long processes in background with separate tool calls - Run servers/long processes in background
- Chain multiple foreground commands in single call if needed - Monitor disk usage for large tasks
- Monitor disk usage for large tasks, clean up to free space - Install whatever tools you need with apt-get or pip
- Test components incrementally with mock inputs - Do not be afraid to run pip with --break-system-packages
- Install whatever tools needed - full system access provided"""
# Global state for VM lifecycle management **Things to avoid:**
# These persist across tool calls to enable session continuity - Do NOT use interactive tools such as tmux, vim, nano, python repl - you will get stuck.
# Changed to dictionaries keyed by task_id to prevent leakage between concurrent tasks - Even git sometimes becomes interactive if the output is large. If you're not sure, pipe to cat.
_active_instances: Dict[str, Any] = {} """
_active_contexts: Dict[str, Any] = {}
_last_activity: Dict[str, float] = {} # Track last activity time for each VM # Global state for environment lifecycle management
_instance_lock = threading.Lock() _active_environments: Dict[str, Any] = {}
_last_activity: Dict[str, float] = {}
_env_lock = threading.Lock()
_cleanup_thread = None _cleanup_thread = None
_cleanup_running = False _cleanup_running = False
def _cleanup_inactive_vms(vm_lifetime_seconds: int = 300): # Configuration from environment variables
def _get_env_config() -> Dict[str, Any]:
"""Get terminal environment configuration from environment variables."""
return {
"env_type": os.getenv("TERMINAL_ENV", "local"), # local, docker, or modal
"docker_image": os.getenv("TERMINAL_DOCKER_IMAGE", "python:3.11-slim"),
"modal_image": os.getenv("TERMINAL_MODAL_IMAGE", "python:3.11-slim"),
"cwd": os.getenv("TERMINAL_CWD", "/tmp"),
"timeout": int(os.getenv("TERMINAL_TIMEOUT", "60")),
"lifetime_seconds": int(os.getenv("TERMINAL_LIFETIME_SECONDS", "300")),
}
def _create_environment(env_type: str, image: str, cwd: str, timeout: int):
""" """
Clean up VMs that have been inactive for longer than vm_lifetime_seconds. Create an execution environment from mini-swe-agent.
This function should be called periodically by a background thread.
Args: Args:
vm_lifetime_seconds: Maximum lifetime in seconds for inactive VMs (default: 300) env_type: One of "local", "docker", "modal"
image: Docker/Modal image name (ignored for local)
cwd: Working directory
timeout: Default command timeout
Returns:
Environment instance with execute() method
""" """
global _active_instances, _active_contexts, _last_activity if env_type == "local":
from minisweagent.environments.local import LocalEnvironment
return LocalEnvironment(cwd=cwd, timeout=timeout)
elif env_type == "docker":
from minisweagent.environments.docker import DockerEnvironment
return DockerEnvironment(image=image, cwd=cwd, timeout=timeout)
elif env_type == "modal":
from minisweagent.environments.extra.swerex_modal import SwerexModalEnvironment
return SwerexModalEnvironment(image=image, cwd=cwd, timeout=timeout)
else:
raise ValueError(f"Unknown environment type: {env_type}. Use 'local', 'docker', or 'modal'")
def _cleanup_inactive_envs(lifetime_seconds: int = 300):
"""Clean up environments that have been inactive for longer than lifetime_seconds."""
global _active_environments, _last_activity
current_time = time.time() current_time = time.time()
tasks_to_cleanup = [] tasks_to_cleanup = []
with _instance_lock: with _env_lock:
# Find all VMs that have been inactive for too long
for task_id, last_time in list(_last_activity.items()): for task_id, last_time in list(_last_activity.items()):
if current_time - last_time > vm_lifetime_seconds: if current_time - last_time > lifetime_seconds:
tasks_to_cleanup.append(task_id) tasks_to_cleanup.append(task_id)
# Clean up the inactive VMs
for task_id in tasks_to_cleanup: for task_id in tasks_to_cleanup:
try: try:
if task_id in _active_instances: if task_id in _active_environments:
instance = _active_instances[task_id] env = _active_environments[task_id]
# Terminate the VM instance # Try various cleanup methods
if hasattr(instance, 'terminate'): if hasattr(env, 'cleanup'):
instance.terminate() env.cleanup()
elif hasattr(instance, 'stop'): elif hasattr(env, 'stop'):
instance.stop() env.stop()
elif hasattr(instance, 'delete'): elif hasattr(env, 'terminate'):
instance.delete() env.terminate()
# Remove from tracking dictionaries del _active_environments[task_id]
del _active_instances[task_id] print(f"[Terminal Cleanup] Cleaned up inactive environment for task: {task_id}")
print(f"[VM Cleanup] Terminated inactive VM for task: {task_id}")
if task_id in _active_contexts:
del _active_contexts[task_id]
if task_id in _last_activity: if task_id in _last_activity:
del _last_activity[task_id] del _last_activity[task_id]
except Exception as e: except Exception as e:
print(f"[VM Cleanup] Error cleaning up VM for task {task_id}: {e}") error_str = str(e)
if "404" in error_str or "not found" in error_str.lower():
print(f"[Terminal Cleanup] Environment for task {task_id} already cleaned up")
else:
print(f"[Terminal Cleanup] Error cleaning up environment for task {task_id}: {e}")
# Always remove from tracking dicts
if task_id in _active_environments:
del _active_environments[task_id]
if task_id in _last_activity:
del _last_activity[task_id]
def _cleanup_thread_worker(): def _cleanup_thread_worker():
""" """Background thread worker that periodically cleans up inactive environments."""
Background thread worker that periodically cleans up inactive VMs.
Runs every 60 seconds.
"""
global _cleanup_running global _cleanup_running
while _cleanup_running: while _cleanup_running:
try: try:
vm_lifetime = int(os.getenv("HECATE_VM_LIFETIME_SECONDS", "300")) config = _get_env_config()
_cleanup_inactive_vms(vm_lifetime) _cleanup_inactive_envs(config["lifetime_seconds"])
except Exception as e: except Exception as e:
print(f"[VM Cleanup] Error in cleanup thread: {e}") print(f"[Terminal Cleanup] Error in cleanup thread: {e}")
# Sleep for 60 seconds, but check every second if we should stop
for _ in range(60): for _ in range(60):
if not _cleanup_running: if not _cleanup_running:
break break
time.sleep(1) time.sleep(1)
def _start_cleanup_thread(): def _start_cleanup_thread():
""" """Start the background cleanup thread if not already running."""
Start the background cleanup thread if it's not already running.
"""
global _cleanup_thread, _cleanup_running global _cleanup_thread, _cleanup_running
with _instance_lock: with _env_lock:
if _cleanup_thread is None or not _cleanup_thread.is_alive(): if _cleanup_thread is None or not _cleanup_thread.is_alive():
_cleanup_running = True _cleanup_running = True
_cleanup_thread = threading.Thread(target=_cleanup_thread_worker, daemon=True) _cleanup_thread = threading.Thread(target=_cleanup_thread_worker, daemon=True)
_cleanup_thread.start() _cleanup_thread.start()
def _stop_cleanup_thread(): def _stop_cleanup_thread():
""" """Stop the background cleanup thread."""
Stop the background cleanup thread.
"""
global _cleanup_running global _cleanup_running
_cleanup_running = False _cleanup_running = False
if _cleanup_thread is not None: if _cleanup_thread is not None:
_cleanup_thread.join(timeout=5) _cleanup_thread.join(timeout=5)
def cleanup_vm(task_id: str): def cleanup_vm(task_id: str):
""" """Manually clean up a specific environment by task_id."""
Manually clean up a specific VM by task_id. global _active_environments, _last_activity
This should be called when a task is completed.
Args: with _env_lock:
task_id: The task ID of the VM to clean up
"""
global _active_instances, _active_contexts, _last_activity
with _instance_lock:
try: try:
if task_id in _active_instances: if task_id in _active_environments:
instance = _active_instances[task_id] env = _active_environments[task_id]
# Terminate the VM instance if hasattr(env, 'cleanup'):
if hasattr(instance, 'terminate'): env.cleanup()
instance.terminate() elif hasattr(env, 'stop'):
elif hasattr(instance, 'stop'): env.stop()
instance.stop() elif hasattr(env, 'terminate'):
elif hasattr(instance, 'delete'): env.terminate()
instance.delete()
# Remove from tracking dictionaries del _active_environments[task_id]
del _active_instances[task_id] print(f"[Terminal Cleanup] Manually cleaned up environment for task: {task_id}")
print(f"[VM Cleanup] Manually terminated VM for task: {task_id}")
if task_id in _active_contexts:
del _active_contexts[task_id]
if task_id in _last_activity: if task_id in _last_activity:
del _last_activity[task_id] del _last_activity[task_id]
except Exception as e: except Exception as e:
print(f"[VM Cleanup] Error manually cleaning up VM for task {task_id}: {e}") error_str = str(e)
if "404" in error_str or "not found" in error_str.lower():
print(f"[Terminal Cleanup] Environment for task {task_id} already cleaned up")
else:
print(f"[Terminal Cleanup] Error cleaning up environment for task {task_id}: {e}")
# Register cleanup on program exit
atexit.register(_stop_cleanup_thread) atexit.register(_stop_cleanup_thread)
def terminal_tool( def terminal_tool(
command: Optional[str] = None, command: str,
input_keys: Optional[str] = None,
session_id: Optional[str] = None,
background: bool = False, background: bool = False,
idle_threshold: float = 5.0,
timeout: Optional[int] = None, timeout: Optional[int] = None,
task_id: Optional[str] = None task_id: Optional[str] = None
) -> str: ) -> str:
""" """
Execute a command on a Morph VM with optional interactive session support. Execute a command using mini-swe-agent's execution environments.
This tool uses Hecate's VM lifecycle management to automatically create
and manage VMs. VMs are reused within the configured lifetime window
and automatically cleaned up after inactivity.
Args: Args:
command: The command to execute (optional if continuing existing session) command: The command to execute
input_keys: Keystrokes to send to interactive session (e.g., "hello\\n") background: Whether to run in background (default: False)
session_id: ID of existing session to continue (optional) timeout: Command timeout in seconds (default: from config)
background: Whether to run the command in the background (default: False) task_id: Unique identifier for environment isolation (optional)
idle_threshold: Seconds to wait for output before considering session idle (default: 5.0)
timeout: Command timeout in seconds (optional)
task_id: Unique identifier for this task to isolate VMs between concurrent tasks (optional)
Returns: Returns:
str: JSON string containing command output, session info, exit code, and any errors str: JSON string with output, exit_code, and error fields
Examples: Examples:
# Execute a simple command # Execute a simple command
>>> result = terminal_tool(command="ls -la /tmp") >>> result = terminal_tool(command="ls -la /tmp")
# Start an interactive Python session
>>> result = terminal_tool(command="python3")
>>> session_data = json.loads(result)
>>> session_id = session_data["session_id"]
# Send input to the session
>>> result = terminal_tool(input_keys="print('Hello')\\n", session_id=session_id)
# Run a background task # Run a background task
>>> result = terminal_tool(command="sleep 60", background=True) >>> result = terminal_tool(command="python server.py", background=True)
# With custom timeout
>>> result = terminal_tool(command="long_task.sh", timeout=300)
""" """
global _active_instances, _active_contexts global _active_environments, _last_activity
try: try:
# Import required modules lazily so this module can be imported # Get configuration
# even when hecate is not installed config = _get_env_config()
try: env_type = config["env_type"]
from morphcloud._llm import ToolCall
from morphcloud.api import MorphCloudClient
from hecate.cli import run_tool, ExecutionContext
from rich.console import Console
import io
except ImportError as import_error:
return json.dumps({
"output": "",
"stderr": "",
"screen": "",
"exit_code": -1,
"error": f"Terminal tool is disabled due to import error: {import_error}",
"status": "disabled"
}, ensure_ascii=False)
# Select image based on env type
if env_type == "docker":
image = config["docker_image"]
elif env_type == "modal":
image = config["modal_image"]
else:
image = ""
# Get configuration from environment cwd = config["cwd"]
vm_lifetime_seconds = int(os.getenv("HECATE_VM_LIFETIME_SECONDS", "300")) default_timeout = config["timeout"]
vm_ttl_seconds = int(os.getenv("HECATE_VM_TTL_SECONDS", "1200")) # 20 minutes default effective_timeout = timeout or default_timeout
snapshot_id = os.getenv("HECATE_DEFAULT_SNAPSHOT_ID", "snapshot_1a8xowaq")
# Check API key # Use task_id for environment isolation
morph_api_key = os.getenv("MORPH_API_KEY")
if not morph_api_key:
return json.dumps({
"output": "",
"stderr": "",
"screen": "",
"exit_code": -1,
"error": "MORPH_API_KEY environment variable not set",
"status": "disabled"
}, ensure_ascii=False)
# Use task_id to isolate VMs between concurrent tasks
# If no task_id provided, use "default" for backward compatibility
effective_task_id = task_id or "default" effective_task_id = task_id or "default"
# Start the cleanup thread if not already running # Start cleanup thread
_start_cleanup_thread() _start_cleanup_thread()
# Get or create VM instance and execution context per task # Get or create environment
# This is critical for interactive session support - the context must persist! with _env_lock:
with _instance_lock: if effective_task_id not in _active_environments:
if effective_task_id not in _active_instances: try:
morph_client = MorphCloudClient(api_key=morph_api_key) _active_environments[effective_task_id] = _create_environment(
_active_instances[effective_task_id] = morph_client.instances.start( env_type=env_type,
snapshot_id=snapshot_id, image=image,
ttl_seconds=vm_ttl_seconds, cwd=cwd,
ttl_action="stop" timeout=effective_timeout
) )
except ImportError as e:
return json.dumps({
"output": "",
"exit_code": -1,
"error": f"Terminal tool disabled: mini-swe-agent not available ({e})",
"status": "disabled"
}, ensure_ascii=False)
# Get or create persistent execution context per task # Update last activity time
if effective_task_id not in _active_contexts:
_active_contexts[effective_task_id] = ExecutionContext()
# Update last activity time for this VM (resets the inactivity timer)
_last_activity[effective_task_id] = time.time() _last_activity[effective_task_id] = time.time()
env = _active_environments[effective_task_id]
instance = _active_instances[effective_task_id] # Prepare command for execution
ctx = _active_contexts[effective_task_id]
# Build tool input based on provided parameters
tool_input = {}
if command:
tool_input["command"] = command
if input_keys:
tool_input["input_keys"] = input_keys
if session_id:
tool_input["session_id"] = session_id
if background: if background:
tool_input["background"] = background # Run in background with nohup and redirect output
if idle_threshold != 5.0: exec_command = f"nohup {command} > /tmp/bg_output.log 2>&1 &"
tool_input["idle_threshold"] = idle_threshold try:
if timeout is not None: result = env.execute(exec_command, timeout=10)
tool_input["timeout"] = timeout return json.dumps({
"output": "Background task started successfully",
"exit_code": 0,
"error": None
}, ensure_ascii=False)
except Exception as e:
return json.dumps({
"output": "",
"exit_code": -1,
"error": f"Failed to start background task: {str(e)}"
}, ensure_ascii=False)
else:
# Run foreground command with retry logic
max_retries = 3
retry_count = 0
result = None
tool_call = ToolCall( while retry_count <= max_retries:
name="run_command", try:
input=tool_input result = env.execute(command, timeout=effective_timeout)
) except Exception as e:
error_str = str(e).lower()
if "timeout" in error_str:
return json.dumps({
"output": "",
"exit_code": 124,
"error": f"Command timed out after {effective_timeout} seconds"
}, ensure_ascii=False)
# Create a console for output (redirect to string buffer to avoid printing) # Retry on transient errors
console_output = io.StringIO() if retry_count < max_retries:
console = Console(file=console_output, force_terminal=False, legacy_windows=False) retry_count += 1
wait_time = 2 ** retry_count
print(f"⚠️ Terminal: execution error, retrying in {wait_time}s (attempt {retry_count}/{max_retries})")
time.sleep(wait_time)
continue
# Generate unique tool block ID return json.dumps({
tool_block_id = f"tool_{uuid.uuid4().hex[:8]}" "output": "",
"exit_code": -1,
"error": f"Command execution failed: {str(e)}"
}, ensure_ascii=False)
# Retry configuration for handling transient empty responses # Got a result
max_retries = 3 break
retry_count = 0
while retry_count <= max_retries: # Extract output
# Execute the tool with hecate output = result.get("output", "")
result = run_tool( returncode = result.get("returncode", 0)
tool_call=tool_call,
instance=instance,
console=console,
tool_block_id=tool_block_id,
ctx=ctx
)
# Format the result with only essential fields for the LLM # Truncate output if too long
# Map hecate's "stdout" to "output" for compatibility MAX_OUTPUT_CHARS = 50000
stdout = result.get("stdout", result.get("output", "")) if len(output) > MAX_OUTPUT_CHARS:
stderr = result.get("stderr", "") truncated_notice = f"\n\n... [OUTPUT TRUNCATED - showing last {MAX_OUTPUT_CHARS} chars of {len(output)} total] ..."
exit_code = result.get("returncode", result.get("exit_code", -1)) output = truncated_notice + output[-MAX_OUTPUT_CHARS:]
error = result.get("error")
screen = result.get("screen", "")
# If there's no explicit error but there's stderr, include it in error field return json.dumps({
# This helps capture why commands failed even without an explicit error message "output": output.strip() if output else "",
if not error and stderr: "exit_code": returncode,
error = stderr "error": None
# If exit code is non-zero but no error info, note that }, ensure_ascii=False)
elif not error and exit_code and exit_code != 0 and not stdout:
error = f"Command exited with code {exit_code}"
# Check if we should retry:
# 1. Empty output with non-zero exit code (clear failure)
# 2. Completely empty response (may indicate timing/VM issue)
should_retry = False
retry_reason = ""
if not stdout and not stderr and not screen and not error and exit_code == 0:
# Completely empty response - might be a timing issue
should_retry = True
retry_reason = "completely empty response (possible timing issue)"
elif not stdout and not stderr and exit_code != 0 and exit_code != -1:
# Non-zero exit with no output at all - might be transient
should_retry = True
retry_reason = f"empty output with exit code {exit_code}"
if should_retry and retry_count < max_retries:
retry_count += 1
wait_time = 2 ** retry_count # Exponential backoff: 2s, 4s, 8s
print(f"⚠️ Terminal: {retry_reason}, retrying in {wait_time}s (attempt {retry_count}/{max_retries})")
time.sleep(wait_time)
continue
# Success or max retries reached - return the result
formatted_result = {
"output": stdout,
"stderr": stderr, # Now capturing stderr separately too
"screen": screen,
"exit_code": exit_code,
"error": error
}
if retry_count > 0:
formatted_result["retries"] = retry_count
return json.dumps(formatted_result, ensure_ascii=False)
# Should never reach here, but just in case
return json.dumps({
"output": "",
"stderr": "",
"screen": "",
"exit_code": -1,
"error": "Terminal tool: max retries exceeded"
}, ensure_ascii=False)
except Exception as e: except Exception as e:
return json.dumps({ return json.dumps({
"output": "", "output": "",
"stderr": "",
"screen": "",
"exit_code": -1, "exit_code": -1,
"error": f"Failed to execute terminal command: {str(e)}", "error": f"Failed to execute command: {str(e)}",
"status": "error" "status": "error"
}, ensure_ascii=False) }, ensure_ascii=False)
def check_hecate_requirements() -> bool:
"""
Check if all requirements for terminal tools are met.
Returns: def check_terminal_requirements() -> bool:
bool: True if all requirements are met, False otherwise """Check if all requirements for the terminal tool are met."""
""" config = _get_env_config()
# Check for required environment variables env_type = config["env_type"]
required_vars = ["MORPH_API_KEY"]
optional_vars = ["OPENAI_API_KEY"] # Needed for Hecate's LLM features
missing_required = [var for var in required_vars if not os.getenv(var)]
missing_optional = [var for var in optional_vars if not os.getenv(var)]
if missing_required:
print(f"Missing required environment variables: {', '.join(missing_required)}")
return False
if missing_optional:
print(f"Warning: Missing optional environment variables: {', '.join(missing_optional)}")
print(" (Some Hecate features may be limited)")
# Check if Hecate and required modules are importable
try: try:
from morphcloud._llm import ToolCall if env_type == "local":
from morphcloud.api import MorphCloudClient from minisweagent.environments.local import LocalEnvironment
from hecate.cli import run_tool, ExecutionContext return True
from rich.console import Console elif env_type == "docker":
return True from minisweagent.environments.docker import DockerEnvironment
# Check if docker is available
import subprocess
result = subprocess.run(["docker", "version"], capture_output=True, timeout=5)
return result.returncode == 0
elif env_type == "modal":
from minisweagent.environments.extra.swerex_modal import SwerexModalEnvironment
# Check for modal token
return os.getenv("MODAL_TOKEN_ID") is not None or Path.home().joinpath(".modal.toml").exists()
else:
return False
except Exception as e: except Exception as e:
print(f"Hecate not available: {e}") print(f"Terminal requirements check failed: {e}")
print(f"Make sure hecate is installed and MORPH_API_KEY is set.")
return False return False
# Module-level initialization check
_requirements_met = check_hecate_requirements()
if __name__ == "__main__": if __name__ == "__main__":
""" """Simple test when run directly."""
Simple test/demo when run directly print("Terminal Tool Module (mini-swe-agent backend)")
""" print("=" * 50)
print("Terminal Tool Module")
print("=" * 40)
if not _requirements_met: config = _get_env_config()
print("Requirements not met. Please check the messages above.") print(f"\nCurrent Configuration:")
print(f" Environment type: {config['env_type']}")
print(f" Docker image: {config['docker_image']}")
print(f" Modal image: {config['modal_image']}")
print(f" Working directory: {config['cwd']}")
print(f" Default timeout: {config['timeout']}s")
print(f" Lifetime: {config['lifetime_seconds']}s")
if not check_terminal_requirements():
print("\n❌ Requirements not met. Please check the messages above.")
exit(1) exit(1)
print("All requirements met!") print("\nAll requirements met!")
print("\nAvailable Tool:") print("\nAvailable Tool:")
print(" - terminal_tool: Execute commands with optional interactive session support") print(" - terminal_tool: Execute commands using mini-swe-agent environments")
print("\nUsage Examples:") print("\nUsage Examples:")
print(" # Execute a command") print(" # Execute a command")
print(" result = terminal_tool(command='ls -la')") print(" result = terminal_tool(command='ls -la')")
print(" ") print(" ")
print(" # Start an interactive session")
print(" result = terminal_tool(command='python3')")
print(" session_data = json.loads(result)")
print(" session_id = session_data['session_id']")
print(" ")
print(" # Send input to the session")
print(" result = terminal_tool(")
print(" input_keys='print(\"Hello\")\\\\n',")
print(" session_id=session_id")
print(" )")
print(" ")
print(" # Run a background task") print(" # Run a background task")
print(" result = terminal_tool(command='sleep 60', background=True)") print(" result = terminal_tool(command='python server.py', background=True)")
print("\nEnvironment Variables:") print("\nEnvironment Variables:")
print(f" MORPH_API_KEY: {'Set' if os.getenv('MORPH_API_KEY') else 'Not set'}") print(f" TERMINAL_ENV: {os.getenv('TERMINAL_ENV', 'local')} (local/docker/modal)")
print(f" OPENAI_API_KEY: {'Set' if os.getenv('OPENAI_API_KEY') else 'Not set (optional)'}") print(f" TERMINAL_DOCKER_IMAGE: {os.getenv('TERMINAL_DOCKER_IMAGE', 'python:3.11-slim')}")
print(f" HECATE_VM_TTL_SECONDS: {os.getenv('HECATE_VM_TTL_SECONDS', '1200')} (default: 1200 / 20 minutes)") print(f" TERMINAL_MODAL_IMAGE: {os.getenv('TERMINAL_MODAL_IMAGE', 'python:3.11-slim')}")
print(f" HECATE_VM_LIFETIME_SECONDS: {os.getenv('HECATE_VM_LIFETIME_SECONDS', '300')} (default: 300 / 5 minutes)") print(f" TERMINAL_CWD: {os.getenv('TERMINAL_CWD', '/tmp')}")
print(f" HECATE_DEFAULT_SNAPSHOT_ID: {os.getenv('HECATE_DEFAULT_SNAPSHOT_ID', 'snapshot_1a8xowaq')} (default: snapshot_1a8xowaq)") print(f" TERMINAL_TIMEOUT: {os.getenv('TERMINAL_TIMEOUT', '60')}")
print(f" TERMINAL_LIFETIME_SECONDS: {os.getenv('TERMINAL_LIFETIME_SECONDS', '300')}")