diff --git a/AGENTS.md b/AGENTS.md index 05a6742d4..d5a548cde 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 146cb1161..be87ed831 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/README.md b/README.md index 11390fb2b..3eecb9a78 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), [Open A closed learning loopAgent-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. Honcho dialectic user modeling. Compatible with the agentskills.io open standard. Scheduled automationsBuilt-in cron scheduler with delivery to any platform. Daily reports, nightly backups, weekly audits — all in natural language, running unattended. Delegates and parallelizesSpawn isolated subagents for parallel workstreams. Write Python scripts that call tools via RPC, collapsing multi-step pipelines into zero-context-cost turns. -Runs anywhere, not just your laptopSix 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. +Runs anywhere, not just your laptopSeven 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. Research-readyBatch trajectory generation, Atropos RL environments, trajectory compression for training the next generation of tool-calling models. diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 977acbe92..581b79103 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -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 diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 7678287a0..b03ad2266 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -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", diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index cba4ebcdd..ab12acf2b 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -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") diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index e28acd41b..dde603821 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -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.") diff --git a/pyproject.toml b/pyproject.toml index 4b7e8816a..1c9ae5e57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/tests/integration/test_koyeb_terminal.py b/tests/integration/test_koyeb_terminal.py new file mode 100644 index 000000000..91724b1f2 --- /dev/null +++ b/tests/integration/test_koyeb_terminal.py @@ -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) diff --git a/tests/tools/test_koyeb_environment.py b/tests/tools/test_koyeb_environment.py new file mode 100644 index 000000000..3bd32bf5d --- /dev/null +++ b/tests/tools/test_koyeb_environment.py @@ -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() diff --git a/tests/tools/test_sync_back_backends.py b/tests/tools/test_sync_back_backends.py index 97bec17e2..9f79d5c87 100644 --- a/tests/tools/test_sync_back_backends.py +++ b/tests/tools/test_sync_back_backends.py @@ -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 # ===================================================================== diff --git a/tools/environments/__init__.py b/tools/environments/__init__.py index 7ffcce1c6..2f338bf53 100644 --- a/tools/environments/__init__.py +++ b/tools/environments/__init__.py @@ -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. diff --git a/tools/environments/koyeb.py b/tools/environments/koyeb.py new file mode 100644 index 000000000..7fd5ec7fe --- /dev/null +++ b/tools/environments/koyeb.py @@ -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 diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index b288d4ad9..1f0eccbe6 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -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')}") diff --git a/website/docs/developer-guide/architecture.md b/website/docs/developer-guide/architecture.md index 17e883081..43b0411d5 100644 --- a/website/docs/developer-guide/architecture.md +++ b/website/docs/developer-guide/architecture.md @@ -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) diff --git a/website/docs/developer-guide/environments.md b/website/docs/developer-guide/environments.md index 3409f3047..0ec1968c7 100644 --- a/website/docs/developer-guide/environments.md +++ b/website/docs/developer-guide/environments.md @@ -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 | diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 42280b4df..2755d228e 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -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 | diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 80f5c6f88..517c50d64 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -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. diff --git a/website/docs/user-guide/features/tools.md b/website/docs/user-guide/features/tools.md index 2283c16fb..4174e80ad 100644 --- a/website/docs/user-guide/features/tools.md +++ b/website/docs/user-guide/features/tools.md @@ -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)