diff --git a/AGENTS.md b/AGENTS.md
index ae78e005a..3a3dd5cfb 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 loop | 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. Honcho dialectic user modeling. Compatible with the agentskills.io open standard. |
| Scheduled automations | Built-in cron scheduler with delivery to any platform. Daily reports, nightly backups, weekly audits — all in natural language, running unattended. |
| Delegates and parallelizes | Spawn 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 laptop | 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. |
+| Runs anywhere, not just your laptop | 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. |
| Research-ready | Batch trajectory generation, Atropos RL environments, trajectory compression for training the next generation of tool-calling models. |
diff --git a/hermes_cli/config.py b/hermes_cli/config.py
index 58f874595..ce00266d5 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, koyeb — ignored for local/ssh)
"container_cpu": 1,
"container_memory": 5120, # MB (default 5GB)
"container_disk": 51200, # MB (default 50GB)
@@ -3694,6 +3695,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')
@@ -3886,6 +3891,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 064b1d68d..163a8eccf 100644
--- a/hermes_cli/doctor.py
+++ b/hermes_cli/doctor.py
@@ -777,6 +777,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 ebc7de940..190e18612 100644
--- a/hermes_cli/setup.py
+++ b/hermes_cli/setup.py
@@ -1182,11 +1182,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"
@@ -1441,6 +1442,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 2b76537fc..7c7bd2e19 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/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
index 0b012a8f1..7fd5ec7fe 100644
--- a/tools/environments/koyeb.py
+++ b/tools/environments/koyeb.py
@@ -1,13 +1,13 @@
"""Koyeb cloud execution environment.
Uses the Koyeb Python SDK to run commands in cloud sandboxes.
-Supports persistent sandboxes: when enabled, sandboxes are stopped on cleanup
-and resumed on next creation, preserving the filesystem across sessions.
+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
@@ -56,7 +56,6 @@ class KoyebEnvironment(BaseEnvironment):
from koyeb import Sandbox
- self._persistent = persistent_filesystem
self._task_id = task_id
self._sandbox = None
self._lock = threading.Lock()
@@ -71,43 +70,27 @@ class KoyebEnvironment(BaseEnvironment):
# 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
- sandbox_name = f"hermes-{task_id}"
- labels = {"hermes_task_id": task_id}
-
- # Try to reuse existing sandbox if persistent
- if self._persistent:
- try:
- # List existing sandboxes with our label
- existing = Sandbox.list(api_token=self._api_token, labels=labels)
- if existing:
- self._sandbox = existing[0]
- logger.info("Koyeb: resumed sandbox %s for task %s",
- self._sandbox.id, task_id)
- except Exception as e:
- logger.debug("Koyeb: could not resume sandbox for task %s: %s",
- task_id, e)
- self._sandbox = None
-
- # Create new sandbox if needed
- if self._sandbox is None:
- 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,
- idle_timeout=0, # Disable auto-sleep for persistent sandboxes
- delete_after_delay=0,
- delete_after_inactivity_delay=0,
- )
- 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
+ # 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"
@@ -135,20 +118,33 @@ class KoyebEnvironment(BaseEnvironment):
"""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)
+ 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 via Koyeb SDK."""
+ """Upload many files as a single tar archive to avoid per-file HTTP overhead."""
if not files:
return
- parents = unique_parent_dirs(files)
- if parents:
- self._sandbox.exec(quoted_mkdir_command(parents))
+ import tarfile
+ import tempfile
- # Upload files one by one (Koyeb SDK doesn't have bulk upload for files)
- for host_path, remote_path in files:
- self._sandbox.filesystem.upload_file(host_path, remote_path)
+ 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."""
@@ -228,14 +224,8 @@ class KoyebEnvironment(BaseEnvironment):
logger.warning("Koyeb: sync_back failed: %s", e)
try:
- if self._persistent:
- # For persistent sandboxes, we don't delete them
- # They'll be reused on next creation
- logger.info("Koyeb: keeping sandbox %s (filesystem preserved)",
- self._sandbox.id)
- else:
- self._sandbox.delete()
- logger.info("Koyeb: deleted sandbox %s", self._sandbox.id)
+ 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