mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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:
parent
455bf2e853
commit
735ca9dfb2
9 changed files with 113 additions and 102 deletions
|
|
@ -101,21 +101,11 @@ Available methods:
|
||||||
|
|
||||||
### Patches (`patches.py`)
|
### 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:
|
`patches.py` is now a no-op (kept for backward compatibility with imports).
|
||||||
- `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`.
|
|
||||||
|
|
||||||
### Tool Call Parsers (`tool_call_parsers/`)
|
### Tool Call Parsers (`tool_call_parsers/`)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2092,11 +2092,11 @@ def setup_terminal_backend(config: dict):
|
||||||
print_info("Serverless cloud sandboxes. Each session gets its own container.")
|
print_info("Serverless cloud sandboxes. Each session gets its own container.")
|
||||||
print_info("Requires a Modal account: https://modal.com")
|
print_info("Requires a Modal account: https://modal.com")
|
||||||
|
|
||||||
# Check if swe-rex[modal] is installed
|
# Check if modal SDK is installed
|
||||||
try:
|
try:
|
||||||
__import__("swe_rex")
|
__import__("modal")
|
||||||
except ImportError:
|
except ImportError:
|
||||||
print_info("Installing swe-rex[modal]...")
|
print_info("Installing modal SDK...")
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
uv_bin = shutil.which("uv")
|
uv_bin = shutil.which("uv")
|
||||||
|
|
@ -2108,22 +2108,22 @@ def setup_terminal_backend(config: dict):
|
||||||
"install",
|
"install",
|
||||||
"--python",
|
"--python",
|
||||||
sys.executable,
|
sys.executable,
|
||||||
"swe-rex[modal]",
|
"modal",
|
||||||
],
|
],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[sys.executable, "-m", "pip", "install", "swe-rex[modal]"],
|
[sys.executable, "-m", "pip", "install", "modal"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
print_success("swe-rex[modal] installed")
|
print_success("modal SDK installed")
|
||||||
else:
|
else:
|
||||||
print_warning(
|
print_warning(
|
||||||
"Install failed — run manually: pip install 'swe-rex[modal]'"
|
"Install failed — run manually: pip install modal"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Modal token
|
# Modal token
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ dependencies = [
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
modal = ["swe-rex[modal]>=1.4.0,<2"]
|
modal = ["modal>=1.0.0,<2"]
|
||||||
daytona = ["daytona>=0.148.0,<1"]
|
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"]
|
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"]
|
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"]
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
# Kill all running Modal apps (sandboxes, deployments, etc.)
|
# Kill all running Modal apps (sandboxes, deployments, etc.)
|
||||||
#
|
#
|
||||||
# Usage:
|
# 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
|
# bash scripts/kill_modal.sh --all # Stop ALL Modal apps
|
||||||
|
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
@ -17,10 +17,10 @@ if [[ "${1:-}" == "--all" ]]; then
|
||||||
modal app stop "$app_id" 2>/dev/null || true
|
modal app stop "$app_id" 2>/dev/null || true
|
||||||
done
|
done
|
||||||
else
|
else
|
||||||
echo "Stopping swe-rex sandboxes..."
|
echo "Stopping hermes-agent sandboxes..."
|
||||||
APPS=$(echo "$APP_LIST" | grep 'swe-rex' | grep -oE 'ap-[A-Za-z0-9]+' || true)
|
APPS=$(echo "$APP_LIST" | grep 'hermes-agent' | grep -oE 'ap-[A-Za-z0-9]+' || true)
|
||||||
if [[ -z "$APPS" ]]; then
|
if [[ -z "$APPS" ]]; then
|
||||||
echo " No swe-rex apps found."
|
echo " No hermes-agent apps found."
|
||||||
else
|
else
|
||||||
echo "$APPS" | while read app_id; do
|
echo "$APPS" | while read app_id; do
|
||||||
echo " Stopping $app_id"
|
echo " Stopping $app_id"
|
||||||
|
|
@ -30,5 +30,5 @@ else
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Current swe-rex status:"
|
echo "Current hermes-agent status:"
|
||||||
modal app list 2>/dev/null | grep -E 'State|swe-rex' || echo " (none)"
|
modal app list 2>/dev/null | grep -E 'State|hermes-agent' || echo " (none)"
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,9 @@ Covers the bugs discovered while setting up TBLite evaluation:
|
||||||
1. Tool resolution — terminal + file tools load correctly
|
1. Tool resolution — terminal + file tools load correctly
|
||||||
2. CWD fix — host paths get replaced with /root for container backends
|
2. CWD fix — host paths get replaced with /root for container backends
|
||||||
3. ephemeral_disk version check
|
3. ephemeral_disk version check
|
||||||
4. Tilde ~ replaced with /root for container backends
|
4. ensurepip fix in Modal image builder
|
||||||
5. ensurepip fix in Modal image builder
|
5. No swe-rex dependency — uses native Modal SDK
|
||||||
6. install_pipx stays True for swerex-remote
|
6. /home/ added to host prefix check
|
||||||
7. /home/ added to host prefix check
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
@ -251,7 +250,7 @@ class TestModalEnvironmentDefaults:
|
||||||
|
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Test 7: ensurepip fix in patches.py
|
# Test 7: ensurepip fix in ModalEnvironment
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
class TestEnsurepipFix:
|
class TestEnsurepipFix:
|
||||||
|
|
@ -275,17 +274,24 @@ class TestEnsurepipFix:
|
||||||
"to fix pip before Modal's bootstrap"
|
"to fix pip before Modal's bootstrap"
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_modal_environment_uses_install_pipx(self):
|
def test_modal_environment_uses_native_sdk(self):
|
||||||
"""ModalEnvironment should pass install_pipx to ModalDeployment."""
|
"""ModalEnvironment should use Modal SDK directly, not swe-rex."""
|
||||||
try:
|
try:
|
||||||
from tools.environments.modal import ModalEnvironment
|
from tools.environments.modal import ModalEnvironment
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pytest.skip("tools.environments.modal not importable")
|
pytest.skip("tools.environments.modal not importable")
|
||||||
|
|
||||||
import inspect
|
import inspect
|
||||||
source = inspect.getsource(ModalEnvironment.__init__)
|
source = inspect.getsource(ModalEnvironment)
|
||||||
assert "install_pipx" in source, (
|
assert "swerex" not in source.lower(), (
|
||||||
"ModalEnvironment should pass install_pipx to ModalDeployment"
|
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ def test_modal_backend_without_token_or_config_logs_specific_error(monkeypatch,
|
||||||
monkeypatch.setenv("TERMINAL_ENV", "modal")
|
monkeypatch.setenv("TERMINAL_ENV", "modal")
|
||||||
monkeypatch.setenv("HOME", str(tmp_path))
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||||||
monkeypatch.setenv("USERPROFILE", 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())
|
monkeypatch.setattr(terminal_tool_module.importlib.util, "find_spec", lambda _name: object())
|
||||||
|
|
||||||
with caplog.at_level(logging.ERROR):
|
with caplog.at_level(logging.ERROR):
|
||||||
|
|
|
||||||
|
|
@ -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
|
Replaces the previous swe-rex ModalDeployment wrapper with native Modal
|
||||||
is snapshotted on cleanup and restored on next creation, so installed packages,
|
Sandbox.create() + Sandbox.exec() calls. This eliminates the need for
|
||||||
project files, and config changes survive across sessions.
|
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 asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import shlex
|
||||||
import threading
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -39,7 +46,7 @@ def _save_snapshots(data: Dict[str, str]) -> None:
|
||||||
|
|
||||||
|
|
||||||
class _AsyncWorker:
|
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,
|
Allows sync code to submit async coroutines and block for results,
|
||||||
even when called from inside another running event loop (e.g. Atropos).
|
even when called from inside another running event loop (e.g. Atropos).
|
||||||
|
|
@ -75,9 +82,10 @@ class _AsyncWorker:
|
||||||
|
|
||||||
|
|
||||||
class ModalEnvironment(BaseEnvironment):
|
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),
|
Adds sudo -S support, configurable resources (CPU, memory, disk),
|
||||||
and optional filesystem persistence via Modal's snapshot API.
|
and optional filesystem persistence via Modal's snapshot API.
|
||||||
"""
|
"""
|
||||||
|
|
@ -96,7 +104,8 @@ class ModalEnvironment(BaseEnvironment):
|
||||||
self._persistent = persistent_filesystem
|
self._persistent = persistent_filesystem
|
||||||
self._task_id = task_id
|
self._task_id = task_id
|
||||||
self._base_image = image
|
self._base_image = image
|
||||||
self._deployment = None
|
self._sandbox = None
|
||||||
|
self._app = None
|
||||||
self._worker = _AsyncWorker()
|
self._worker = _AsyncWorker()
|
||||||
|
|
||||||
sandbox_kwargs = dict(modal_sandbox_kwargs or {})
|
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.
|
# so all gRPC channels are bound to the worker's event loop.
|
||||||
self._worker.start()
|
self._worker.start()
|
||||||
|
|
||||||
from swerex.deployment.modal import ModalDeployment
|
async def _create_sandbox():
|
||||||
|
app = await _modal.App.lookup.aio(
|
||||||
async def _create_and_start():
|
"hermes-agent", create_if_missing=True
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
await deployment.start()
|
sandbox = await _modal.Sandbox.create.aio(
|
||||||
return deployment
|
"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 = "", *,
|
def execute(self, command: str, cwd: str = "", *,
|
||||||
timeout: int | None = None,
|
timeout: int | None = None,
|
||||||
|
|
@ -159,42 +170,47 @@ class ModalEnvironment(BaseEnvironment):
|
||||||
|
|
||||||
exec_command, sudo_stdin = self._prepare_command(command)
|
exec_command, sudo_stdin = self._prepare_command(command)
|
||||||
|
|
||||||
# Modal sandboxes execute commands via the Modal SDK and cannot pipe
|
# Modal sandboxes execute commands via exec() and cannot pipe
|
||||||
# subprocess stdin directly the way a local Popen can. When a sudo
|
# subprocess stdin directly. When a sudo password is present,
|
||||||
# password is present, use a shell-level pipe from printf so that the
|
# use a shell-level pipe from printf.
|
||||||
# password feeds sudo -S without appearing as an echo argument embedded
|
|
||||||
# in the shell string.
|
|
||||||
if sudo_stdin is not None:
|
if sudo_stdin is not None:
|
||||||
import shlex
|
|
||||||
exec_command = (
|
exec_command = (
|
||||||
f"printf '%s\\n' {shlex.quote(sudo_stdin.rstrip())} | {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_cwd = cwd or self.cwd
|
||||||
effective_timeout = timeout or self.timeout
|
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
|
# Run in a background thread so we can poll for interrupts
|
||||||
result_holder = {"value": None, "error": None}
|
result_holder = {"value": None, "error": None}
|
||||||
|
|
||||||
def _run():
|
def _run():
|
||||||
try:
|
try:
|
||||||
async def _do_execute():
|
async def _do_execute():
|
||||||
return await self._deployment.runtime.execute(
|
process = await self._sandbox.exec.aio(
|
||||||
RexCommand(
|
"bash", "-c", full_command,
|
||||||
command=exec_command,
|
timeout=effective_timeout,
|
||||||
shell=True,
|
|
||||||
check=False,
|
|
||||||
cwd=effective_cwd,
|
|
||||||
timeout=effective_timeout,
|
|
||||||
merge_output_streams=True,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
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"] = {
|
result_holder["value"] = {
|
||||||
"output": output.stdout,
|
"output": output,
|
||||||
"returncode": output.exit_code,
|
"returncode": exit_code,
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
result_holder["error"] = e
|
result_holder["error"] = e
|
||||||
|
|
@ -206,7 +222,7 @@ class ModalEnvironment(BaseEnvironment):
|
||||||
if is_interrupted():
|
if is_interrupted():
|
||||||
try:
|
try:
|
||||||
self._worker.run_coroutine(
|
self._worker.run_coroutine(
|
||||||
asyncio.wait_for(self._deployment.stop(), timeout=10),
|
self._sandbox.terminate.aio(),
|
||||||
timeout=15,
|
timeout=15,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -222,38 +238,37 @@ class ModalEnvironment(BaseEnvironment):
|
||||||
|
|
||||||
def cleanup(self):
|
def cleanup(self):
|
||||||
"""Snapshot the filesystem (if persistent) then stop the sandbox."""
|
"""Snapshot the filesystem (if persistent) then stop the sandbox."""
|
||||||
if self._deployment is None:
|
if self._sandbox is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
if self._persistent:
|
if self._persistent:
|
||||||
try:
|
try:
|
||||||
sandbox = getattr(self._deployment, '_sandbox', None)
|
async def _snapshot():
|
||||||
if sandbox:
|
img = await self._sandbox.snapshot_filesystem.aio()
|
||||||
async def _snapshot():
|
return img.object_id
|
||||||
img = await sandbox.snapshot_filesystem.aio()
|
|
||||||
return img.object_id
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
snapshot_id = self._worker.run_coroutine(_snapshot(), timeout=60)
|
snapshot_id = self._worker.run_coroutine(_snapshot(), timeout=60)
|
||||||
except Exception:
|
except Exception:
|
||||||
snapshot_id = None
|
snapshot_id = None
|
||||||
|
|
||||||
if snapshot_id:
|
if snapshot_id:
|
||||||
snapshots = _load_snapshots()
|
snapshots = _load_snapshots()
|
||||||
snapshots[self._task_id] = snapshot_id
|
snapshots[self._task_id] = snapshot_id
|
||||||
_save_snapshots(snapshots)
|
_save_snapshots(snapshots)
|
||||||
logger.info("Modal: saved filesystem snapshot %s for task %s",
|
logger.info("Modal: saved filesystem snapshot %s for task %s",
|
||||||
snapshot_id[:20], self._task_id)
|
snapshot_id[:20], self._task_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Modal: filesystem snapshot failed: %s", e)
|
logger.warning("Modal: filesystem snapshot failed: %s", e)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._worker.run_coroutine(
|
self._worker.run_coroutine(
|
||||||
asyncio.wait_for(self._deployment.stop(), timeout=10),
|
self._sandbox.terminate.aio(),
|
||||||
timeout=15,
|
timeout=15,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
self._worker.stop()
|
self._worker.stop()
|
||||||
self._deployment = None
|
self._sandbox = None
|
||||||
|
self._app = None
|
||||||
|
|
|
||||||
|
|
@ -1216,8 +1216,8 @@ def check_terminal_requirements() -> bool:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
elif env_type == "modal":
|
elif env_type == "modal":
|
||||||
if importlib.util.find_spec("swerex") is None:
|
if importlib.util.find_spec("modal") is None:
|
||||||
logger.error("swe-rex is required for modal terminal backend: pip install 'swe-rex[modal]'")
|
logger.error("modal is required for modal terminal backend: pip install modal")
|
||||||
return False
|
return False
|
||||||
has_token = os.getenv("MODAL_TOKEN_ID") is not None
|
has_token = os.getenv("MODAL_TOKEN_ID") is not None
|
||||||
has_config = Path.home().joinpath(".modal.toml").exists()
|
has_config = Path.home().joinpath(".modal.toml").exists()
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ hermes config set terminal.singularity_image ~/python.sif
|
||||||
### Modal (Serverless Cloud)
|
### Modal (Serverless Cloud)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv pip install "swe-rex[modal]"
|
uv pip install modal
|
||||||
modal setup
|
modal setup
|
||||||
hermes config set terminal.backend modal
|
hermes config set terminal.backend modal
|
||||||
```
|
```
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue