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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user