diff --git a/environments/README.md b/environments/README.md index f2d1a795..9677fdb7 100644 --- a/environments/README.md +++ b/environments/README.md @@ -101,21 +101,11 @@ Available methods: ### Patches (`patches.py`) -**Problem**: Some hermes-agent tools use `asyncio.run()` internally (e.g., the Modal backend via SWE-ReX). This crashes when called from inside Atropos's event loop because `asyncio.run()` cannot be nested. +**Problem**: Some hermes-agent tools use `asyncio.run()` internally (e.g., the Modal backend). This crashes when called from inside Atropos's event loop because `asyncio.run()` cannot be nested. -**Solution**: `patches.py` monkey-patches `SwerexModalEnvironment` to use a dedicated background thread (`_AsyncWorker`) with its own event loop. The calling code sees the same sync interface, but internally the async work happens on a separate thread that doesn't conflict with Atropos's loop. +**Solution**: `ModalEnvironment` uses a dedicated `_AsyncWorker` background thread with its own event loop. The calling code sees a sync interface, but internally all async Modal SDK calls happen on the worker thread so they don't conflict with Atropos's loop. This is built directly into `tools/environments/modal.py` — no monkey-patching required. -What gets patched: -- `SwerexModalEnvironment.__init__` -- creates Modal deployment on a background thread -- `SwerexModalEnvironment.execute` -- runs commands on the same background thread -- `SwerexModalEnvironment.stop` -- stops deployment on the background thread - -The patches are: -- **Idempotent** -- calling `apply_patches()` multiple times is safe -- **Transparent** -- same interface and behavior, only the internal async execution changes -- **Universal** -- works identically in normal CLI use (no running event loop) - -Applied automatically at import time by `hermes_base_env.py`. +`patches.py` is now a no-op (kept for backward compatibility with imports). ### Tool Call Parsers (`tool_call_parsers/`) diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 88d62970..89f8b0df 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -2092,11 +2092,11 @@ def setup_terminal_backend(config: dict): print_info("Serverless cloud sandboxes. Each session gets its own container.") print_info("Requires a Modal account: https://modal.com") - # Check if swe-rex[modal] is installed + # Check if modal SDK is installed try: - __import__("swe_rex") + __import__("modal") except ImportError: - print_info("Installing swe-rex[modal]...") + print_info("Installing modal SDK...") import subprocess uv_bin = shutil.which("uv") @@ -2108,22 +2108,22 @@ def setup_terminal_backend(config: dict): "install", "--python", sys.executable, - "swe-rex[modal]", + "modal", ], capture_output=True, text=True, ) else: result = subprocess.run( - [sys.executable, "-m", "pip", "install", "swe-rex[modal]"], + [sys.executable, "-m", "pip", "install", "modal"], capture_output=True, text=True, ) if result.returncode == 0: - print_success("swe-rex[modal] installed") + print_success("modal SDK installed") else: print_warning( - "Install failed — run manually: pip install 'swe-rex[modal]'" + "Install failed — run manually: pip install modal" ) # Modal token diff --git a/pyproject.toml b/pyproject.toml index c0a7078e..0b02c9d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ dependencies = [ ] [project.optional-dependencies] -modal = ["swe-rex[modal]>=1.4.0,<2"] +modal = ["modal>=1.0.0,<2"] daytona = ["daytona>=0.148.0,<1"] dev = ["pytest>=9.0.2,<10", "pytest-asyncio>=1.3.0,<2", "pytest-xdist>=3.0,<4", "mcp>=1.2.0,<2"] messaging = ["python-telegram-bot>=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"] diff --git a/scripts/kill_modal.sh b/scripts/kill_modal.sh index aae3f63e..1e9a3312 100755 --- a/scripts/kill_modal.sh +++ b/scripts/kill_modal.sh @@ -2,7 +2,7 @@ # Kill all running Modal apps (sandboxes, deployments, etc.) # # Usage: -# bash scripts/kill_modal.sh # Stop swe-rex (the sandbox app) +# bash scripts/kill_modal.sh # Stop hermes-agent sandboxes # bash scripts/kill_modal.sh --all # Stop ALL Modal apps set -uo pipefail @@ -17,10 +17,10 @@ if [[ "${1:-}" == "--all" ]]; then modal app stop "$app_id" 2>/dev/null || true done else - echo "Stopping swe-rex sandboxes..." - APPS=$(echo "$APP_LIST" | grep 'swe-rex' | grep -oE 'ap-[A-Za-z0-9]+' || true) + echo "Stopping hermes-agent sandboxes..." + APPS=$(echo "$APP_LIST" | grep 'hermes-agent' | grep -oE 'ap-[A-Za-z0-9]+' || true) if [[ -z "$APPS" ]]; then - echo " No swe-rex apps found." + echo " No hermes-agent apps found." else echo "$APPS" | while read app_id; do echo " Stopping $app_id" @@ -30,5 +30,5 @@ else fi echo "" -echo "Current swe-rex status:" -modal app list 2>/dev/null | grep -E 'State|swe-rex' || echo " (none)" +echo "Current hermes-agent status:" +modal app list 2>/dev/null | grep -E 'State|hermes-agent' || echo " (none)" diff --git a/tests/tools/test_modal_sandbox_fixes.py b/tests/tools/test_modal_sandbox_fixes.py index 23dfa2f8..7e3feb5c 100644 --- a/tests/tools/test_modal_sandbox_fixes.py +++ b/tests/tools/test_modal_sandbox_fixes.py @@ -4,10 +4,9 @@ Covers the bugs discovered while setting up TBLite evaluation: 1. Tool resolution — terminal + file tools load correctly 2. CWD fix — host paths get replaced with /root for container backends 3. ephemeral_disk version check -4. Tilde ~ replaced with /root for container backends -5. ensurepip fix in Modal image builder -6. install_pipx stays True for swerex-remote -7. /home/ added to host prefix check +4. ensurepip fix in Modal image builder +5. No swe-rex dependency — uses native Modal SDK +6. /home/ added to host prefix check """ import os @@ -251,7 +250,7 @@ class TestModalEnvironmentDefaults: # ========================================================================= -# Test 7: ensurepip fix in patches.py +# Test 7: ensurepip fix in ModalEnvironment # ========================================================================= class TestEnsurepipFix: @@ -275,17 +274,24 @@ class TestEnsurepipFix: "to fix pip before Modal's bootstrap" ) - def test_modal_environment_uses_install_pipx(self): - """ModalEnvironment should pass install_pipx to ModalDeployment.""" + def test_modal_environment_uses_native_sdk(self): + """ModalEnvironment should use Modal SDK directly, not swe-rex.""" try: from tools.environments.modal import ModalEnvironment except ImportError: pytest.skip("tools.environments.modal not importable") import inspect - source = inspect.getsource(ModalEnvironment.__init__) - assert "install_pipx" in source, ( - "ModalEnvironment should pass install_pipx to ModalDeployment" + source = inspect.getsource(ModalEnvironment) + assert "swerex" not in source.lower(), ( + "ModalEnvironment should not depend on swe-rex; " + "use Modal SDK directly via Sandbox.create() + exec()" + ) + assert "Sandbox.create.aio" in source, ( + "ModalEnvironment should use async Modal Sandbox.create.aio()" + ) + assert "exec.aio" in source, ( + "ModalEnvironment should use Sandbox.exec.aio() for command execution" ) diff --git a/tests/tools/test_terminal_requirements.py b/tests/tools/test_terminal_requirements.py index b3bc0b19..cefb81cd 100644 --- a/tests/tools/test_terminal_requirements.py +++ b/tests/tools/test_terminal_requirements.py @@ -63,7 +63,7 @@ def test_modal_backend_without_token_or_config_logs_specific_error(monkeypatch, monkeypatch.setenv("TERMINAL_ENV", "modal") monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("USERPROFILE", str(tmp_path)) - # Pretend swerex is installed + # Pretend modal is installed monkeypatch.setattr(terminal_tool_module.importlib.util, "find_spec", lambda _name: object()) with caplog.at_level(logging.ERROR): diff --git a/tools/environments/modal.py b/tools/environments/modal.py index f8210ba7..c12ba8b1 100644 --- a/tools/environments/modal.py +++ b/tools/environments/modal.py @@ -1,13 +1,20 @@ -"""Modal cloud execution environment using SWE-ReX directly. +"""Modal cloud execution environment using the Modal SDK directly. -Supports persistent filesystem snapshots: when enabled, the sandbox's filesystem -is snapshotted on cleanup and restored on next creation, so installed packages, -project files, and config changes survive across sessions. +Replaces the previous swe-rex ModalDeployment wrapper with native Modal +Sandbox.create() + Sandbox.exec() calls. This eliminates the need for +swe-rex's HTTP runtime server and unencrypted tunnel, fixing: + - AsyncUsageWarning from synchronous App.lookup in async context + - DeprecationError from unencrypted_ports / .url on unencrypted tunnels + +Supports persistent filesystem snapshots: when enabled, the sandbox's +filesystem is snapshotted on cleanup and restored on next creation, so +installed packages, project files, and config changes survive across sessions. """ import asyncio import json import logging +import shlex import threading import uuid from pathlib import Path @@ -39,7 +46,7 @@ def _save_snapshots(data: Dict[str, str]) -> None: class _AsyncWorker: - """Background thread with its own event loop for async-safe swe-rex calls. + """Background thread with its own event loop for async-safe Modal calls. Allows sync code to submit async coroutines and block for results, even when called from inside another running event loop (e.g. Atropos). @@ -75,9 +82,10 @@ class _AsyncWorker: class ModalEnvironment(BaseEnvironment): - """Modal cloud execution via SWE-ReX. + """Modal cloud execution via native Modal SDK. - Uses swe-rex's ModalDeployment directly for sandbox management. + Uses Modal's Sandbox.create() for container lifecycle and Sandbox.exec() + for command execution — no intermediate HTTP server or tunnel required. Adds sudo -S support, configurable resources (CPU, memory, disk), and optional filesystem persistence via Modal's snapshot API. """ @@ -96,7 +104,8 @@ class ModalEnvironment(BaseEnvironment): self._persistent = persistent_filesystem self._task_id = task_id self._base_image = image - self._deployment = None + self._sandbox = None + self._app = None self._worker = _AsyncWorker() sandbox_kwargs = dict(modal_sandbox_kwargs or {}) @@ -128,25 +137,27 @@ class ModalEnvironment(BaseEnvironment): ], ) - # Start the async worker thread and create the deployment on it + # Start the async worker thread and create sandbox on it # so all gRPC channels are bound to the worker's event loop. self._worker.start() - from swerex.deployment.modal import ModalDeployment - - async def _create_and_start(): - deployment = ModalDeployment( - image=effective_image, - startup_timeout=180.0, - runtime_timeout=3600.0, - deployment_timeout=3600.0, - install_pipx=True, - modal_sandbox_kwargs=sandbox_kwargs, + async def _create_sandbox(): + app = await _modal.App.lookup.aio( + "hermes-agent", create_if_missing=True ) - await deployment.start() - return deployment + sandbox = await _modal.Sandbox.create.aio( + "sleep", "infinity", + image=effective_image, + app=app, + timeout=int(sandbox_kwargs.pop("timeout", 3600)), + **sandbox_kwargs, + ) + return app, sandbox - self._deployment = self._worker.run_coroutine(_create_and_start()) + self._app, self._sandbox = self._worker.run_coroutine( + _create_sandbox(), timeout=300 + ) + logger.info("Modal: sandbox created (task=%s)", self._task_id) def execute(self, command: str, cwd: str = "", *, timeout: int | None = None, @@ -159,42 +170,47 @@ class ModalEnvironment(BaseEnvironment): exec_command, sudo_stdin = self._prepare_command(command) - # Modal sandboxes execute commands via the Modal SDK and cannot pipe - # subprocess stdin directly the way a local Popen can. When a sudo - # password is present, use a shell-level pipe from printf so that the - # password feeds sudo -S without appearing as an echo argument embedded - # in the shell string. + # Modal sandboxes execute commands via exec() and cannot pipe + # subprocess stdin directly. When a sudo password is present, + # use a shell-level pipe from printf. if sudo_stdin is not None: - import shlex exec_command = ( f"printf '%s\\n' {shlex.quote(sudo_stdin.rstrip())} | {exec_command}" ) - from swerex.runtime.abstract import Command as RexCommand - effective_cwd = cwd or self.cwd effective_timeout = timeout or self.timeout + # Wrap command with cd + stderr merge + full_command = f"cd {shlex.quote(effective_cwd)} && {exec_command}" + # Run in a background thread so we can poll for interrupts result_holder = {"value": None, "error": None} def _run(): try: async def _do_execute(): - return await self._deployment.runtime.execute( - RexCommand( - command=exec_command, - shell=True, - check=False, - cwd=effective_cwd, - timeout=effective_timeout, - merge_output_streams=True, - ) + process = await self._sandbox.exec.aio( + "bash", "-c", full_command, + timeout=effective_timeout, ) - output = self._worker.run_coroutine(_do_execute()) + # Read stdout; redirect stderr to stdout in the shell + # command so we get merged output + stdout = await process.stdout.read.aio() + stderr = await process.stderr.read.aio() + exit_code = await process.wait.aio() + # Merge stdout + stderr (stderr after stdout) + output = stdout + if stderr: + output = f"{stdout}\n{stderr}" if stdout else stderr + return output, exit_code + + output, exit_code = self._worker.run_coroutine( + _do_execute(), timeout=effective_timeout + 30 + ) result_holder["value"] = { - "output": output.stdout, - "returncode": output.exit_code, + "output": output, + "returncode": exit_code, } except Exception as e: result_holder["error"] = e @@ -206,7 +222,7 @@ class ModalEnvironment(BaseEnvironment): if is_interrupted(): try: self._worker.run_coroutine( - asyncio.wait_for(self._deployment.stop(), timeout=10), + self._sandbox.terminate.aio(), timeout=15, ) except Exception: @@ -222,38 +238,37 @@ class ModalEnvironment(BaseEnvironment): def cleanup(self): """Snapshot the filesystem (if persistent) then stop the sandbox.""" - if self._deployment is None: + if self._sandbox is None: return if self._persistent: try: - sandbox = getattr(self._deployment, '_sandbox', None) - if sandbox: - async def _snapshot(): - img = await sandbox.snapshot_filesystem.aio() - return img.object_id + async def _snapshot(): + img = await self._sandbox.snapshot_filesystem.aio() + return img.object_id - try: - snapshot_id = self._worker.run_coroutine(_snapshot(), timeout=60) - except Exception: - snapshot_id = None + try: + snapshot_id = self._worker.run_coroutine(_snapshot(), timeout=60) + except Exception: + snapshot_id = None - if snapshot_id: - snapshots = _load_snapshots() - snapshots[self._task_id] = snapshot_id - _save_snapshots(snapshots) - logger.info("Modal: saved filesystem snapshot %s for task %s", - snapshot_id[:20], self._task_id) + if snapshot_id: + snapshots = _load_snapshots() + snapshots[self._task_id] = snapshot_id + _save_snapshots(snapshots) + logger.info("Modal: saved filesystem snapshot %s for task %s", + snapshot_id[:20], self._task_id) except Exception as e: logger.warning("Modal: filesystem snapshot failed: %s", e) try: self._worker.run_coroutine( - asyncio.wait_for(self._deployment.stop(), timeout=10), + self._sandbox.terminate.aio(), timeout=15, ) except Exception: pass finally: self._worker.stop() - self._deployment = None + self._sandbox = None + self._app = None diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index aa917ab1..222632f6 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -1216,8 +1216,8 @@ def check_terminal_requirements() -> bool: return True elif env_type == "modal": - if importlib.util.find_spec("swerex") is None: - logger.error("swe-rex is required for modal terminal backend: pip install 'swe-rex[modal]'") + if importlib.util.find_spec("modal") is None: + logger.error("modal is required for modal terminal backend: pip install modal") return False has_token = os.getenv("MODAL_TOKEN_ID") is not None has_config = Path.home().joinpath(".modal.toml").exists() diff --git a/website/docs/user-guide/features/tools.md b/website/docs/user-guide/features/tools.md index 981d2caf..5e1ab601 100644 --- a/website/docs/user-guide/features/tools.md +++ b/website/docs/user-guide/features/tools.md @@ -104,7 +104,7 @@ hermes config set terminal.singularity_image ~/python.sif ### Modal (Serverless Cloud) ```bash -uv pip install "swe-rex[modal]" +uv pip install modal modal setup hermes config set terminal.backend modal ```