This commit is contained in:
Fabzer 2026-04-24 19:24:50 -05:00 committed by GitHub
commit b4f338ccf4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1022 additions and 29 deletions

View file

@ -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,

View file

@ -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

View file

@ -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>

View file

@ -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

View file

@ -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",

View file

@ -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")

View file

@ -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.")

View file

@ -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"]

View 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)

View 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()

View file

@ -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
# =====================================================================

View file

@ -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
View 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

View file

@ -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')}")

View file

@ -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)

View file

@ -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 |

View file

@ -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 |

View file

@ -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.

View file

@ -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)