refactor: replace swe-rex with native Modal SDK for Modal backend (#3538)

Drop the swe-rex dependency for Modal terminal backend and use the
Modal SDK directly (Sandbox.create + Sandbox.exec). This fixes:

- AsyncUsageWarning from synchronous App.lookup() in async context
- DeprecationError from unencrypted_ports / .url on unencrypted tunnels
  (deprecated 2026-03-05)

The new implementation:
- Uses modal.App.lookup.aio() for async-safe app creation
- Uses Sandbox.create.aio() with 'sleep infinity' entrypoint
- Uses Sandbox.exec.aio() for direct command execution (no HTTP server
  or tunnel needed)
- Keeps all existing features: persistent filesystem snapshots,
  configurable resources (CPU/memory/disk), sudo support, interrupt
  handling, _AsyncWorker for event loop safety

Consistent with the Docker backend precedent (PR #2804) where we
removed mini-swe-agent in favor of direct docker run.

Files changed:
- tools/environments/modal.py - core rewrite
- tools/terminal_tool.py - health check: modal instead of swerex
- hermes_cli/setup.py - install modal instead of swe-rex[modal]
- pyproject.toml - modal extra: modal>=1.0.0 instead of swe-rex[modal]
- scripts/kill_modal.sh - grep for hermes-agent instead of swe-rex
- tests/ - updated for new implementation
- environments/README.md - updated patches section
- website/docs - updated install command
This commit is contained in:
Teknium
2026-03-28 11:21:44 -07:00
committed by GitHub
parent 455bf2e853
commit 735ca9dfb2
9 changed files with 113 additions and 102 deletions

View File

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

View File

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