Files
hermes-agent/tools/environments/managed_modal.py
Teknium a5bd246865 Old pre-decomposition import paths are gone: plugin compat layer removed on schedule (#126164)
* refactor(plugins): remove the Sep 2026 decomposition compat layer on schedule

The PLUGIN-COMPAT layer (2776813df3 + d63e380324 + 0a5164cebe) kept pre-#102117 import paths
alive for external plugins until 2026-09-14. That window closed two weeks ago; since then the loader
has already been skipping plugins that use the old paths. This removes the layer itself:

- 328 appended `PLUGIN-COMPAT` blocks (lazy `__getattr__` pointer tables, re-exported third-party
  names, restored dead definitions) and the three re-export stub modules
  (gateway/startup_watchdog, hermes_cli/observability/relay_runtime, tools/environments/modal_utils)
- COMPAT_MANIFEST.md, compat_manifest.json, scripts/check_compat_pointers.py and its lint step
- the reporting surfaces: CLI banner notice, `hermes plugins compat`, the `hermes doctor` section,
  the post-update notice, the Desktop one-time dialog, the loader's pre-import skip and the
  `plugins.allow_deprecated_imports` escape hatch

An external plugin that still imports an old path now fails to load with its ImportError as the
reason in `hermes plugins list`, the same path as any broken plugin.

hermes_cli/plugin_compat.py stays as three inert stubs (compat_report, removal_in_effect,
summary_lines): an already-running pre-removal `hermes update` lazy-imports them after the checkout
swap (tests/compat/old_updater_surface.json).

In-tree fallout, both already dead: hermes_cli/setup.py::_check_espeak_ng (no callers; its
`shutil` came from a compat block) and gateway/config.py::SessionResetPolicy ("retained solely for
the scheduled plugin-compat window"). Two test_run_agent patches targeted the removed
`run_agent.handle_function_call` pointer; they now patch `model_tools.handle_function_call`, the
seam production reads, like every sibling test in that file.

* chore: retrigger CI (zero-job startup_failure phantom)

* test: drop resolution allowlist rows for the two deleted which() sites

hermes_cli/setup.py::_check_espeak_ng (dead) and tools/skillevaluator_scan.py::scanner_available
(a restored definition inside a PLUGIN-COMPAT block) no longer exist; the stale-row gate requires
their allowlist entries go with them.
2026-09-28 10:21:41 -07:00

206 lines
10 KiB
Python

"""Managed Modal environment backed by tool-gateway.
Deliberately overrides :meth:`BaseEnvironment.execute`: the tool-gateway does command
preparation, CWD tracking and env-snapshot management server-side, so the base
``_wrap_command`` / ``_wait_for_process`` / snapshot machinery does not apply.
"""
from __future__ import annotations
import json
import logging
import os
import requests
import shlex
import time
import uuid
from typing import Any, Dict, Optional
from tools.environments.base import BaseEnvironment
from tools.interrupt import is_interrupted
from tools.managed_tool_gateway import resolve_managed_tool_gateway
logger = logging.getLogger(__name__)
_TERMINAL_EXEC_STATUSES = frozenset({"completed", "failed", "cancelled", "timeout"})
_CLIENT_TIMEOUT_GRACE_SECONDS = 10.0
def _coerce_number(value: Any, default: float) -> float:
try:
return default if value is None else float(value)
except (TypeError, ValueError):
return default
def _request_timeout_env(name: str, default: float) -> float:
value = _coerce_number(os.getenv(name), default)
return value if value > 0 else default
def _result(output: str, returncode: int = 1) -> dict:
return {"output": output, "returncode": returncode}
class ManagedModalEnvironment(BaseEnvironment):
"""Gateway-owned Modal sandbox with Hermes-compatible execute/cleanup."""
_stdin_mode = "payload"
_CONNECT_TIMEOUT_SECONDS = _request_timeout_env("TERMINAL_MANAGED_MODAL_CONNECT_TIMEOUT_SECONDS", 1.0)
_POLL_READ_TIMEOUT_SECONDS = _request_timeout_env("TERMINAL_MANAGED_MODAL_POLL_READ_TIMEOUT_SECONDS", 5.0)
_CANCEL_READ_TIMEOUT_SECONDS = _request_timeout_env("TERMINAL_MANAGED_MODAL_CANCEL_READ_TIMEOUT_SECONDS", 5.0)
def __init__(self, image: str, cwd: str = "/root", timeout: int = 60,
modal_sandbox_kwargs: Optional[Dict[str, Any]] = None,
persistent_filesystem: bool = True, task_id: str = "default"):
super().__init__(cwd=cwd, timeout=timeout)
# Managed Modal does not sync or mount host credential files.
try:
from tools.credential_files import get_credential_file_mounts
except Exception:
get_credential_file_mounts = None
if get_credential_file_mounts is not None and get_credential_file_mounts():
raise ValueError(
"Managed Modal does not support host credential-file passthrough. "
"Use TERMINAL_MODAL_MODE=direct when skills or config require "
"credential files inside the sandbox."
)
gateway = resolve_managed_tool_gateway("modal")
if gateway is None:
raise ValueError("Managed Modal requires a configured tool gateway and Nous user token")
self._gateway_origin = gateway.gateway_origin.rstrip("/")
self._nous_user_token = gateway.nous_user_token
self._task_id, self._persistent, self._image = task_id, persistent_filesystem, image
self._sandbox_kwargs = dict(modal_sandbox_kwargs or {})
self._create_idempotency_key = str(uuid.uuid4())
self._sandbox_id = self._create_sandbox()
def execute(self, command: str, cwd: str = "", *, timeout: int | None = None, stdin_data: str | None = None,
rewrite_compound_background: bool = True, bounded_capture: bool = False) -> dict:
# Signature parity with BaseEnvironment.execute only: the gateway runs commands
# explicitly (no shell background rewriting) and returns the remote result in one
# payload, so streaming-time bounding does not apply (the terminal tool's final
# truncation still caps it).
del rewrite_compound_background, bounded_capture
exec_command, sudo_stdin = self._prepare_command(command)
if sudo_stdin is not None:
# Feed sudo via a shell pipe: the transport has no direct stdin piping.
exec_command = f"printf '%s\\n' {shlex.quote(sudo_stdin.rstrip())} | {exec_command}"
timeout = timeout or self.timeout
exec_id = str(uuid.uuid4())
payload: Dict[str, Any] = {"execId": exec_id, "command": exec_command, "cwd": cwd or self.cwd,
"timeoutMs": int(timeout * 1000)}
if stdin_data is not None:
payload["stdinData"] = stdin_data
try:
response = self._request("POST", f"/v1/sandboxes/{self._sandbox_id}/execs", json=payload, timeout=10)
body = response.json() if response.status_code < 400 else None
except Exception as exc:
return _result(f"Managed Modal exec failed: {exc}")
if body is None:
return _result(self._format_error("Managed Modal exec failed", response))
if (final := self._result_from_body(body)) is not None:
return final
if body.get("execId") != exec_id:
return _result("Managed Modal exec start did not return the expected exec id")
deadline = time.monotonic() + timeout + _CLIENT_TIMEOUT_GRACE_SECONDS
_now = time.monotonic()
_activity_state = {"last_touch": _now, "start": _now}
while True:
if is_interrupted():
self._cancel_exec(exec_id)
return _result("[Command interrupted - Modal sandbox exec cancelled]", 130)
try:
if (result := self._poll_exec(exec_id)) is not None:
return result
except Exception as exc:
return _result(f"Managed Modal exec failed: {exc}")
if time.monotonic() >= deadline:
self._cancel_exec(exec_id)
return _result(f"Managed Modal exec timed out after {timeout}s", 124)
# Periodic activity touch so the gateway knows we're alive (lazy import:
# tests stub tools.environments.base with only BaseEnvironment)
try:
from tools.environments.base import touch_activity_if_due
touch_activity_if_due(_activity_state, "modal command running")
except Exception:
pass
time.sleep(0.25)
@staticmethod
def _result_from_body(body: dict) -> dict | None:
"""Final result dict if the exec body reports a terminal status, else ``None``."""
if body.get("status") in _TERMINAL_EXEC_STATUSES:
return _result(body.get("output", ""), body.get("returncode", 1))
def _poll_exec(self, exec_id: str) -> dict | None:
try:
response = self._request("GET", f"/v1/sandboxes/{self._sandbox_id}/execs/{exec_id}",
timeout=(self._CONNECT_TIMEOUT_SECONDS, self._POLL_READ_TIMEOUT_SECONDS))
except Exception as exc:
return _result(f"Managed Modal exec poll failed: {exc}")
if response.status_code == 404:
return _result("Managed Modal exec not found")
if response.status_code >= 400:
return _result(self._format_error("Managed Modal exec poll failed", response))
return self._result_from_body(response.json())
def _cancel_exec(self, exec_id: str) -> None:
try:
self._request("POST", f"/v1/sandboxes/{self._sandbox_id}/execs/{exec_id}/cancel",
timeout=(self._CONNECT_TIMEOUT_SECONDS, self._CANCEL_READ_TIMEOUT_SECONDS))
except Exception as exc:
logger.warning("Managed Modal exec cancel failed: %s", exc)
def cleanup(self):
if not getattr(self, "_sandbox_id", None):
return
try:
self._request("POST", f"/v1/sandboxes/{self._sandbox_id}/terminate",
json={"snapshotBeforeTerminate": self._persistent}, timeout=60)
except Exception as exc:
logger.warning("Managed Modal cleanup failed: %s", exc)
finally:
self._sandbox_id = None
def _create_sandbox(self) -> str:
kw = self._sandbox_kwargs
cpu = _coerce_number(kw.get("cpu"), 1)
memory = _coerce_number(kw.get("memoryMiB", kw.get("memory")), 5120)
disk = _coerce_number(kw.get("ephemeral_disk", kw.get("diskMiB")), None)
create_payload = {
"image": self._image, "cwd": self.cwd, "cpu": cpu, "memoryMiB": memory, "timeoutMs": 3_600_000,
"idleTimeoutMs": max(300_000, int(self.timeout * 1000)),
"persistentFilesystem": self._persistent, "logicalKey": self._task_id,
}
if disk is not None:
create_payload["diskMiB"] = disk
response = self._request("POST", "/v1/sandboxes", json=create_payload, timeout=60,
extra_headers={"x-idempotency-key": self._create_idempotency_key})
if response.status_code >= 400:
raise RuntimeError(self._format_error("Managed Modal create failed", response))
sandbox_id = response.json().get("id")
if not isinstance(sandbox_id, str) or not sandbox_id:
raise RuntimeError("Managed Modal create did not return a sandbox id")
return sandbox_id
def _request(self, method: str, path: str, *, json: Dict[str, Any] | None = None, timeout: int = 30,
extra_headers: Dict[str, str] | None = None) -> requests.Response:
headers = {"Authorization": f"Bearer {self._nous_user_token}", "Content-Type": "application/json",
**(extra_headers or {})}
return requests.request(method, f"{self._gateway_origin}{path}", headers=headers, json=json, timeout=timeout)
@staticmethod
def _format_error(prefix: str, response: requests.Response) -> str:
try:
payload = response.json()
if isinstance(payload, dict):
message = payload.get("error") or payload.get("message") or payload.get("code")
if isinstance(message, str) and message:
return f"{prefix}: {message}"
return f"{prefix}: {json.dumps(payload, ensure_ascii=False)}"
except Exception:
pass
text = response.text.strip()
return f"{prefix}: {text}" if text else f"{prefix}: HTTP {response.status_code}"