Files
hermes-agent/tools/terminal_tool.py

457 lines
17 KiB
Python
Raw Normal View History

2025-07-25 15:15:36 +00:00
#!/usr/bin/env python3
"""
2025-07-26 04:31:17 +00:00
Terminal Tool Module
2025-07-25 15:15:36 +00:00
2025-07-26 04:31:17 +00:00
This module provides a single terminal tool using Hecate's VM infrastructure.
2025-07-25 15:15:36 +00:00
It wraps Hecate's functionality to provide a simple interface for executing commands
on Morph VMs with automatic lifecycle management.
VM Lifecycle:
- VMs have a TTL (time to live) set at creation (default: 20 minutes)
- VMs are also cleaned up locally after 5 minutes of inactivity
- Timer resets with each use
2025-11-02 08:52:05 +08:00
2025-07-26 04:31:17 +00:00
Available tool:
- terminal_tool: Execute commands with optional interactive session support
2025-07-25 15:15:36 +00:00
Usage:
2025-07-26 04:31:17 +00:00
from terminal_tool import terminal_tool
2025-11-02 08:52:05 +08:00
2025-07-25 15:15:36 +00:00
# Execute a single command
2025-07-26 04:31:17 +00:00
result = terminal_tool("ls -la")
2025-11-02 08:52:05 +08:00
2025-07-26 04:31:17 +00:00
# Execute in an interactive session
result = terminal_tool("python", input_keys="print('hello')\\nexit()\\n")
2025-07-25 15:15:36 +00:00
"""
import json
import os
2025-11-02 08:52:05 +08:00
import uuid
import threading
import time
import atexit
2025-07-26 04:31:17 +00:00
from typing import Optional, Dict, Any
2025-07-25 15:15:36 +00:00
2025-07-26 04:43:07 +00:00
# Detailed description for the terminal tool based on Hermes Terminal system prompt
TERMINAL_TOOL_DESCRIPTION = """Execute commands on a secure, persistent Linux VM environment with full interactive application support.
**Environment:**
- Minimal Debian-based OS with internet access
- Automatic VM lifecycle management (creates on-demand, reuses, cleans up)
- **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
- Session state managed automatically via tmux
**Command Execution:**
- Simple commands: Just provide the 'command' parameter
- Background processes: Set 'background': True for servers/long-running tasks
- Interactive applications automatically detected and handled
**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:**
- Start vim: `{"command": "vim file.txt"}`
- Type in vim: `{"input_keys": "iHello World<ESC>"}`
- Save and quit: `{"input_keys": ":wq<ENTER>"}`
- Navigate in less: `{"input_keys": "j"}`
- Quit less: `{"input_keys": "q"}`
**Best Practices:**
- Run servers/long processes in background with separate tool calls
- Chain multiple foreground commands in single call if needed
- Monitor disk usage for large tasks, clean up to free space
- Test components incrementally with mock inputs
- Install whatever tools needed - full system access provided"""
2025-11-02 08:52:05 +08:00
# Global state for VM lifecycle management
# These persist across tool calls to enable session continuity
2025-11-03 17:42:23 -05:00
# Changed to dictionaries keyed by task_id to prevent leakage between concurrent tasks
_active_instances: Dict[str, Any] = {}
_active_contexts: Dict[str, Any] = {}
_last_activity: Dict[str, float] = {} # Track last activity time for each VM
2025-11-02 08:52:05 +08:00
_instance_lock = threading.Lock()
_cleanup_thread = None
_cleanup_running = False
def _cleanup_inactive_vms(vm_lifetime_seconds: int = 300):
"""
Clean up VMs that have been inactive for longer than vm_lifetime_seconds.
This function should be called periodically by a background thread.
Args:
vm_lifetime_seconds: Maximum lifetime in seconds for inactive VMs (default: 300)
"""
global _active_instances, _active_contexts, _last_activity
current_time = time.time()
tasks_to_cleanup = []
with _instance_lock:
# Find all VMs that have been inactive for too long
for task_id, last_time in list(_last_activity.items()):
if current_time - last_time > vm_lifetime_seconds:
tasks_to_cleanup.append(task_id)
# Clean up the inactive VMs
for task_id in tasks_to_cleanup:
try:
if task_id in _active_instances:
instance = _active_instances[task_id]
# Terminate the VM instance
if hasattr(instance, 'terminate'):
instance.terminate()
elif hasattr(instance, 'stop'):
instance.stop()
elif hasattr(instance, 'delete'):
instance.delete()
# Remove from tracking dictionaries
del _active_instances[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:
del _last_activity[task_id]
except Exception as e:
print(f"[VM Cleanup] Error cleaning up VM for task {task_id}: {e}")
def _cleanup_thread_worker():
"""
Background thread worker that periodically cleans up inactive VMs.
Runs every 60 seconds.
"""
global _cleanup_running
while _cleanup_running:
try:
vm_lifetime = int(os.getenv("HECATE_VM_LIFETIME_SECONDS", "300"))
_cleanup_inactive_vms(vm_lifetime)
except Exception as e:
print(f"[VM Cleanup] Error in cleanup thread: {e}")
# Sleep for 60 seconds, but check every second if we should stop
for _ in range(60):
if not _cleanup_running:
break
time.sleep(1)
def _start_cleanup_thread():
"""
Start the background cleanup thread if it's not already running.
"""
global _cleanup_thread, _cleanup_running
with _instance_lock:
if _cleanup_thread is None or not _cleanup_thread.is_alive():
_cleanup_running = True
_cleanup_thread = threading.Thread(target=_cleanup_thread_worker, daemon=True)
_cleanup_thread.start()
def _stop_cleanup_thread():
"""
Stop the background cleanup thread.
"""
global _cleanup_running
_cleanup_running = False
if _cleanup_thread is not None:
_cleanup_thread.join(timeout=5)
def cleanup_vm(task_id: str):
"""
Manually clean up a specific VM by task_id.
This should be called when a task is completed.
Args:
task_id: The task ID of the VM to clean up
"""
global _active_instances, _active_contexts, _last_activity
with _instance_lock:
try:
if task_id in _active_instances:
instance = _active_instances[task_id]
# Terminate the VM instance
if hasattr(instance, 'terminate'):
instance.terminate()
elif hasattr(instance, 'stop'):
instance.stop()
elif hasattr(instance, 'delete'):
instance.delete()
# Remove from tracking dictionaries
del _active_instances[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:
del _last_activity[task_id]
except Exception as e:
print(f"[VM Cleanup] Error manually cleaning up VM for task {task_id}: {e}")
# Register cleanup on program exit
atexit.register(_stop_cleanup_thread)
2025-11-02 08:52:05 +08:00
2025-07-26 04:31:17 +00:00
def terminal_tool(
command: Optional[str] = None,
input_keys: Optional[str] = None,
session_id: Optional[str] = None,
2025-07-25 15:15:36 +00:00
background: bool = False,
2025-07-26 04:31:17 +00:00
idle_threshold: float = 5.0,
2025-11-03 17:42:23 -05:00
timeout: Optional[int] = None,
task_id: Optional[str] = None
2025-07-25 15:15:36 +00:00
) -> str:
"""
2025-07-26 04:31:17 +00:00
Execute a command on a Morph VM with optional interactive session support.
2025-11-03 17:42:23 -05:00
2025-07-25 15:15:36 +00:00
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.
2025-11-03 17:42:23 -05:00
2025-07-25 15:15:36 +00:00
Args:
2025-07-26 04:31:17 +00:00
command: The command to execute (optional if continuing existing session)
input_keys: Keystrokes to send to interactive session (e.g., "hello\\n")
2025-07-25 15:15:36 +00:00
session_id: ID of existing session to continue (optional)
2025-11-03 17:42:23 -05:00
background: Whether to run the command in the background (default: False)
2025-07-25 15:15:36 +00:00
idle_threshold: Seconds to wait for output before considering session idle (default: 5.0)
2025-07-26 04:31:17 +00:00
timeout: Command timeout in seconds (optional)
2025-11-03 17:42:23 -05:00
task_id: Unique identifier for this task to isolate VMs between concurrent tasks (optional)
2025-07-25 15:15:36 +00:00
Returns:
2025-07-26 04:31:17 +00:00
str: JSON string containing command output, session info, exit code, and any errors
2025-07-25 15:15:36 +00:00
2025-07-26 04:31:17 +00:00
Examples:
# Execute a simple command
>>> result = terminal_tool(command="ls -la /tmp")
# Start an interactive Python session
>>> result = terminal_tool(command="python3")
2025-07-25 15:15:36 +00:00
>>> session_data = json.loads(result)
>>> session_id = session_data["session_id"]
2025-07-26 04:31:17 +00:00
# Send input to the session
>>> result = terminal_tool(input_keys="print('Hello')\\n", session_id=session_id)
# Run a background task
>>> result = terminal_tool(command="sleep 60", background=True)
2025-07-25 15:15:36 +00:00
"""
2025-11-03 17:42:23 -05:00
global _active_instances, _active_contexts
2025-11-02 08:52:05 +08:00
2025-07-25 15:15:36 +00:00
try:
2025-11-02 08:52:05 +08:00
# Import required modules lazily so this module can be imported
# even when hecate is not installed
try:
from morphcloud._llm import ToolCall
2025-11-02 08:52:05 +08:00
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": "",
"screen": "",
"exit_code": -1,
"error": f"Terminal tool is disabled due to import error: {import_error}",
"status": "disabled"
2025-11-05 03:47:17 +00:00
}, ensure_ascii=False)
2025-11-02 08:52:05 +08:00
# Get configuration from environment
vm_lifetime_seconds = int(os.getenv("HECATE_VM_LIFETIME_SECONDS", "300"))
vm_ttl_seconds = int(os.getenv("HECATE_VM_TTL_SECONDS", "1200")) # 20 minutes default
2025-11-02 23:13:49 -05:00
snapshot_id = os.getenv("HECATE_DEFAULT_SNAPSHOT_ID", "snapshot_defv9tjg")
2025-11-02 08:52:05 +08:00
# Check API key
morph_api_key = os.getenv("MORPH_API_KEY")
if not morph_api_key:
return json.dumps({
"output": "",
"screen": "",
"exit_code": -1,
"error": "MORPH_API_KEY environment variable not set",
"status": "disabled"
2025-11-05 03:47:17 +00:00
}, ensure_ascii=False)
2025-11-02 08:52:05 +08:00
2025-11-03 17:42:23 -05:00
# 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"
# Start the cleanup thread if not already running
_start_cleanup_thread()
2025-11-03 17:42:23 -05:00
# Get or create VM instance and execution context per task
2025-11-02 08:52:05 +08:00
# This is critical for interactive session support - the context must persist!
with _instance_lock:
2025-11-03 17:42:23 -05:00
if effective_task_id not in _active_instances:
2025-11-02 08:52:05 +08:00
morph_client = MorphCloudClient(api_key=morph_api_key)
_active_instances[effective_task_id] = morph_client.instances.start(
snapshot_id=snapshot_id,
ttl_seconds=vm_ttl_seconds,
ttl_action="stop"
)
2025-11-02 08:52:05 +08:00
2025-11-03 17:42:23 -05:00
# Get or create persistent execution context per task
if effective_task_id not in _active_contexts:
_active_contexts[effective_task_id] = ExecutionContext()
2025-11-02 08:52:05 +08:00
# Update last activity time for this VM (resets the inactivity timer)
_last_activity[effective_task_id] = time.time()
2025-11-02 08:52:05 +08:00
2025-11-03 17:42:23 -05:00
instance = _active_instances[effective_task_id]
ctx = _active_contexts[effective_task_id]
2025-11-02 08:52:05 +08:00
2025-07-26 04:31:17 +00:00
# Build tool input based on provided parameters
2025-07-25 15:15:36 +00:00
tool_input = {}
2025-11-02 08:52:05 +08:00
2025-07-25 15:15:36 +00:00
if command:
tool_input["command"] = command
if input_keys:
tool_input["input_keys"] = input_keys
if session_id:
tool_input["session_id"] = session_id
2025-07-26 04:31:17 +00:00
if background:
tool_input["background"] = background
2025-07-25 15:15:36 +00:00
if idle_threshold != 5.0:
tool_input["idle_threshold"] = idle_threshold
2025-07-26 04:31:17 +00:00
if timeout is not None:
tool_input["timeout"] = timeout
2025-11-02 08:52:05 +08:00
2025-07-25 15:15:36 +00:00
tool_call = ToolCall(
name="run_command",
input=tool_input
)
2025-11-02 08:52:05 +08:00
# Create a console for output (redirect to string buffer to avoid printing)
console_output = io.StringIO()
console = Console(file=console_output, force_terminal=False, legacy_windows=False)
# Generate unique tool block ID
tool_block_id = f"tool_{uuid.uuid4().hex[:8]}"
# Execute the tool with hecate
result = run_tool(
tool_call=tool_call,
instance=instance,
console=console,
tool_block_id=tool_block_id,
ctx=ctx
)
2025-11-03 17:42:23 -05:00
# Format the result with only essential fields for the LLM
2025-07-26 09:46:21 +00:00
# Map hecate's "stdout" to "output" for compatibility
2025-07-25 15:15:36 +00:00
formatted_result = {
2025-07-26 09:46:21 +00:00
"output": result.get("stdout", result.get("output", "")),
2025-07-25 15:15:36 +00:00
"screen": result.get("screen", ""),
2025-07-26 04:31:17 +00:00
"exit_code": result.get("returncode", result.get("exit_code", -1)),
2025-11-03 17:42:23 -05:00
"error": result.get("error")
2025-07-25 15:15:36 +00:00
}
2025-11-02 08:52:05 +08:00
2025-11-05 03:47:17 +00:00
return json.dumps(formatted_result, ensure_ascii=False)
2025-11-02 08:52:05 +08:00
2025-07-25 15:15:36 +00:00
except Exception as e:
return json.dumps({
2025-07-26 04:31:17 +00:00
"output": "",
2025-07-25 15:15:36 +00:00
"screen": "",
"exit_code": -1,
2025-07-26 04:31:17 +00:00
"error": f"Failed to execute terminal command: {str(e)}",
2025-07-25 15:15:36 +00:00
"status": "error"
2025-11-05 03:47:17 +00:00
}, ensure_ascii=False)
2025-07-25 15:15:36 +00:00
def check_hecate_requirements() -> bool:
"""
Check if all requirements for terminal tools are met.
Returns:
bool: True if all requirements are met, False otherwise
"""
# Check for required environment variables
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)")
2025-11-02 08:52:05 +08:00
# Check if Hecate and required modules are importable
2025-07-25 15:15:36 +00:00
try:
2025-11-02 08:52:05 +08:00
from morphcloud._llm import ToolCall
from morphcloud.api import MorphCloudClient
from hecate.cli import run_tool, ExecutionContext
from rich.console import Console
2025-07-25 15:15:36 +00:00
return True
except Exception as e:
2025-11-02 08:52:05 +08:00
print(f"Hecate not available: {e}")
print(f"Make sure hecate is installed and MORPH_API_KEY is set.")
2025-07-25 15:15:36 +00:00
return False
# Module-level initialization check
_requirements_met = check_hecate_requirements()
if __name__ == "__main__":
"""
Simple test/demo when run directly
"""
2025-07-26 04:31:17 +00:00
print("Terminal Tool Module")
2025-07-25 15:15:36 +00:00
print("=" * 40)
if not _requirements_met:
print("Requirements not met. Please check the messages above.")
exit(1)
print("All requirements met!")
2025-07-26 04:31:17 +00:00
print("\nAvailable Tool:")
print(" - terminal_tool: Execute commands with optional interactive session support")
2025-07-25 15:15:36 +00:00
print("\nUsage Examples:")
print(" # Execute a command")
2025-07-26 04:31:17 +00:00
print(" result = terminal_tool(command='ls -la')")
2025-07-25 15:15:36 +00:00
print(" ")
print(" # Start an interactive session")
2025-07-26 04:31:17 +00:00
print(" result = terminal_tool(command='python3')")
2025-07-25 15:15:36 +00:00
print(" session_data = json.loads(result)")
print(" session_id = session_data['session_id']")
print(" ")
print(" # Send input to the session")
2025-07-26 04:31:17 +00:00
print(" result = terminal_tool(")
2025-07-25 15:15:36 +00:00
print(" input_keys='print(\"Hello\")\\\\n',")
print(" session_id=session_id")
print(" )")
2025-07-26 04:31:17 +00:00
print(" ")
print(" # Run a background task")
print(" result = terminal_tool(command='sleep 60', background=True)")
2025-07-25 15:15:36 +00:00
print("\nEnvironment Variables:")
print(f" MORPH_API_KEY: {'Set' if os.getenv('MORPH_API_KEY') else 'Not set'}")
print(f" OPENAI_API_KEY: {'Set' if os.getenv('OPENAI_API_KEY') else 'Not set (optional)'}")
print(f" HECATE_VM_TTL_SECONDS: {os.getenv('HECATE_VM_TTL_SECONDS', '1200')} (default: 1200 / 20 minutes)")
print(f" HECATE_VM_LIFETIME_SECONDS: {os.getenv('HECATE_VM_LIFETIME_SECONDS', '300')} (default: 300 / 5 minutes)")
2025-11-02 23:13:49 -05:00
print(f" HECATE_DEFAULT_SNAPSHOT_ID: {os.getenv('HECATE_DEFAULT_SNAPSHOT_ID', 'snapshot_defv9tjg')} (default: snapshot_defv9tjg)")