mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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:
parent
47555602d7
commit
ba19d530ad
11 changed files with 548 additions and 473 deletions
65
.env.example
65
.env.example
|
|
@ -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
3
.gitmodules
vendored
Normal 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
108
README.md
|
|
@ -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
1
mini-swe-agent
Submodule
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit 07aa6a738556e44b30d7b5c3bbd5063dac871d25
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
24
run_agent.py
24
run_agent.py
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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'}")
|
||||||
|
|
@ -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("\n✅ All 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')}")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue