mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
Merge 5727b3429f into 05d8f11085
This commit is contained in:
commit
b4f338ccf4
19 changed files with 1022 additions and 29 deletions
|
|
@ -32,7 +32,7 @@ hermes-agent/
|
|||
├── agent/ # Agent internals (provider adapters, memory, caching, compression, etc.)
|
||||
├── hermes_cli/ # CLI subcommands, setup wizard, plugins loader, skin engine
|
||||
├── tools/ # Tool implementations — auto-discovered via tools/registry.py
|
||||
│ └── environments/ # Terminal backends (local, docker, ssh, modal, daytona, singularity)
|
||||
│ └── environments/ # Terminal backends (local, docker, ssh, modal, daytona, singularity, koyeb)
|
||||
├── gateway/ # Messaging gateway — run.py + session.py + platforms/
|
||||
│ ├── platforms/ # Adapter per platform (telegram, discord, slack, whatsapp,
|
||||
│ │ # homeassistant, signal, matrix, mattermost, email, sms,
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ hermes-agent/
|
|||
│ ├── skill_tools.py # Skill search, load, manage
|
||||
│ └── environments/ # Terminal execution backends
|
||||
│ ├── base.py # BaseEnvironment ABC
|
||||
│ ├── local.py, docker.py, ssh.py, singularity.py, modal.py, daytona.py
|
||||
│ ├── local.py, docker.py, ssh.py, singularity.py, modal.py, daytona.py, koyeb.py
|
||||
│
|
||||
├── gateway/ # Messaging gateway
|
||||
│ ├── run.py # GatewayRunner — platform lifecycle, message routing, cron
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), [Open
|
|||
<tr><td><b>A closed learning loop</b></td><td>Agent-curated memory with periodic nudges. Autonomous skill creation after complex tasks. Skills self-improve during use. FTS5 session search with LLM summarization for cross-session recall. <a href="https://github.com/plastic-labs/honcho">Honcho</a> dialectic user modeling. Compatible with the <a href="https://agentskills.io">agentskills.io</a> open standard.</td></tr>
|
||||
<tr><td><b>Scheduled automations</b></td><td>Built-in cron scheduler with delivery to any platform. Daily reports, nightly backups, weekly audits — all in natural language, running unattended.</td></tr>
|
||||
<tr><td><b>Delegates and parallelizes</b></td><td>Spawn isolated subagents for parallel workstreams. Write Python scripts that call tools via RPC, collapsing multi-step pipelines into zero-context-cost turns.</td></tr>
|
||||
<tr><td><b>Runs anywhere, not just your laptop</b></td><td>Six terminal backends — local, Docker, SSH, Daytona, Singularity, and Modal. Daytona and Modal offer serverless persistence — your agent's environment hibernates when idle and wakes on demand, costing nearly nothing between sessions. Run it on a $5 VPS or a GPU cluster.</td></tr>
|
||||
<tr><td><b>Runs anywhere, not just your laptop</b></td><td>Seven terminal backends — local, Docker, SSH, Daytona, Singularity, Modal, and Koyeb. Daytona, Modal, and Koyeb offer serverless cloud sandboxes — your agent's environment spins up on demand and is deleted when done. Run it on a $5 VPS or a GPU cluster.</td></tr>
|
||||
<tr><td><b>Research-ready</b></td><td>Batch trajectory generation, Atropos RL environments, trajectory compression for training the next generation of tool-calling models.</td></tr>
|
||||
</table>
|
||||
|
||||
|
|
|
|||
|
|
@ -226,8 +226,23 @@ terminal:
|
|||
# daytona_image: "nikolaik/python-nodejs:python3.11-nodejs20"
|
||||
# container_disk: 10240 # Daytona max is 10GB per sandbox
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# OPTION 7: Koyeb cloud execution
|
||||
# Commands run in Koyeb cloud sandboxes
|
||||
# Great for: Serverless execution, global deployment, GPU access
|
||||
# Requires: pip install koyeb-sdk, KOYEB_API_TOKEN env var
|
||||
# -----------------------------------------------------------------------------
|
||||
# terminal:
|
||||
# backend: "koyeb"
|
||||
# cwd: "/root"
|
||||
# timeout: 180
|
||||
# lifetime_seconds: 300
|
||||
# koyeb_image: "nikolaik/python-nodejs:python3.11-nodejs20"
|
||||
# koyeb_instance_type: "micro" # Options: micro, small, medium, large, xlarge, 2xlarge
|
||||
# koyeb_region: "na" # Options: na, eu, fr, etc.
|
||||
|
||||
#
|
||||
# --- Container resource limits (docker, singularity, modal, daytona -- ignored for local/ssh) ---
|
||||
# --- Container resource limits (docker, singularity, modal, daytona, koyeb -- ignored for local/ssh) ---
|
||||
# These settings apply to all container backends. They control the resources
|
||||
# allocated to the sandbox and whether its filesystem persists across sessions.
|
||||
container_cpu: 1 # CPU cores
|
||||
|
|
|
|||
|
|
@ -437,7 +437,8 @@ DEFAULT_CONFIG = {
|
|||
"singularity_image": "docker://nikolaik/python-nodejs:python3.11-nodejs20",
|
||||
"modal_image": "nikolaik/python-nodejs:python3.11-nodejs20",
|
||||
"daytona_image": "nikolaik/python-nodejs:python3.11-nodejs20",
|
||||
# Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)
|
||||
"koyeb_image": "koyeb/sandbox:latest",
|
||||
# Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh/koyeb)
|
||||
"container_cpu": 1,
|
||||
"container_memory": 5120, # MB (default 5GB)
|
||||
"container_disk": 51200, # MB (default 50GB)
|
||||
|
|
@ -3700,6 +3701,10 @@ def show_config():
|
|||
print(f" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}")
|
||||
daytona_key = get_env_value('DAYTONA_API_KEY')
|
||||
print(f" API key: {'configured' if daytona_key else '(not set)'}")
|
||||
elif terminal.get('backend') == 'koyeb':
|
||||
print(f" Koyeb image: {terminal.get('koyeb_image', 'koyeb/sandbox:latest')}")
|
||||
koyeb_token = get_env_value('KOYEB_API_TOKEN')
|
||||
print(f" API token: {'configured' if koyeb_token else '(not set)'}")
|
||||
elif terminal.get('backend') == 'ssh':
|
||||
ssh_host = get_env_value('TERMINAL_SSH_HOST')
|
||||
ssh_user = get_env_value('TERMINAL_SSH_USER')
|
||||
|
|
@ -3892,6 +3897,7 @@ def set_config_value(key: str, value: str):
|
|||
"terminal.singularity_image": "TERMINAL_SINGULARITY_IMAGE",
|
||||
"terminal.modal_image": "TERMINAL_MODAL_IMAGE",
|
||||
"terminal.daytona_image": "TERMINAL_DAYTONA_IMAGE",
|
||||
"terminal.koyeb_image": "TERMINAL_KOYEB_IMAGE",
|
||||
"terminal.docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
|
||||
"terminal.cwd": "TERMINAL_CWD",
|
||||
"terminal.timeout": "TERMINAL_TIMEOUT",
|
||||
|
|
|
|||
|
|
@ -795,6 +795,21 @@ def run_doctor(args):
|
|||
check_fail("daytona SDK not installed", "(pip install daytona)")
|
||||
issues.append("Install daytona SDK: pip install daytona")
|
||||
|
||||
# Koyeb (if using koyeb backend)
|
||||
if terminal_env == "koyeb":
|
||||
koyeb_token = os.getenv("KOYEB_API_TOKEN")
|
||||
if koyeb_token:
|
||||
check_ok("Koyeb API token", "(configured)")
|
||||
else:
|
||||
check_fail("KOYEB_API_TOKEN not set", "(required for TERMINAL_ENV=koyeb)")
|
||||
issues.append("Set KOYEB_API_TOKEN environment variable")
|
||||
try:
|
||||
from koyeb import Sandbox # noqa: F401 — SDK presence check
|
||||
check_ok("koyeb SDK", "(installed)")
|
||||
except ImportError:
|
||||
check_fail("koyeb SDK not installed", "(pip install koyeb-sdk)")
|
||||
issues.append("Install koyeb SDK: pip install koyeb-sdk")
|
||||
|
||||
# Node.js + agent-browser (for browser automation tools)
|
||||
if shutil.which("node"):
|
||||
check_ok("Node.js")
|
||||
|
|
|
|||
|
|
@ -1191,11 +1191,12 @@ def setup_terminal_backend(config: dict):
|
|||
"Modal - serverless cloud sandbox",
|
||||
"SSH - run on a remote machine",
|
||||
"Daytona - persistent cloud development environment",
|
||||
"Koyeb - cloud sandbox execution",
|
||||
]
|
||||
idx_to_backend = {0: "local", 1: "docker", 2: "modal", 3: "ssh", 4: "daytona"}
|
||||
backend_to_idx = {"local": 0, "docker": 1, "modal": 2, "ssh": 3, "daytona": 4}
|
||||
idx_to_backend = {0: "local", 1: "docker", 2: "modal", 3: "ssh", 4: "daytona", 5: "koyeb"}
|
||||
backend_to_idx = {"local": 0, "docker": 1, "modal": 2, "ssh": 3, "daytona": 4, "koyeb": 5}
|
||||
|
||||
next_idx = 5
|
||||
next_idx = 6
|
||||
if is_linux:
|
||||
terminal_choices.append("Singularity/Apptainer - HPC-friendly container")
|
||||
idx_to_backend[next_idx] = "singularity"
|
||||
|
|
@ -1450,6 +1451,64 @@ def setup_terminal_backend(config: dict):
|
|||
|
||||
_prompt_container_resources(config)
|
||||
|
||||
elif selected_backend == "koyeb":
|
||||
print_success("Terminal backend: Koyeb")
|
||||
print_info("Cloud sandbox execution via Koyeb.")
|
||||
print_info("Sign up at: https://www.koyeb.com")
|
||||
|
||||
# Check if koyeb SDK is installed
|
||||
try:
|
||||
__import__("koyeb")
|
||||
except ImportError:
|
||||
print_info("Installing koyeb SDK...")
|
||||
import subprocess
|
||||
|
||||
uv_bin = shutil.which("uv")
|
||||
if uv_bin:
|
||||
result = subprocess.run(
|
||||
[uv_bin, "pip", "install", "--python", sys.executable, "koyeb-sdk"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "koyeb-sdk"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print_success("koyeb SDK installed")
|
||||
else:
|
||||
print_warning("Install failed — run manually: pip install koyeb-sdk")
|
||||
if result.stderr:
|
||||
print_info(f" Error: {result.stderr.strip().splitlines()[-1]}")
|
||||
|
||||
# Koyeb API token
|
||||
print()
|
||||
existing_key = get_env_value("KOYEB_API_TOKEN")
|
||||
if existing_key:
|
||||
print_info(" Koyeb API token: already configured")
|
||||
if prompt_yes_no(" Update API token?", False):
|
||||
api_key = prompt(" Koyeb API token", password=True)
|
||||
if api_key:
|
||||
save_env_value("KOYEB_API_TOKEN", api_key)
|
||||
print_success(" Updated")
|
||||
else:
|
||||
api_key = prompt(" Koyeb API token", password=True)
|
||||
if api_key:
|
||||
save_env_value("KOYEB_API_TOKEN", api_key)
|
||||
print_success(" Configured")
|
||||
|
||||
# Koyeb image
|
||||
current_image = config.get("terminal", {}).get(
|
||||
"koyeb_image", "koyeb/sandbox:latest"
|
||||
)
|
||||
image = prompt(" Sandbox image", current_image)
|
||||
config["terminal"]["koyeb_image"] = image
|
||||
save_env_value("TERMINAL_KOYEB_IMAGE", image)
|
||||
|
||||
_prompt_container_resources(config)
|
||||
|
||||
elif selected_backend == "ssh":
|
||||
print_success("Terminal backend: SSH")
|
||||
print_info("Run commands on a remote machine via SSH.")
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ dependencies = [
|
|||
[project.optional-dependencies]
|
||||
modal = ["modal>=1.0.0,<2"]
|
||||
daytona = ["daytona>=0.148.0,<1"]
|
||||
koyeb = ["koyeb-sdk>=1.4.0,<2"]
|
||||
dev = ["debugpy>=1.8.0,<2", "pytest>=9.0.2,<10", "pytest-asyncio>=1.3.0,<2", "pytest-xdist>=3.0,<4", "mcp>=1.2.0,<2", "ty>=0.0.1a29,<0.0.22", "ruff"]
|
||||
messaging = ["python-telegram-bot[webhooks]>=22.6,<23", "discord.py[voice]>=2.7.1,<3", "aiohttp>=3.13.3,<4", "slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4", "qrcode>=7.0,<8"]
|
||||
cron = ["croniter>=6.0.0,<7"]
|
||||
|
|
|
|||
258
tests/integration/test_koyeb_terminal.py
Normal file
258
tests/integration/test_koyeb_terminal.py
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test Koyeb Terminal Tool
|
||||
|
||||
This script tests that the Koyeb terminal backend is correctly configured
|
||||
and can execute commands in Koyeb sandboxes.
|
||||
|
||||
Usage:
|
||||
# Run with Koyeb backend
|
||||
TERMINAL_ENV=koyeb python tests/test_koyeb_terminal.py
|
||||
|
||||
# Or run directly (will use whatever TERMINAL_ENV is set in .env)
|
||||
python tests/test_koyeb_terminal.py
|
||||
"""
|
||||
|
||||
import pytest
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
# Try to load .env file if python-dotenv is available
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
except ImportError:
|
||||
# Manually load .env if dotenv not available
|
||||
env_file = Path(__file__).parent.parent.parent / ".env"
|
||||
if env_file.exists():
|
||||
with open(env_file) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#') and '=' in line:
|
||||
key, value = line.split('=', 1)
|
||||
# Remove quotes if present
|
||||
value = value.strip().strip('"').strip("'")
|
||||
os.environ.setdefault(key.strip(), value)
|
||||
|
||||
# Add project root to path for imports
|
||||
parent_dir = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(parent_dir))
|
||||
|
||||
# Import terminal_tool module directly using importlib to avoid tools/__init__.py
|
||||
import importlib.util
|
||||
terminal_tool_path = parent_dir / "tools" / "terminal_tool.py"
|
||||
spec = importlib.util.spec_from_file_location("terminal_tool", terminal_tool_path)
|
||||
terminal_module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(terminal_module)
|
||||
|
||||
terminal_tool = terminal_module.terminal_tool
|
||||
check_terminal_requirements = terminal_module.check_terminal_requirements
|
||||
_get_env_config = terminal_module._get_env_config
|
||||
cleanup_vm = terminal_module.cleanup_vm
|
||||
|
||||
|
||||
def test_koyeb_requirements():
|
||||
"""Test that Koyeb requirements are met."""
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST 1: Koyeb Requirements Check")
|
||||
print("=" * 60)
|
||||
|
||||
config = _get_env_config()
|
||||
print(f"Current TERMINAL_ENV: {config['env_type']}")
|
||||
print(f"Koyeb image: {config['koyeb_image']}")
|
||||
|
||||
# Check for Koyeb authentication
|
||||
koyeb_token = os.getenv("KOYEB_API_TOKEN")
|
||||
|
||||
print(f"\nKoyeb authentication:")
|
||||
print(f" KOYEB_API_TOKEN env var: {'✅ Set' if koyeb_token else '❌ Not set'}")
|
||||
|
||||
if config['env_type'] != 'koyeb':
|
||||
print(f"\n⚠️ TERMINAL_ENV is '{config['env_type']}', not 'koyeb'")
|
||||
print(" Set TERMINAL_ENV=koyeb in .env or export it to test Koyeb backend")
|
||||
return False
|
||||
|
||||
requirements_met = check_terminal_requirements()
|
||||
print(f"\nRequirements check: {'✅ Passed' if requirements_met else '❌ Failed'}")
|
||||
|
||||
return requirements_met
|
||||
|
||||
|
||||
def test_simple_command():
|
||||
"""Test executing a simple command."""
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST 2: Simple Command Execution")
|
||||
print("=" * 60)
|
||||
|
||||
test_task_id = "koyeb_test_simple"
|
||||
|
||||
print("Executing: echo 'Hello from Koyeb!'")
|
||||
result = terminal_tool("echo 'Hello from Koyeb!'", task_id=test_task_id)
|
||||
result_json = json.loads(result)
|
||||
|
||||
print(f"\nResult:")
|
||||
print(f" Output: {result_json.get('output', '')[:200]}")
|
||||
print(f" Exit code: {result_json.get('exit_code')}")
|
||||
print(f" Error: {result_json.get('error')}")
|
||||
|
||||
success = result_json.get('exit_code') == 0 and 'Hello from Koyeb!' in result_json.get('output', '')
|
||||
print(f"\nTest: {'✅ Passed' if success else '❌ Failed'}")
|
||||
|
||||
# Cleanup
|
||||
cleanup_vm(test_task_id)
|
||||
|
||||
return success
|
||||
|
||||
|
||||
def test_python_execution():
|
||||
"""Test executing Python code in Koyeb."""
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST 3: Python Execution")
|
||||
print("=" * 60)
|
||||
|
||||
test_task_id = "koyeb_test_python"
|
||||
|
||||
python_cmd = 'python3 -c "import sys; print(f\'Python {sys.version}\')"'
|
||||
print(f"Executing: {python_cmd}")
|
||||
|
||||
result = terminal_tool(python_cmd, task_id=test_task_id)
|
||||
result_json = json.loads(result)
|
||||
|
||||
print(f"\nResult:")
|
||||
print(f" Output: {result_json.get('output', '')[:200]}")
|
||||
print(f" Exit code: {result_json.get('exit_code')}")
|
||||
print(f" Error: {result_json.get('error')}")
|
||||
|
||||
success = result_json.get('exit_code') == 0 and 'Python' in result_json.get('output', '')
|
||||
print(f"\nTest: {'✅ Passed' if success else '❌ Failed'}")
|
||||
|
||||
# Cleanup
|
||||
cleanup_vm(test_task_id)
|
||||
|
||||
return success
|
||||
|
||||
|
||||
def test_filesystem_operations():
|
||||
"""Test filesystem operations in Koyeb."""
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST 4: Filesystem Operations")
|
||||
print("=" * 60)
|
||||
|
||||
test_task_id = "koyeb_test_fs"
|
||||
|
||||
# Create a file
|
||||
print("Step 1: Creating test file...")
|
||||
result1 = terminal_tool("echo 'koyeb filesystem test' > /tmp/koyeb_test.txt", task_id=test_task_id)
|
||||
result1_json = json.loads(result1)
|
||||
print(f" Exit code: {result1_json.get('exit_code')}")
|
||||
|
||||
# Read the file back
|
||||
print("Step 2: Reading test file...")
|
||||
result2 = terminal_tool("cat /tmp/koyeb_test.txt", task_id=test_task_id)
|
||||
result2_json = json.loads(result2)
|
||||
print(f" Output: {result2_json.get('output', '')}")
|
||||
print(f" Exit code: {result2_json.get('exit_code')}")
|
||||
|
||||
success = (
|
||||
result1_json.get('exit_code') == 0 and
|
||||
result2_json.get('exit_code') == 0 and
|
||||
'koyeb filesystem test' in result2_json.get('output', '')
|
||||
)
|
||||
print(f"\nTest: {'✅ Passed' if success else '❌ Failed'}")
|
||||
|
||||
# Cleanup
|
||||
cleanup_vm(test_task_id)
|
||||
|
||||
return success
|
||||
|
||||
|
||||
def test_environment_isolation():
|
||||
"""Test that different task_ids get isolated environments."""
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST 5: Environment Isolation")
|
||||
print("=" * 60)
|
||||
|
||||
task1 = "koyeb_test_iso_1"
|
||||
task2 = "koyeb_test_iso_2"
|
||||
|
||||
# Create file in task1
|
||||
print("Step 1: Creating file in task1...")
|
||||
result1 = terminal_tool("echo 'task1 data' > /tmp/isolated.txt", task_id=task1)
|
||||
|
||||
# Try to read from task2 (should not exist)
|
||||
print("Step 2: Trying to read file from task2 (should not exist)...")
|
||||
result2 = terminal_tool("cat /tmp/isolated.txt 2>&1 || echo 'FILE_NOT_FOUND'", task_id=task2)
|
||||
result2_json = json.loads(result2)
|
||||
|
||||
# The file should either not exist or be empty in task2
|
||||
output = result2_json.get('output', '')
|
||||
isolated = 'task1 data' not in output or 'FILE_NOT_FOUND' in output or 'No such file' in output
|
||||
|
||||
print(f" Task2 output: {output[:200]}")
|
||||
print(f"\nTest: {'✅ Passed (environments isolated)' if isolated else '❌ Failed (environments NOT isolated)'}")
|
||||
|
||||
# Cleanup
|
||||
cleanup_vm(task1)
|
||||
cleanup_vm(task2)
|
||||
|
||||
return isolated
|
||||
|
||||
|
||||
def main():
|
||||
"""Run all Koyeb terminal tests."""
|
||||
print("🧪 Koyeb Terminal Tool Test Suite")
|
||||
print("=" * 60)
|
||||
|
||||
# Check current config
|
||||
config = _get_env_config()
|
||||
print(f"\nCurrent configuration:")
|
||||
print(f" TERMINAL_ENV: {config['env_type']}")
|
||||
print(f" TERMINAL_KOYEB_IMAGE: {config['koyeb_image']}")
|
||||
print(f" TERMINAL_TIMEOUT: {config['timeout']}s")
|
||||
|
||||
if config['env_type'] != 'koyeb':
|
||||
print(f"\n⚠️ WARNING: TERMINAL_ENV is set to '{config['env_type']}', not 'koyeb'")
|
||||
print(" To test Koyeb specifically, set TERMINAL_ENV=koyeb")
|
||||
response = input("\n Continue testing with current backend? (y/n): ")
|
||||
if response.lower() != 'y':
|
||||
print("Aborting.")
|
||||
return
|
||||
|
||||
results = {}
|
||||
|
||||
# Run tests
|
||||
results['requirements'] = test_koyeb_requirements()
|
||||
|
||||
if not results['requirements']:
|
||||
print("\n❌ Requirements not met. Cannot continue with other tests.")
|
||||
return
|
||||
|
||||
results['simple_command'] = test_simple_command()
|
||||
results['python_execution'] = test_python_execution()
|
||||
results['filesystem_operations'] = test_filesystem_operations()
|
||||
results['environment_isolation'] = test_environment_isolation()
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST SUMMARY")
|
||||
print("=" * 60)
|
||||
|
||||
passed = sum(1 for v in results.values() if v)
|
||||
total = len(results)
|
||||
|
||||
for test_name, passed_test in results.items():
|
||||
status = "✅ PASSED" if passed_test else "❌ FAILED"
|
||||
print(f" {test_name}: {status}")
|
||||
|
||||
print(f"\nTotal: {passed}/{total} tests passed")
|
||||
|
||||
return passed == total
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = main()
|
||||
sys.exit(0 if success else 1)
|
||||
266
tests/tools/test_koyeb_environment.py
Normal file
266
tests/tools/test_koyeb_environment.py
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
"""Unit tests for the Koyeb cloud sandbox environment backend."""
|
||||
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers to build mock Koyeb SDK objects
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_exec_response(stdout="", stderr="", exit_code=0):
|
||||
return SimpleNamespace(stdout=stdout, stderr=stderr, exit_code=exit_code)
|
||||
|
||||
|
||||
def _make_sandbox(sandbox_id="sb-koyeb-123"):
|
||||
sb = MagicMock()
|
||||
sb.id = sandbox_id
|
||||
sb.exec.return_value = _make_exec_response()
|
||||
sb.filesystem = MagicMock()
|
||||
return sb
|
||||
|
||||
|
||||
def _patch_koyeb_imports(monkeypatch):
|
||||
"""Patch the koyeb SDK so KoyebEnvironment can be imported without it."""
|
||||
import types as _types
|
||||
|
||||
koyeb_mod = _types.ModuleType("koyeb")
|
||||
koyeb_mod.Sandbox = MagicMock()
|
||||
|
||||
monkeypatch.setitem(__import__("sys").modules, "koyeb", koyeb_mod)
|
||||
return koyeb_mod
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture()
|
||||
def koyeb_sdk(monkeypatch):
|
||||
"""Provide a mock koyeb SDK module and return it for assertions."""
|
||||
return _patch_koyeb_imports(monkeypatch)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def make_env(koyeb_sdk, monkeypatch):
|
||||
"""Factory that creates a KoyebEnvironment with a mocked SDK."""
|
||||
monkeypatch.setattr("tools.environments.base.is_interrupted", lambda: False)
|
||||
monkeypatch.setattr("tools.credential_files.get_credential_file_mounts", lambda: [])
|
||||
monkeypatch.setattr("tools.credential_files.get_skills_directory_mount", lambda **kw: None)
|
||||
monkeypatch.setattr("tools.credential_files.iter_skills_files", lambda **kw: [])
|
||||
|
||||
def _factory(
|
||||
sandbox=None,
|
||||
home_dir="/root",
|
||||
**kwargs,
|
||||
):
|
||||
sandbox = sandbox or _make_sandbox()
|
||||
# Mock the $HOME detection
|
||||
sandbox.exec.return_value = _make_exec_response(stdout=home_dir)
|
||||
|
||||
koyeb_sdk.Sandbox.create.return_value = sandbox
|
||||
|
||||
from tools.environments.koyeb import KoyebEnvironment
|
||||
|
||||
kwargs.setdefault("task_id", "test-task")
|
||||
env = KoyebEnvironment(
|
||||
image="koyeb/sandbox:latest",
|
||||
**kwargs,
|
||||
)
|
||||
return env
|
||||
|
||||
return _factory
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constructor / cwd resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCwdResolution:
|
||||
def test_default_cwd_resolves_home(self, make_env):
|
||||
env = make_env(home_dir="/home/testuser")
|
||||
assert env.cwd == "/home/testuser"
|
||||
|
||||
def test_tilde_cwd_resolves_home(self, make_env):
|
||||
env = make_env(cwd="~", home_dir="/home/testuser")
|
||||
assert env.cwd == "/home/testuser"
|
||||
|
||||
def test_explicit_cwd_not_overridden(self, make_env):
|
||||
env = make_env(cwd="/workspace", home_dir="/root")
|
||||
assert env.cwd == "/workspace"
|
||||
|
||||
def test_home_detection_failure_keeps_default_cwd(self, make_env):
|
||||
sb = _make_sandbox()
|
||||
sb.exec.side_effect = RuntimeError("exec failed")
|
||||
env = make_env(sandbox=sb)
|
||||
assert env.cwd == "/root" # keeps constructor default
|
||||
|
||||
def test_empty_home_keeps_default_cwd(self, make_env):
|
||||
env = make_env(home_dir="")
|
||||
assert env.cwd == "/root"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sandbox name sanitization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSandboxNameSanitization:
|
||||
def test_underscores_replaced_with_hyphens(self, make_env, koyeb_sdk):
|
||||
make_env(task_id="my_test_task")
|
||||
name_arg = koyeb_sdk.Sandbox.create.call_args[1]["name"]
|
||||
assert "_" not in name_arg
|
||||
assert name_arg == "hermes-my-test-task"
|
||||
|
||||
def test_uppercase_lowered(self, make_env, koyeb_sdk):
|
||||
make_env(task_id="MyTask")
|
||||
name_arg = koyeb_sdk.Sandbox.create.call_args[1]["name"]
|
||||
assert name_arg == "hermes-mytask"
|
||||
|
||||
def test_special_chars_removed(self, make_env, koyeb_sdk):
|
||||
make_env(task_id="task@#$123")
|
||||
name_arg = koyeb_sdk.Sandbox.create.call_args[1]["name"]
|
||||
assert name_arg == "hermes-task-123"
|
||||
|
||||
def test_name_truncated_to_63_chars(self, make_env, koyeb_sdk):
|
||||
make_env(task_id="a" * 100)
|
||||
name_arg = koyeb_sdk.Sandbox.create.call_args[1]["name"]
|
||||
assert len(name_arg) <= 63
|
||||
|
||||
def test_consecutive_hyphens_collapsed(self, make_env, koyeb_sdk):
|
||||
make_env(task_id="a__b---c")
|
||||
name_arg = koyeb_sdk.Sandbox.create.call_args[1]["name"]
|
||||
assert "--" not in name_arg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cleanup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCleanup:
|
||||
def test_cleanup_deletes_sandbox(self, make_env):
|
||||
env = make_env()
|
||||
sb = env._sandbox
|
||||
env.cleanup()
|
||||
sb.delete.assert_called_once()
|
||||
|
||||
def test_cleanup_idempotent(self, make_env):
|
||||
env = make_env()
|
||||
env.cleanup()
|
||||
env.cleanup() # should not raise
|
||||
|
||||
def test_cleanup_swallows_errors(self, make_env):
|
||||
env = make_env()
|
||||
env._sandbox.delete.side_effect = RuntimeError("delete failed")
|
||||
env.cleanup() # should not raise
|
||||
assert env._sandbox is None
|
||||
|
||||
def test_cleanup_calls_sync_back_before_delete(self, make_env):
|
||||
env = make_env()
|
||||
call_order = []
|
||||
sync_mgr = MagicMock()
|
||||
sync_mgr.sync_back = lambda: call_order.append("sync_back")
|
||||
env._sync_manager = sync_mgr
|
||||
original_delete = env._sandbox.delete
|
||||
env._sandbox.delete = lambda: (call_order.append("delete"), original_delete())
|
||||
|
||||
env.cleanup()
|
||||
|
||||
assert "sync_back" in call_order
|
||||
assert "delete" in call_order
|
||||
assert call_order.index("sync_back") < call_order.index("delete")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Execute
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExecute:
|
||||
def test_basic_command(self, make_env):
|
||||
sb = _make_sandbox()
|
||||
# Calls: (1) $HOME detection, (2) init_session bootstrap, (3) actual command
|
||||
sb.exec.side_effect = [
|
||||
_make_exec_response(stdout="/root"), # $HOME
|
||||
_make_exec_response(stdout="", exit_code=0), # init_session
|
||||
_make_exec_response(stdout="hello", exit_code=0), # actual cmd
|
||||
]
|
||||
env = make_env(sandbox=sb)
|
||||
|
||||
result = env.execute("echo hello")
|
||||
assert "hello" in result["output"]
|
||||
assert result["returncode"] == 0
|
||||
|
||||
def test_nonzero_exit_code(self, make_env):
|
||||
sb = _make_sandbox()
|
||||
sb.exec.side_effect = [
|
||||
_make_exec_response(stdout="/root"),
|
||||
_make_exec_response(stdout="", exit_code=0), # init_session
|
||||
_make_exec_response(stdout="not found", exit_code=127),
|
||||
]
|
||||
env = make_env(sandbox=sb)
|
||||
|
||||
result = env.execute("bad_cmd")
|
||||
assert result["returncode"] == 127
|
||||
|
||||
def test_stderr_included_in_output(self, make_env):
|
||||
sb = _make_sandbox()
|
||||
sb.exec.side_effect = [
|
||||
_make_exec_response(stdout="/root"),
|
||||
_make_exec_response(stdout="", exit_code=0), # init_session
|
||||
_make_exec_response(stdout="out", stderr="err", exit_code=0),
|
||||
]
|
||||
env = make_env(sandbox=sb)
|
||||
|
||||
result = env.execute("cmd")
|
||||
assert "out" in result["output"]
|
||||
assert "err" in result["output"]
|
||||
|
||||
def test_stdin_data_wraps_heredoc(self, make_env):
|
||||
sb = _make_sandbox()
|
||||
sb.exec.side_effect = [
|
||||
_make_exec_response(stdout="/root"),
|
||||
_make_exec_response(stdout="", exit_code=0), # init_session
|
||||
_make_exec_response(stdout="ok", exit_code=0),
|
||||
]
|
||||
env = make_env(sandbox=sb)
|
||||
|
||||
env.execute("python3", stdin_data="print('hi')")
|
||||
call_args = sb.exec.call_args_list[-1]
|
||||
cmd = call_args[0][0]
|
||||
assert "HERMES_STDIN_" in cmd
|
||||
assert "print" in cmd
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interrupt
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestInterrupt:
|
||||
def test_interrupt_kills_and_returns_130(self, make_env, monkeypatch):
|
||||
sb = _make_sandbox()
|
||||
event = threading.Event()
|
||||
calls = {"n": 0}
|
||||
|
||||
def exec_side_effect(*args, **kwargs):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
return _make_exec_response(stdout="/root") # $HOME
|
||||
if calls["n"] == 2:
|
||||
return _make_exec_response(stdout="", exit_code=0) # init_session
|
||||
event.wait(timeout=5) # simulate long-running command
|
||||
return _make_exec_response(stdout="done", exit_code=0)
|
||||
|
||||
sb.exec.side_effect = exec_side_effect
|
||||
env = make_env(sandbox=sb)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tools.environments.base.is_interrupted", lambda: True
|
||||
)
|
||||
try:
|
||||
result = env.execute("sleep 10")
|
||||
assert result["returncode"] == 130
|
||||
sb.delete.assert_called() # cancel_fn calls sandbox.delete()
|
||||
finally:
|
||||
event.set()
|
||||
|
|
@ -10,6 +10,7 @@ import pytest
|
|||
from tools.environments import ssh as ssh_env
|
||||
from tools.environments import modal as modal_env
|
||||
from tools.environments import daytona as daytona_env
|
||||
from tools.environments import koyeb as koyeb_env
|
||||
from tools.environments.ssh import SSHEnvironment
|
||||
|
||||
|
||||
|
|
@ -95,6 +96,20 @@ def _make_mock_daytona_env():
|
|||
return env
|
||||
|
||||
|
||||
# ── Koyeb helpers ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_mock_koyeb_env():
|
||||
"""Create a minimal KoyebEnvironment without calling __init__."""
|
||||
env = object.__new__(koyeb_env.KoyebEnvironment)
|
||||
env._sandbox = MagicMock()
|
||||
env._remote_home = "/root"
|
||||
env._sync_manager = None
|
||||
env._lock = __import__("threading").Lock()
|
||||
env._task_id = "test"
|
||||
return env
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# SSH bulk download
|
||||
# =====================================================================
|
||||
|
|
@ -402,6 +417,69 @@ class TestDaytonaCleanup:
|
|||
assert call_order.index("sync_back") < call_order.index("stop")
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Koyeb bulk download + cleanup
|
||||
# =====================================================================
|
||||
|
||||
|
||||
class TestKoyebBulkDownload:
|
||||
"""Unit tests for _koyeb_bulk_download."""
|
||||
|
||||
def test_koyeb_bulk_download_creates_tar_and_downloads(self, tmp_path):
|
||||
"""exec and download_file should both be called."""
|
||||
env = _make_mock_koyeb_env()
|
||||
dest = tmp_path / "backup.tar"
|
||||
|
||||
env._koyeb_bulk_download(dest)
|
||||
|
||||
# exec called twice: tar creation + rm cleanup
|
||||
assert env._sandbox.exec.call_count == 2
|
||||
tar_cmd = env._sandbox.exec.call_args_list[0][0][0]
|
||||
assert "tar cf" in tar_cmd
|
||||
assert "/tmp/.hermes_sync." in tar_cmd
|
||||
assert ".tar" in tar_cmd
|
||||
assert ".hermes" in tar_cmd
|
||||
|
||||
cleanup_cmd = env._sandbox.exec.call_args_list[1][0][0]
|
||||
assert "rm -f" in cleanup_cmd
|
||||
|
||||
env._sandbox.filesystem.download_file.assert_called_once()
|
||||
download_args = env._sandbox.filesystem.download_file.call_args[0]
|
||||
assert download_args[0].startswith("/tmp/.hermes_sync.")
|
||||
assert download_args[1] == str(dest)
|
||||
|
||||
def test_koyeb_bulk_download_uses_remote_home(self, tmp_path):
|
||||
"""The tar command should use the env's _remote_home."""
|
||||
env = _make_mock_koyeb_env()
|
||||
env._remote_home = "/home/koyeb"
|
||||
dest = tmp_path / "backup.tar"
|
||||
|
||||
env._koyeb_bulk_download(dest)
|
||||
|
||||
tar_cmd = env._sandbox.exec.call_args_list[0][0][0]
|
||||
assert "home/koyeb/.hermes" in tar_cmd
|
||||
|
||||
|
||||
class TestKoyebCleanup:
|
||||
"""Verify Koyeb cleanup() calls sync_back() before delete."""
|
||||
|
||||
def test_koyeb_cleanup_calls_sync_back(self):
|
||||
"""cleanup() should call sync_back() before sandbox.delete()."""
|
||||
env = _make_mock_koyeb_env()
|
||||
|
||||
call_order = []
|
||||
sync_mgr = MagicMock()
|
||||
sync_mgr.sync_back = lambda: call_order.append("sync_back")
|
||||
env._sync_manager = sync_mgr
|
||||
env._sandbox.delete = lambda: call_order.append("delete")
|
||||
|
||||
env.cleanup()
|
||||
|
||||
assert "sync_back" in call_order
|
||||
assert "delete" in call_order
|
||||
assert call_order.index("sync_back") < call_order.index("delete")
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# FileSyncManager wiring: bulk_download_fn passed by each backend
|
||||
# =====================================================================
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Each backend provides the same interface (BaseEnvironment ABC) for running
|
||||
shell commands in a specific execution context: local, Docker, Singularity,
|
||||
SSH, Modal, or Daytona.
|
||||
SSH, Modal, Daytona, or Koyeb.
|
||||
|
||||
The terminal_tool.py factory (_create_environment) selects the backend
|
||||
based on the TERMINAL_ENV configuration.
|
||||
|
|
|
|||
231
tools/environments/koyeb.py
Normal file
231
tools/environments/koyeb.py
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
"""Koyeb cloud execution environment.
|
||||
|
||||
Uses the Koyeb Python SDK to run commands in cloud sandboxes.
|
||||
Each task gets its own sandbox which is deleted on cleanup.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from tools.environments.base import (
|
||||
BaseEnvironment,
|
||||
_ThreadedProcessHandle,
|
||||
)
|
||||
from tools.environments.file_sync import (
|
||||
FileSyncManager,
|
||||
iter_sync_files,
|
||||
quoted_mkdir_command,
|
||||
quoted_rm_command,
|
||||
unique_parent_dirs,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class KoyebEnvironment(BaseEnvironment):
|
||||
"""Koyeb cloud sandbox execution backend.
|
||||
|
||||
Spawn-per-call via _ThreadedProcessHandle wrapping blocking SDK calls.
|
||||
cancel_fn wired to sandbox.delete() for interrupt support.
|
||||
Shell timeout wrapper preserved (SDK timeout unreliable).
|
||||
"""
|
||||
|
||||
_stdin_mode = "heredoc"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
image: str,
|
||||
cwd: str = "/root",
|
||||
timeout: int = 60,
|
||||
cpu: int = 1,
|
||||
memory: int = 5120,
|
||||
disk: int = 10240,
|
||||
persistent_filesystem: bool = True,
|
||||
task_id: str = "default",
|
||||
instance_type: str = "micro",
|
||||
region: str = None,
|
||||
):
|
||||
requested_cwd = cwd
|
||||
super().__init__(cwd=cwd, timeout=timeout)
|
||||
|
||||
from koyeb import Sandbox
|
||||
|
||||
self._task_id = task_id
|
||||
self._sandbox = None
|
||||
self._lock = threading.Lock()
|
||||
self._instance_type = instance_type
|
||||
self._region = region or os.getenv("KOYEB_REGION", "na")
|
||||
self._api_token = os.getenv("KOYEB_API_TOKEN")
|
||||
|
||||
# Convert memory from MB to GB (Koyeb uses GB)
|
||||
memory_gib = max(1, math.ceil(memory / 1024))
|
||||
|
||||
# Koyeb instance types: micro, small, medium, large, xlarge, 2xlarge, etc.
|
||||
# For now, we'll use the instance_type parameter directly
|
||||
# cpu and memory parameters are kept for compatibility but may be overridden by instance_type
|
||||
|
||||
# Koyeb app names must be lowercase alphanumeric + hyphens only.
|
||||
# Sanitize task_id: replace underscores/invalid chars with hyphens,
|
||||
# collapse runs, strip leading/trailing hyphens, and truncate.
|
||||
safe_id = re.sub(r"[^a-z0-9-]", "-", task_id.lower())
|
||||
safe_id = re.sub(r"-{2,}", "-", safe_id).strip("-")
|
||||
sandbox_name = f"hermes-{safe_id}"[:63] # Koyeb name max length
|
||||
try:
|
||||
self._sandbox = Sandbox.create(
|
||||
image=image,
|
||||
name=sandbox_name,
|
||||
wait_ready=True,
|
||||
instance_type=self._instance_type,
|
||||
region=self._region,
|
||||
api_token=self._api_token,
|
||||
timeout=300,
|
||||
)
|
||||
logger.info("Koyeb: created sandbox %s for task %s",
|
||||
self._sandbox.id, task_id)
|
||||
except Exception as e:
|
||||
logger.error("Koyeb: failed to create sandbox: %s", e)
|
||||
raise
|
||||
|
||||
# Detect remote home dir
|
||||
self._remote_home = "/root"
|
||||
try:
|
||||
home = self._sandbox.exec("echo $HOME").stdout.strip()
|
||||
if home:
|
||||
self._remote_home = home
|
||||
if requested_cwd in ("~", "/root"):
|
||||
self.cwd = home
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("Koyeb: resolved home to %s, cwd to %s", self._remote_home, self.cwd)
|
||||
|
||||
self._sync_manager = FileSyncManager(
|
||||
get_files_fn=lambda: iter_sync_files(f"{self._remote_home}/.hermes"),
|
||||
upload_fn=self._koyeb_upload,
|
||||
delete_fn=self._koyeb_delete,
|
||||
bulk_upload_fn=self._koyeb_bulk_upload,
|
||||
bulk_download_fn=self._koyeb_bulk_download,
|
||||
)
|
||||
self._sync_manager.sync(force=True)
|
||||
self.init_session()
|
||||
|
||||
def _koyeb_upload(self, host_path: str, remote_path: str) -> None:
|
||||
"""Upload a single file via Koyeb SDK."""
|
||||
parent = str(Path(remote_path).parent)
|
||||
self._sandbox.exec(f"mkdir -p {shlex.quote(parent)}")
|
||||
self._sandbox.filesystem.upload_file(host_path, remote_path, encoding="base64")
|
||||
|
||||
def _koyeb_bulk_upload(self, files: list[tuple[str, str]]) -> None:
|
||||
"""Upload many files as a single tar archive to avoid per-file HTTP overhead."""
|
||||
if not files:
|
||||
return
|
||||
|
||||
import tarfile
|
||||
import tempfile
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".tar", delete=False) as tmp:
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
with tarfile.open(tmp_path, "w") as tar:
|
||||
for host_path, remote_path in files:
|
||||
# Store with absolute remote path inside the tar
|
||||
tar.add(host_path, arcname=remote_path)
|
||||
|
||||
remote_tar = f"/tmp/.hermes_upload.{os.getpid()}.tar"
|
||||
self._sandbox.filesystem.upload_file(tmp_path, remote_tar, encoding="base64")
|
||||
self._sandbox.exec(f"tar xf {shlex.quote(remote_tar)} -C / && rm -f {shlex.quote(remote_tar)}")
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _koyeb_bulk_download(self, dest: Path) -> None:
|
||||
"""Download remote .hermes/ as a tar archive."""
|
||||
rel_base = f"{self._remote_home}/.hermes".lstrip("/")
|
||||
# PID-suffixed remote temp path avoids collisions if sync_back fires
|
||||
# concurrently for the same sandbox (e.g. retry after partial failure).
|
||||
remote_tar = f"/tmp/.hermes_sync.{os.getpid()}.tar"
|
||||
self._sandbox.exec(
|
||||
f"tar cf {shlex.quote(remote_tar)} -C / {shlex.quote(rel_base)}"
|
||||
)
|
||||
self._sandbox.filesystem.download_file(remote_tar, str(dest))
|
||||
# Clean up remote temp file
|
||||
try:
|
||||
self._sandbox.exec(f"rm -f {shlex.quote(remote_tar)}")
|
||||
except Exception:
|
||||
pass # best-effort cleanup
|
||||
|
||||
def _koyeb_delete(self, remote_paths: list[str]) -> None:
|
||||
"""Batch-delete remote files via SDK exec."""
|
||||
self._sandbox.exec(quoted_rm_command(remote_paths))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Sandbox lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _ensure_sandbox_ready(self) -> None:
|
||||
"""Restart sandbox if it was stopped (e.g., by a previous interrupt)."""
|
||||
# Koyeb sandboxes don't have a stopped state like Daytona
|
||||
# They're either running or need to be recreated
|
||||
pass
|
||||
|
||||
def _before_execute(self) -> None:
|
||||
"""Ensure sandbox is ready, then sync files via FileSyncManager."""
|
||||
with self._lock:
|
||||
self._ensure_sandbox_ready()
|
||||
self._sync_manager.sync()
|
||||
|
||||
def _run_bash(self, cmd_string: str, *, login: bool = False,
|
||||
timeout: int = 120,
|
||||
stdin_data: str | None = None):
|
||||
"""Return a _ThreadedProcessHandle wrapping a blocking Koyeb SDK call."""
|
||||
sandbox = self._sandbox
|
||||
lock = self._lock
|
||||
|
||||
def cancel():
|
||||
with lock:
|
||||
try:
|
||||
sandbox.delete()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if login:
|
||||
shell_cmd = f"bash -l -c {shlex.quote(cmd_string)}"
|
||||
else:
|
||||
shell_cmd = f"bash -c {shlex.quote(cmd_string)}"
|
||||
|
||||
def exec_fn() -> tuple[str, int]:
|
||||
result = sandbox.exec(shell_cmd, timeout=timeout)
|
||||
output = result.stdout or ""
|
||||
if result.stderr:
|
||||
output = f"{output}\n{result.stderr}" if output else result.stderr
|
||||
return (output, result.exit_code)
|
||||
|
||||
return _ThreadedProcessHandle(exec_fn, cancel_fn=cancel)
|
||||
|
||||
def cleanup(self):
|
||||
with self._lock:
|
||||
if self._sandbox is None:
|
||||
return
|
||||
|
||||
# Sync remote changes back to host before teardown
|
||||
if self._sync_manager:
|
||||
logger.info("Koyeb: syncing files from sandbox...")
|
||||
try:
|
||||
self._sync_manager.sync_back()
|
||||
except Exception as e:
|
||||
logger.warning("Koyeb: sync_back failed: %s", e)
|
||||
|
||||
try:
|
||||
self._sandbox.delete()
|
||||
logger.info("Koyeb: deleted sandbox %s", self._sandbox.id)
|
||||
except Exception as e:
|
||||
logger.warning("Koyeb: cleanup failed: %s", e)
|
||||
self._sandbox = None
|
||||
|
|
@ -2,16 +2,17 @@
|
|||
"""
|
||||
Terminal Tool Module
|
||||
|
||||
A terminal tool that executes commands in local, Docker, Modal, SSH, Singularity, and Daytona environments.
|
||||
Supports local execution, containerized backends, and Modal cloud sandboxes, including managed gateway mode.
|
||||
A terminal tool that executes commands in local, Docker, Modal, SSH, Singularity, Daytona, and Koyeb environments.
|
||||
Supports local execution, containerized backends, and cloud sandboxes (Modal, Daytona, Koyeb), including managed gateway mode.
|
||||
|
||||
Environment Selection (via TERMINAL_ENV environment variable):
|
||||
- "local": Execute directly on the host machine (default, fastest)
|
||||
- "docker": Execute in Docker containers (isolated, requires Docker)
|
||||
- "modal": Execute in Modal cloud sandboxes (direct Modal or managed gateway)
|
||||
- "koyeb": Execute in Koyeb cloud sandboxes
|
||||
|
||||
Features:
|
||||
- Multiple execution backends (local, docker, modal)
|
||||
- Multiple execution backends (local, docker, modal, koyeb)
|
||||
- Background task support
|
||||
- VM/container lifecycle management
|
||||
- Automatic cleanup after inactivity
|
||||
|
|
@ -855,7 +856,7 @@ def _get_env_config() -> Dict[str, Any]:
|
|||
):
|
||||
host_cwd = candidate
|
||||
cwd = "/workspace"
|
||||
elif env_type in ("modal", "docker", "singularity", "daytona") and cwd:
|
||||
elif env_type in ("modal", "docker", "singularity", "daytona", "koyeb") and cwd:
|
||||
# Host paths and relative paths that won't work inside containers
|
||||
is_host_path = any(cwd.startswith(p) for p in host_prefixes)
|
||||
is_relative = not os.path.isabs(cwd) # e.g. "." or "src/"
|
||||
|
|
@ -873,6 +874,7 @@ def _get_env_config() -> Dict[str, Any]:
|
|||
"singularity_image": os.getenv("TERMINAL_SINGULARITY_IMAGE", f"docker://{default_image}"),
|
||||
"modal_image": os.getenv("TERMINAL_MODAL_IMAGE", default_image),
|
||||
"daytona_image": os.getenv("TERMINAL_DAYTONA_IMAGE", default_image),
|
||||
"koyeb_image": os.getenv("TERMINAL_KOYEB_IMAGE", default_image),
|
||||
"cwd": cwd,
|
||||
"host_cwd": host_cwd,
|
||||
"docker_mount_cwd_to_workspace": mount_docker_cwd,
|
||||
|
|
@ -891,12 +893,15 @@ def _get_env_config() -> Dict[str, Any]:
|
|||
os.getenv("TERMINAL_PERSISTENT_SHELL", "true"),
|
||||
).lower() in ("true", "1", "yes"),
|
||||
"local_persistent": os.getenv("TERMINAL_LOCAL_PERSISTENT", "false").lower() in ("true", "1", "yes"),
|
||||
# Container resource config (applies to docker, singularity, modal, daytona -- ignored for local/ssh)
|
||||
# Container resource config (applies to docker, singularity, modal, daytona, koyeb -- ignored for local/ssh)
|
||||
"container_cpu": _parse_env_var("TERMINAL_CONTAINER_CPU", "1", float, "number"),
|
||||
"container_memory": _parse_env_var("TERMINAL_CONTAINER_MEMORY", "5120"), # MB (default 5GB)
|
||||
"container_disk": _parse_env_var("TERMINAL_CONTAINER_DISK", "51200"), # MB (default 50GB)
|
||||
"container_persistent": os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in ("true", "1", "yes"),
|
||||
"docker_volumes": _parse_env_var("TERMINAL_DOCKER_VOLUMES", "[]", json.loads, "valid JSON"),
|
||||
# Koyeb-specific config
|
||||
"koyeb_instance_type": os.getenv("TERMINAL_KOYEB_INSTANCE_TYPE", "micro"),
|
||||
"koyeb_region": os.getenv("KOYEB_REGION", "na"),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -918,8 +923,8 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
|
|||
Create an execution environment for sandboxed command execution.
|
||||
|
||||
Args:
|
||||
env_type: One of "local", "docker", "singularity", "modal", "daytona", "ssh"
|
||||
image: Docker/Singularity/Modal image name (ignored for local/ssh)
|
||||
env_type: One of "local", "docker", "singularity", "modal", "daytona", "koyeb", "ssh"
|
||||
image: Docker/Singularity/Modal/Koyeb image name (ignored for local/ssh)
|
||||
cwd: Working directory
|
||||
timeout: Default command timeout
|
||||
ssh_config: SSH connection config (for env_type="ssh")
|
||||
|
|
@ -1022,6 +1027,22 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
|
|||
persistent_filesystem=persistent, task_id=task_id,
|
||||
)
|
||||
|
||||
elif env_type == "koyeb":
|
||||
# Lazy import so koyeb SDK is only required when backend is selected.
|
||||
from tools.environments.koyeb import KoyebEnvironment as _KoyebEnvironment
|
||||
|
||||
# Get Koyeb-specific configuration
|
||||
koyeb_instance_type = cc.get("koyeb_instance_type", "micro")
|
||||
koyeb_region = cc.get("koyeb_region")
|
||||
|
||||
return _KoyebEnvironment(
|
||||
image=image, cwd=cwd, timeout=timeout,
|
||||
cpu=int(cpu), memory=memory, disk=disk,
|
||||
persistent_filesystem=persistent, task_id=task_id,
|
||||
instance_type=koyeb_instance_type,
|
||||
region=koyeb_region,
|
||||
)
|
||||
|
||||
elif env_type == "ssh":
|
||||
if not ssh_config or not ssh_config.get("host") or not ssh_config.get("user"):
|
||||
raise ValueError("SSH environment requires ssh_host and ssh_user to be configured")
|
||||
|
|
@ -1035,7 +1056,7 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
|
|||
)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown environment type: {env_type}. Use 'local', 'docker', 'singularity', 'modal', 'daytona', or 'ssh'")
|
||||
raise ValueError(f"Unknown environment type: {env_type}. Use 'local', 'docker', 'singularity', 'modal', 'daytona', 'koyeb', or 'ssh'")
|
||||
|
||||
|
||||
def _cleanup_inactive_envs(lifetime_seconds: int = 300):
|
||||
|
|
@ -1462,6 +1483,8 @@ def terminal_tool(
|
|||
image = overrides.get("modal_image") or config["modal_image"]
|
||||
elif env_type == "daytona":
|
||||
image = overrides.get("daytona_image") or config["daytona_image"]
|
||||
elif env_type == "koyeb":
|
||||
image = overrides.get("koyeb_image") or config["koyeb_image"]
|
||||
else:
|
||||
image = ""
|
||||
|
||||
|
|
@ -1538,7 +1561,7 @@ def terminal_tool(
|
|||
}
|
||||
|
||||
container_config = None
|
||||
if env_type in ("docker", "singularity", "modal", "daytona"):
|
||||
if env_type in ("docker", "singularity", "modal", "daytona", "koyeb"):
|
||||
container_config = {
|
||||
"container_cpu": config.get("container_cpu", 1),
|
||||
"container_memory": config.get("container_memory", 5120),
|
||||
|
|
@ -1948,10 +1971,14 @@ def check_terminal_requirements() -> bool:
|
|||
from daytona import Daytona # noqa: F401 — SDK presence check
|
||||
return os.getenv("DAYTONA_API_KEY") is not None
|
||||
|
||||
elif env_type == "koyeb":
|
||||
from koyeb import Sandbox # noqa: F401 — SDK presence check
|
||||
return os.getenv("KOYEB_API_TOKEN") is not None
|
||||
|
||||
else:
|
||||
logger.error(
|
||||
"Unknown TERMINAL_ENV '%s'. Use one of: local, docker, singularity, "
|
||||
"modal, daytona, ssh.",
|
||||
"modal, daytona, koyeb, ssh.",
|
||||
env_type,
|
||||
)
|
||||
return False
|
||||
|
|
@ -1970,6 +1997,7 @@ if __name__ == "__main__":
|
|||
print(f" Environment type: {config['env_type']}")
|
||||
print(f" Docker image: {config['docker_image']}")
|
||||
print(f" Modal image: {config['modal_image']}")
|
||||
print(f" Koyeb image: {config['koyeb_image']}")
|
||||
print(f" Working directory: {config['cwd']}")
|
||||
print(f" Default timeout: {config['timeout']}s")
|
||||
print(f" Lifetime: {config['lifetime_seconds']}s")
|
||||
|
|
@ -1991,11 +2019,12 @@ if __name__ == "__main__":
|
|||
|
||||
print("\nEnvironment Variables:")
|
||||
default_img = "nikolaik/python-nodejs:python3.11-nodejs20"
|
||||
print(f" TERMINAL_ENV: {os.getenv('TERMINAL_ENV', 'local')} (local/docker/singularity/modal/daytona/ssh)")
|
||||
print(f" TERMINAL_ENV: {os.getenv('TERMINAL_ENV', 'local')} (local/docker/singularity/modal/daytona/koyeb/ssh)")
|
||||
print(f" TERMINAL_DOCKER_IMAGE: {os.getenv('TERMINAL_DOCKER_IMAGE', default_img)}")
|
||||
print(f" TERMINAL_SINGULARITY_IMAGE: {os.getenv('TERMINAL_SINGULARITY_IMAGE', f'docker://{default_img}')}")
|
||||
print(f" TERMINAL_MODAL_IMAGE: {os.getenv('TERMINAL_MODAL_IMAGE', default_img)}")
|
||||
print(f" TERMINAL_DAYTONA_IMAGE: {os.getenv('TERMINAL_DAYTONA_IMAGE', default_img)}")
|
||||
print(f" TERMINAL_KOYEB_IMAGE: {os.getenv('TERMINAL_KOYEB_IMAGE', default_img)}")
|
||||
print(f" TERMINAL_CWD: {os.getenv('TERMINAL_CWD', os.getcwd())}")
|
||||
from hermes_constants import display_hermes_home as _dhh
|
||||
print(f" TERMINAL_SANDBOX_DIR: {os.getenv('TERMINAL_SANDBOX_DIR', f'{_dhh()}/sandboxes')}")
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ hermes-agent/
|
|||
│ ├── credential_files.py # File-based credential passthrough
|
||||
│ ├── env_passthrough.py # Env var passthrough for sandboxes
|
||||
│ ├── ansi_strip.py # ANSI escape stripping
|
||||
│ └── environments/ # Terminal backends (local, docker, ssh, modal, daytona, singularity)
|
||||
│ └── environments/ # Terminal backends (local, docker, ssh, modal, daytona, singularity, koyeb)
|
||||
│
|
||||
├── gateway/ # Messaging platform gateway
|
||||
│ ├── run.py # GatewayRunner — message dispatch (~9,000 lines)
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ The foundation from `atroposlib`. Provides:
|
|||
### HermesAgentBaseEnv
|
||||
|
||||
The hermes-agent layer (`environments/hermes_base_env.py`). Adds:
|
||||
- **Terminal backend configuration** — sets `TERMINAL_ENV` for sandboxed execution (local, Docker, Modal, Daytona, SSH, Singularity)
|
||||
- **Terminal backend configuration** — sets `TERMINAL_ENV` for sandboxed execution (local, Docker, Modal, Daytona, SSH, Singularity, Koyeb)
|
||||
- **Tool resolution** — `_resolve_tools_for_group()` calls hermes-agent's `get_tool_definitions()` to get the right tool schemas based on enabled/disabled toolsets
|
||||
- **Agent loop integration** — `collect_trajectory()` runs `HermesAgentLoop` and scores the result
|
||||
- **Two-phase operation** — Phase 1 (OpenAI server) for eval/SFT, Phase 2 (VLLM ManagedServer) for full RL with logprobs
|
||||
|
|
@ -426,7 +426,7 @@ See `environments/benchmarks/yc_bench/yc_bench_env.py` for a clean, well-documen
|
|||
| `max_agent_turns` | `int` | `30` | Max LLM calls per rollout |
|
||||
| `agent_temperature` | `float` | `1.0` | Sampling temperature |
|
||||
| `system_prompt` | `str` | `None` | System message for the agent |
|
||||
| `terminal_backend` | `str` | `"local"` | `local`, `docker`, `modal`, `daytona`, `ssh`, `singularity` |
|
||||
| `terminal_backend` | `str` | `"local"` | `local`, `docker`, `modal`, `daytona`, `ssh`, `singularity`, `koyeb` |
|
||||
| `terminal_timeout` | `int` | `120` | Seconds per terminal command |
|
||||
| `terminal_lifetime` | `int` | `3600` | Max sandbox lifetime |
|
||||
| `dataset_name` | `str` | `None` | HuggingFace dataset identifier |
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe
|
|||
| `TINKER_API_KEY` | RL training ([tinker-console.thinkingmachines.ai](https://tinker-console.thinkingmachines.ai/)) |
|
||||
| `WANDB_API_KEY` | RL training metrics ([wandb.ai](https://wandb.ai/)) |
|
||||
| `DAYTONA_API_KEY` | Daytona cloud sandboxes ([daytona.io](https://daytona.io/)) |
|
||||
| `KOYEB_API_TOKEN` | Koyeb cloud sandboxes ([koyeb.com](https://www.koyeb.com/)) |
|
||||
|
||||
### Nous Tool Gateway
|
||||
|
||||
|
|
@ -141,7 +142,7 @@ These variables configure the [Tool Gateway](/docs/user-guide/features/tool-gate
|
|||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `TERMINAL_ENV` | Backend: `local`, `docker`, `ssh`, `singularity`, `modal`, `daytona` |
|
||||
| `TERMINAL_ENV` | Backend: `local`, `docker`, `ssh`, `singularity`, `modal`, `daytona`, `koyeb` |
|
||||
| `TERMINAL_DOCKER_IMAGE` | Docker image (default: `nikolaik/python-nodejs:python3.11-nodejs20`) |
|
||||
| `TERMINAL_DOCKER_FORWARD_ENV` | JSON array of env var names to explicitly forward into Docker terminal sessions. Note: skill-declared `required_environment_variables` are forwarded automatically — you only need this for vars not declared by any skill. |
|
||||
| `TERMINAL_DOCKER_VOLUMES` | Additional Docker volume mounts (comma-separated `host:container` pairs) |
|
||||
|
|
@ -149,6 +150,7 @@ These variables configure the [Tool Gateway](/docs/user-guide/features/tool-gate
|
|||
| `TERMINAL_SINGULARITY_IMAGE` | Singularity image or `.sif` path |
|
||||
| `TERMINAL_MODAL_IMAGE` | Modal container image |
|
||||
| `TERMINAL_DAYTONA_IMAGE` | Daytona sandbox image |
|
||||
| `TERMINAL_KOYEB_IMAGE` | Koyeb sandbox image |
|
||||
| `TERMINAL_TIMEOUT` | Command timeout in seconds |
|
||||
| `TERMINAL_LIFETIME_SECONDS` | Max lifetime for terminal sessions in seconds |
|
||||
| `TERMINAL_CWD` | Working directory for all terminal sessions |
|
||||
|
|
@ -166,7 +168,7 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI
|
|||
| `TERMINAL_SSH_KEY` | Path to private key |
|
||||
| `TERMINAL_SSH_PERSISTENT` | Override persistent shell for SSH (default: follows `TERMINAL_PERSISTENT_SHELL`) |
|
||||
|
||||
## Container Resources (Docker, Singularity, Modal, Daytona)
|
||||
## Container Resources (Docker, Singularity, Modal, Daytona, Koyeb)
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
|
|
@ -176,6 +178,13 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI
|
|||
| `TERMINAL_CONTAINER_PERSISTENT` | Persist container filesystem across sessions (default: `true`) |
|
||||
| `TERMINAL_SANDBOX_DIR` | Host directory for workspaces and overlays (default: `~/.hermes/sandboxes/`) |
|
||||
|
||||
## Koyeb Backend
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `TERMINAL_KOYEB_INSTANCE_TYPE` | Koyeb instance type (default: `micro`). Options: `micro`, `small`, `medium`, `large`, `xlarge`, `2xlarge`, etc. |
|
||||
| `KOYEB_REGION` | Koyeb region (default: `na`). Options: `na`, `eu`, `fr`, etc. |
|
||||
|
||||
## Persistent Shell
|
||||
|
||||
| Variable | Description |
|
||||
|
|
|
|||
|
|
@ -83,17 +83,18 @@ Leaving these unset keeps the legacy defaults (`HERMES_API_TIMEOUT=1800`s, `HERM
|
|||
|
||||
## Terminal Backend Configuration
|
||||
|
||||
Hermes supports six terminal backends. Each determines where the agent's shell commands actually execute — your local machine, a Docker container, a remote server via SSH, a Modal cloud sandbox, a Daytona workspace, or a Singularity/Apptainer container.
|
||||
Hermes supports seven terminal backends. Each determines where the agent's shell commands actually execute — your local machine, a Docker container, a remote server via SSH, a Modal cloud sandbox, a Daytona workspace, a Singularity/Apptainer container, or a Koyeb cloud sandbox.
|
||||
|
||||
```yaml
|
||||
terminal:
|
||||
backend: local # local | docker | ssh | modal | daytona | singularity
|
||||
backend: local # local | docker | ssh | modal | daytona | singularity | koyeb
|
||||
cwd: "." # Working directory ("." = current dir for local, "/root" for containers)
|
||||
timeout: 180 # Per-command timeout in seconds
|
||||
env_passthrough: [] # Env var names to forward to sandboxed execution (terminal + execute_code)
|
||||
singularity_image: "docker://nikolaik/python-nodejs:python3.11-nodejs20" # Container image for Singularity backend
|
||||
modal_image: "nikolaik/python-nodejs:python3.11-nodejs20" # Container image for Modal backend
|
||||
daytona_image: "nikolaik/python-nodejs:python3.11-nodejs20" # Container image for Daytona backend
|
||||
koyeb_image: "koyeb/sandbox:latest" # Container image for Koyeb backend
|
||||
```
|
||||
|
||||
For cloud sandboxes such as Modal and Daytona, `container_persistent: true` means Hermes will try to preserve filesystem state across sandbox recreation. It does not promise that the same live sandbox, PID space, or background processes will still be running later.
|
||||
|
|
@ -108,6 +109,7 @@ For cloud sandboxes such as Modal and Daytona, `container_persistent: true` mean
|
|||
| **modal** | Modal cloud sandbox | Full (cloud VM) | Ephemeral cloud compute, evals |
|
||||
| **daytona** | Daytona workspace | Full (cloud container) | Managed cloud dev environments |
|
||||
| **singularity** | Singularity/Apptainer container | Namespaces (--containall) | HPC clusters, shared machines |
|
||||
| **koyeb** | Koyeb cloud sandbox | Full (cloud sandbox) | Serverless cloud execution |
|
||||
|
||||
### Local Backend
|
||||
|
||||
|
|
@ -242,6 +244,28 @@ terminal:
|
|||
|
||||
**Isolation:** Uses `--containall --no-home` for full namespace isolation without mounting the host home directory.
|
||||
|
||||
### Koyeb Backend
|
||||
|
||||
Runs commands in a [Koyeb](https://www.koyeb.com) cloud sandbox. Each task gets an isolated sandbox that is deleted on cleanup.
|
||||
|
||||
```yaml
|
||||
terminal:
|
||||
backend: koyeb
|
||||
koyeb_image: "koyeb/sandbox:latest"
|
||||
```
|
||||
|
||||
**Required:** `KOYEB_API_TOKEN` environment variable. Get one from the [Koyeb control panel](https://app.koyeb.com/account/api).
|
||||
|
||||
**Install the SDK:**
|
||||
|
||||
```bash
|
||||
pip install "hermes-agent[koyeb]"
|
||||
```
|
||||
|
||||
**Lifecycle:** A new sandbox is created for each task and deleted when the agent cleans up. There is no persistent state between sessions.
|
||||
|
||||
**File sync:** `~/.hermes/` credentials and skills are synced to the sandbox via a single tarball upload for fast initialization.
|
||||
|
||||
### Common Terminal Backend Issues
|
||||
|
||||
If terminal commands fail immediately or the terminal tool is reported as disabled:
|
||||
|
|
@ -252,6 +276,7 @@ If terminal commands fail immediately or the terminal tool is reported as disabl
|
|||
- **Modal** — Needs `MODAL_TOKEN_ID` env var or `~/.modal.toml`. Run `hermes doctor` to check.
|
||||
- **Daytona** — Needs `DAYTONA_API_KEY`. The Daytona SDK handles server URL configuration.
|
||||
- **Singularity** — Needs `apptainer` or `singularity` in `$PATH`. Common on HPC clusters.
|
||||
- **Koyeb** — Needs `KOYEB_API_TOKEN` and `pip install koyeb-sdk`. Run `hermes doctor` to check.
|
||||
|
||||
When in doubt, set `terminal.backend` back to `local` and verify that commands run there first.
|
||||
|
||||
|
|
|
|||
|
|
@ -64,13 +64,14 @@ The terminal tool can execute commands in different environments:
|
|||
| `singularity` | HPC containers | Cluster computing, rootless |
|
||||
| `modal` | Cloud execution | Serverless, scale |
|
||||
| `daytona` | Cloud sandbox workspace | Persistent remote dev environments |
|
||||
| `koyeb` | Koyeb cloud sandbox | Serverless cloud execution |
|
||||
|
||||
### Configuration
|
||||
|
||||
```yaml
|
||||
# In ~/.hermes/config.yaml
|
||||
terminal:
|
||||
backend: local # or: docker, ssh, singularity, modal, daytona
|
||||
backend: local # or: docker, ssh, singularity, modal, daytona, koyeb
|
||||
cwd: "." # Working directory
|
||||
timeout: 180 # Command timeout in seconds
|
||||
```
|
||||
|
|
@ -123,7 +124,7 @@ Configure CPU, memory, disk, and persistence for all container backends:
|
|||
|
||||
```yaml
|
||||
terminal:
|
||||
backend: docker # or singularity, modal, daytona
|
||||
backend: docker # or singularity, modal, daytona, koyeb
|
||||
container_cpu: 1 # CPU cores (default: 1)
|
||||
container_memory: 5120 # Memory in MB (default: 5GB)
|
||||
container_disk: 51200 # Disk in MB (default: 50GB)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue