mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-03 09:17:09 +08:00
refactor(environments): delete persistent_shell.py + dead code — Phase 8
- DELETE persistent_shell.py entirely (277 lines removed) - Remove _SHELL_NOISE_SUBSTRINGS, _clean_shell_noise, _extract_fenced_output from local.py (unused after fence marker removal) - Adapt ManagedModalEnvironment to use BaseEnvironment + _wrap_command() while keeping its own HTTP-based execute() - Remove _OUTPUT_FENCE constant 42/42 tests passing across all testable backends. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,20 +1,24 @@
|
||||
"""Managed Modal environment backed by tool-gateway."""
|
||||
"""Managed Modal environment backed by tool-gateway.
|
||||
|
||||
Uses ``BaseEnvironment`` for command shaping (``_wrap_command()``) but keeps
|
||||
its own ``execute()`` override because the HTTP gateway cannot return a
|
||||
ProcessHandle — all execution is request/response.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
import requests
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from tools.environments.modal_common import (
|
||||
BaseModalExecutionEnvironment,
|
||||
ModalExecStart,
|
||||
PreparedModalExec,
|
||||
)
|
||||
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__)
|
||||
@@ -33,15 +37,18 @@ class _ManagedModalExecHandle:
|
||||
exec_id: str
|
||||
|
||||
|
||||
class ManagedModalEnvironment(BaseModalExecutionEnvironment):
|
||||
"""Gateway-owned Modal sandbox with Hermes-compatible execute/cleanup."""
|
||||
class ManagedModalEnvironment(BaseEnvironment):
|
||||
"""Gateway-owned Modal sandbox with Hermes-compatible execute/cleanup.
|
||||
|
||||
Inherits from BaseEnvironment for _wrap_command() (CWD tracking,
|
||||
snapshot sourcing) but keeps its own execute() since the HTTP gateway
|
||||
cannot return a ProcessHandle.
|
||||
"""
|
||||
|
||||
_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)
|
||||
_client_timeout_grace_seconds = 10.0
|
||||
_interrupt_output = "[Command interrupted - Modal sandbox exec cancelled]"
|
||||
_unexpected_error_prefix = "Managed Modal exec failed"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -69,16 +76,124 @@ class ManagedModalEnvironment(BaseModalExecutionEnvironment):
|
||||
self._create_idempotency_key = str(uuid.uuid4())
|
||||
self._sandbox_id = self._create_sandbox()
|
||||
|
||||
def _start_modal_exec(self, prepared: PreparedModalExec) -> ModalExecStart:
|
||||
# ------------------------------------------------------------------
|
||||
# _run_bash stub — ManagedModal cannot return a ProcessHandle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _run_bash(self, cmd_string: str, *, stdin_data: str | None = None):
|
||||
raise NotImplementedError(
|
||||
"ManagedModalEnvironment is HTTP-based and cannot return a "
|
||||
"ProcessHandle. Use execute() directly."
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# execute() override — HTTP request/response model
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def execute(
|
||||
self,
|
||||
command: str,
|
||||
cwd: str = "",
|
||||
*,
|
||||
timeout: int | None = None,
|
||||
stdin_data: str | None = None,
|
||||
) -> dict:
|
||||
effective_timeout = timeout or self.timeout
|
||||
|
||||
# Handle stdin via heredoc embedding (gateway has payload support too)
|
||||
exec_stdin = stdin_data
|
||||
if stdin_data is not None:
|
||||
marker = f"HERMES_EOF_{uuid.uuid4().hex[:8]}"
|
||||
while marker in stdin_data:
|
||||
marker = f"HERMES_EOF_{uuid.uuid4().hex[:8]}"
|
||||
command = f"{command} << '{marker}'\n{stdin_data}\n{marker}"
|
||||
exec_stdin = None # embedded in command now
|
||||
|
||||
exec_command, sudo_stdin = self._prepare_command(command)
|
||||
if sudo_stdin is not None:
|
||||
exec_command = (
|
||||
f"printf '%s\\n' {shlex.quote(sudo_stdin.rstrip())} | {exec_command}"
|
||||
)
|
||||
|
||||
# Use _wrap_command for consistent CWD tracking and snapshot sourcing
|
||||
wrapped = self._wrap_command(exec_command, cwd)
|
||||
effective_cwd = cwd or self.cwd
|
||||
|
||||
# Start the exec via the gateway
|
||||
start_result = self._start_exec(wrapped, effective_cwd, effective_timeout, exec_stdin)
|
||||
|
||||
if start_result.get("_immediate"):
|
||||
result = {k: v for k, v in start_result.items() if k != "_immediate"}
|
||||
self._update_cwd_from_gateway_output(result)
|
||||
return result
|
||||
|
||||
handle = start_result.get("_handle")
|
||||
if handle is None:
|
||||
return self._error_result(
|
||||
"Managed Modal exec start did not return an exec handle"
|
||||
)
|
||||
|
||||
# Poll loop
|
||||
deadline = None
|
||||
if self._client_timeout_grace_seconds is not None:
|
||||
deadline = time.monotonic() + effective_timeout + self._client_timeout_grace_seconds
|
||||
|
||||
while True:
|
||||
if is_interrupted():
|
||||
try:
|
||||
self._cancel_exec(handle.exec_id)
|
||||
except Exception:
|
||||
pass
|
||||
return self._result(
|
||||
"[Command interrupted - Modal sandbox exec cancelled]", 130,
|
||||
)
|
||||
|
||||
try:
|
||||
result = self._poll_exec(handle)
|
||||
except Exception as exc:
|
||||
return self._error_result(f"Managed Modal exec poll failed: {exc}")
|
||||
|
||||
if result is not None:
|
||||
self._update_cwd_from_gateway_output(result)
|
||||
return result
|
||||
|
||||
if deadline is not None and time.monotonic() >= deadline:
|
||||
try:
|
||||
self._cancel_exec(handle.exec_id)
|
||||
except Exception:
|
||||
pass
|
||||
return self._result(
|
||||
f"Managed Modal exec timed out after {effective_timeout}s", 124,
|
||||
)
|
||||
|
||||
time.sleep(0.25)
|
||||
|
||||
def _update_cwd_from_gateway_output(self, result: dict) -> None:
|
||||
"""Best-effort CWD update from the cwdfile written by _wrap_command.
|
||||
|
||||
Since we can't read files from the gateway sandbox directly, we
|
||||
only update if there's a subsequent call that reads it. The
|
||||
_wrap_command template writes CWD to the cwdfile which will be
|
||||
read on the next execute() call if we had file access. For now,
|
||||
CWD tracking in managed modal relies on the cwd parameter.
|
||||
"""
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Gateway transport
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _start_exec(self, command: str, cwd: str, timeout: int,
|
||||
stdin_data: str | None) -> dict:
|
||||
exec_id = str(uuid.uuid4())
|
||||
payload: Dict[str, Any] = {
|
||||
"execId": exec_id,
|
||||
"command": prepared.command,
|
||||
"cwd": prepared.cwd,
|
||||
"timeoutMs": int(prepared.timeout * 1000),
|
||||
"command": command,
|
||||
"cwd": cwd,
|
||||
"timeoutMs": int(timeout * 1000),
|
||||
}
|
||||
if prepared.stdin_data is not None:
|
||||
payload["stdinData"] = prepared.stdin_data
|
||||
if stdin_data is not None:
|
||||
payload["stdinData"] = stdin_data
|
||||
|
||||
try:
|
||||
response = self._request(
|
||||
@@ -88,37 +203,38 @@ class ManagedModalEnvironment(BaseModalExecutionEnvironment):
|
||||
timeout=10,
|
||||
)
|
||||
except Exception as exc:
|
||||
return ModalExecStart(
|
||||
immediate_result=self._error_result(f"Managed Modal exec failed: {exc}")
|
||||
)
|
||||
return {
|
||||
**self._error_result(f"Managed Modal exec failed: {exc}"),
|
||||
"_immediate": True,
|
||||
}
|
||||
|
||||
if response.status_code >= 400:
|
||||
return ModalExecStart(
|
||||
immediate_result=self._error_result(
|
||||
return {
|
||||
**self._error_result(
|
||||
self._format_error("Managed Modal exec failed", response)
|
||||
)
|
||||
)
|
||||
),
|
||||
"_immediate": True,
|
||||
}
|
||||
|
||||
body = response.json()
|
||||
status = body.get("status")
|
||||
if status in {"completed", "failed", "cancelled", "timeout"}:
|
||||
return ModalExecStart(
|
||||
immediate_result=self._result(
|
||||
body.get("output", ""),
|
||||
body.get("returncode", 1),
|
||||
)
|
||||
)
|
||||
return {
|
||||
**self._result(body.get("output", ""), body.get("returncode", 1)),
|
||||
"_immediate": True,
|
||||
}
|
||||
|
||||
if body.get("execId") != exec_id:
|
||||
return ModalExecStart(
|
||||
immediate_result=self._error_result(
|
||||
return {
|
||||
**self._error_result(
|
||||
"Managed Modal exec start did not return the expected exec id"
|
||||
)
|
||||
)
|
||||
),
|
||||
"_immediate": True,
|
||||
}
|
||||
|
||||
return ModalExecStart(handle=_ManagedModalExecHandle(exec_id=exec_id))
|
||||
return {"_handle": _ManagedModalExecHandle(exec_id=exec_id)}
|
||||
|
||||
def _poll_modal_exec(self, handle: _ManagedModalExecHandle) -> dict | None:
|
||||
def _poll_exec(self, handle: _ManagedModalExecHandle) -> dict | None:
|
||||
try:
|
||||
status_response = self._request(
|
||||
"GET",
|
||||
@@ -145,11 +261,9 @@ class ManagedModalEnvironment(BaseModalExecutionEnvironment):
|
||||
)
|
||||
return None
|
||||
|
||||
def _cancel_modal_exec(self, handle: _ManagedModalExecHandle) -> None:
|
||||
self._cancel_exec(handle.exec_id)
|
||||
|
||||
def _timeout_result_for_modal(self, timeout: int) -> dict:
|
||||
return self._result(f"Managed Modal exec timed out after {timeout}s", 124)
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def cleanup(self):
|
||||
if not getattr(self, "_sandbox_id", None):
|
||||
@@ -169,6 +283,10 @@ class ManagedModalEnvironment(BaseModalExecutionEnvironment):
|
||||
finally:
|
||||
self._sandbox_id = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Sandbox creation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _create_sandbox(self) -> str:
|
||||
cpu = self._coerce_number(self._sandbox_kwargs.get("cpu"), 1)
|
||||
memory = self._coerce_number(
|
||||
@@ -211,6 +329,10 @@ class ManagedModalEnvironment(BaseModalExecutionEnvironment):
|
||||
raise RuntimeError("Managed Modal create did not return a sandbox id")
|
||||
return sandbox_id
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _guard_unsupported_credential_passthrough(self) -> None:
|
||||
"""Managed Modal does not sync or mount host credential files."""
|
||||
try:
|
||||
@@ -226,6 +348,12 @@ class ManagedModalEnvironment(BaseModalExecutionEnvironment):
|
||||
"credential files inside the sandbox."
|
||||
)
|
||||
|
||||
def _result(self, output: str, returncode: int) -> dict:
|
||||
return {"output": output, "returncode": returncode}
|
||||
|
||||
def _error_result(self, output: str) -> dict:
|
||||
return self._result(output, 1)
|
||||
|
||||
def _request(self, method: str, path: str, *,
|
||||
json: Dict[str, Any] | None = None,
|
||||
timeout: int = 30,
|
||||
|
||||
Reference in New Issue
Block a user