Merge pull request #4 from NousResearch/fix-terminal
Fix terminal interactivity
This commit is contained in:
@@ -4,23 +4,26 @@ Terminal Tool Module
|
||||
|
||||
This module provides a single terminal tool using Hecate's VM infrastructure.
|
||||
It wraps Hecate's functionality to provide a simple interface for executing commands
|
||||
on Morph VMs with automatic lifecycle management.
|
||||
on Morph VMs with automatic lifecycle management. VMs live for 5 minutes after last use.
|
||||
Timer resets with each use.
|
||||
|
||||
Available tool:
|
||||
- terminal_tool: Execute commands with optional interactive session support
|
||||
|
||||
Usage:
|
||||
from terminal_tool import terminal_tool
|
||||
|
||||
|
||||
# Execute a single command
|
||||
result = terminal_tool("ls -la")
|
||||
|
||||
|
||||
# Execute in an interactive session
|
||||
result = terminal_tool("python", input_keys="print('hello')\\nexit()\\n")
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
import threading
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
# Detailed description for the terminal tool based on Hermes Terminal system prompt
|
||||
@@ -70,6 +73,12 @@ When commands enter interactive mode (vim, nano, less, git prompts, package mana
|
||||
- Test components incrementally with mock inputs
|
||||
- Install whatever tools needed - full system access provided"""
|
||||
|
||||
# Global state for VM lifecycle management
|
||||
# These persist across tool calls to enable session continuity
|
||||
_active_instance = None
|
||||
_active_context = None
|
||||
_instance_lock = threading.Lock()
|
||||
|
||||
def terminal_tool(
|
||||
command: Optional[str] = None,
|
||||
input_keys: Optional[str] = None,
|
||||
@@ -111,27 +120,17 @@ def terminal_tool(
|
||||
# Run a background task
|
||||
>>> result = terminal_tool(command="sleep 60", background=True)
|
||||
"""
|
||||
try:
|
||||
# Import hecate and ToolCall lazily so this module can be imported
|
||||
# even when hecate is not installed. If unavailable, gracefully
|
||||
# indicate that the terminal tool is disabled.
|
||||
try:
|
||||
# Primary import path when the hecate package is properly installed
|
||||
try:
|
||||
from hecate import run_tool_with_lifecycle_management # type: ignore
|
||||
except ImportError as primary_import_error:
|
||||
# Fallback for when a local folder named "hecate" shadows the installed package
|
||||
# (common when the repo is cloned inside the project root). In that case,
|
||||
# the actual implementation lives under hecate.hecate.cli.
|
||||
try:
|
||||
from hecate.hecate.cli import run_tool_with_lifecycle_management # type: ignore
|
||||
except Exception as fallback_import_error:
|
||||
raise ImportError(
|
||||
f"Failed to import 'run_tool_with_lifecycle_management' from hecate: "
|
||||
f"{primary_import_error}; fallback failed: {fallback_import_error}"
|
||||
)
|
||||
global _active_instance, _active_context
|
||||
|
||||
try:
|
||||
# Import required modules lazily so this module can be imported
|
||||
# even when hecate is not installed
|
||||
try:
|
||||
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": "",
|
||||
@@ -142,9 +141,39 @@ def terminal_tool(
|
||||
"status": "disabled"
|
||||
})
|
||||
|
||||
# Get configuration from environment
|
||||
vm_lifetime_seconds = int(os.getenv("HECATE_VM_LIFETIME_SECONDS", "300"))
|
||||
snapshot_id = os.getenv("HECATE_DEFAULT_SNAPSHOT_ID", "python-2025-10-31")
|
||||
|
||||
# Check API key
|
||||
morph_api_key = os.getenv("MORPH_API_KEY")
|
||||
if not morph_api_key:
|
||||
return json.dumps({
|
||||
"output": "",
|
||||
"screen": "",
|
||||
"session_id": None,
|
||||
"exit_code": -1,
|
||||
"error": "MORPH_API_KEY environment variable not set",
|
||||
"status": "disabled"
|
||||
})
|
||||
|
||||
# Get or create VM instance and execution context
|
||||
# This is critical for interactive session support - the context must persist!
|
||||
with _instance_lock:
|
||||
if _active_instance is None:
|
||||
morph_client = MorphCloudClient(api_key=morph_api_key)
|
||||
_active_instance = morph_client.instances.start(snapshot_id=snapshot_id)
|
||||
|
||||
# Get or create persistent execution context
|
||||
if _active_context is None:
|
||||
_active_context = ExecutionContext()
|
||||
|
||||
instance = _active_instance
|
||||
ctx = _active_context
|
||||
|
||||
# Build tool input based on provided parameters
|
||||
tool_input = {}
|
||||
|
||||
|
||||
if command:
|
||||
tool_input["command"] = command
|
||||
if input_keys:
|
||||
@@ -157,15 +186,28 @@ def terminal_tool(
|
||||
tool_input["idle_threshold"] = idle_threshold
|
||||
if timeout is not None:
|
||||
tool_input["timeout"] = timeout
|
||||
|
||||
|
||||
tool_call = ToolCall(
|
||||
name="run_command",
|
||||
input=tool_input
|
||||
)
|
||||
|
||||
# Execute with lifecycle management
|
||||
result = run_tool_with_lifecycle_management(tool_call)
|
||||
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
# Format the result with all possible fields
|
||||
# Map hecate's "stdout" to "output" for compatibility
|
||||
formatted_result = {
|
||||
@@ -176,9 +218,9 @@ def terminal_tool(
|
||||
"error": result.get("error"),
|
||||
"status": "active" if result.get("session_id") else "ended"
|
||||
}
|
||||
|
||||
|
||||
return json.dumps(formatted_result)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({
|
||||
"output": "",
|
||||
@@ -211,16 +253,16 @@ def check_hecate_requirements() -> bool:
|
||||
print(f"Warning: Missing optional environment variables: {', '.join(missing_optional)}")
|
||||
print(" (Some Hecate features may be limited)")
|
||||
|
||||
# Check if Hecate entrypoint is importable (handle local-folder shadowing)
|
||||
# Check if Hecate and required modules are importable
|
||||
try:
|
||||
try:
|
||||
from hecate import run_tool_with_lifecycle_management # type: ignore
|
||||
except ImportError:
|
||||
from hecate.hecate.cli import run_tool_with_lifecycle_management # type: ignore
|
||||
from morphcloud._llm import ToolCall
|
||||
from morphcloud.api import MorphCloudClient
|
||||
from hecate.cli import run_tool, ExecutionContext
|
||||
from rich.console import Console
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Hecate not available: {e}\nIf you cloned the hecate repo into this project, it may shadow the installed package. "
|
||||
f"Either install it (pip install -e hecate) and/or move/rename the local 'hecate' folder.")
|
||||
print(f"Hecate not available: {e}")
|
||||
print(f"Make sure hecate is installed and MORPH_API_KEY is set.")
|
||||
return False
|
||||
|
||||
# Module-level initialization check
|
||||
@@ -263,4 +305,4 @@ if __name__ == "__main__":
|
||||
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_LIFETIME_SECONDS: {os.getenv('HECATE_VM_LIFETIME_SECONDS', '300')} (default: 300)")
|
||||
print(f" HECATE_DEFAULT_SNAPSHOT_ID: {os.getenv('HECATE_DEFAULT_SNAPSHOT_ID', 'snapshot_p5294qxt')} (default: snapshot_p5294qxt)")
|
||||
print(f" HECATE_DEFAULT_SNAPSHOT_ID: {os.getenv('HECATE_DEFAULT_SNAPSHOT_ID', 'snapshot_p5294qxt')} (default: snapshot_p5294qxt)")
|
||||
|
||||
Reference in New Issue
Block a user