refactor(tools): unify remote result assembly, compact execute_code docs and comments

This commit is contained in:
Teknium
2026-09-02 22:33:03 -07:00
parent 89881ad022
commit 02f92ec707
5 changed files with 241 additions and 559 deletions

View File

@@ -69,12 +69,9 @@ def _scrub_child_env(source_env, is_passthrough=None, is_windows=None):
is_passthrough = is_env_passthrough
if is_windows is None:
is_windows = _IS_WINDOWS
scrubbed = {}
# Non-secret HERMES_* vars that no allowlist admits are dropped on purpose,
# but a script importing a repo module that reads one at import time would
# otherwise see it silently unset — log the drop once, pointing at the
# env_passthrough opt-in.
# Non-secret HERMES_* vars no allowlist admits are dropped on purpose; a script importing a
# repo module that reads one would see it silently unset — log the drop, point at the opt-in.
_dropped_hermes = []
for k, v in source_env.items():
if is_passthrough(k):
@@ -98,14 +95,11 @@ def _scrub_child_env(source_env, is_passthrough=None, is_windows=None):
"env_passthrough in the skill/config so it passes by explicit opt-in.",
len(_dropped_hermes), ", ".join(sorted(_dropped_hermes)),
)
# delegate_task children are marked by a ContextVar, not os.environ, and the
# sandbox crosses a process boundary: bridge the marker and strip
# dispatcher-owned Kanban vars AFTER the scrub so an explicit passthrough
# cannot re-grant a delegated child the parent's board mutation capability.
# delegate_task children are marked by a ContextVar, not os.environ, and the sandbox crosses
# a process boundary: strip dispatcher-owned Kanban vars AFTER the scrub so an explicit
# passthrough cannot re-grant a delegated child the parent's board mutation capability.
try:
from agent.delegation_context import is_delegated_child_process_context, scrub_kanban_env
if is_delegated_child_process_context():
scrubbed = scrub_kanban_env(scrubbed)
except Exception:
@@ -121,10 +115,8 @@ def _build_child_env(*, rpc_endpoint: str, rpc_token: str, tmpdir: str,
child_env["HERMES_RPC_SOCKET"] = rpc_endpoint
child_env["HERMES_RPC_TOKEN"] = rpc_token
child_env["PYTHONDONTWRITEBYTECODE"] = "1"
# Force UTF-8 stdio and default file encoding: on Windows sys.stdout is
# bound to the console code page (cp1252) and print("→") raises
# UnicodeEncodeError; PYTHONUTF8 also makes open()'s default UTF-8.
# Harmless belt-and-suspenders under a C/POSIX locale (minimal containers).
# Force UTF-8 stdio and default file encoding: on Windows sys.stdout is bound to the console
# code page (cp1252) and print("→") raises; harmless under a C/POSIX locale (containers).
child_env["PYTHONIOENCODING"] = "utf-8"
child_env["PYTHONUTF8"] = "1"
# Only TZ reaches the child; HERMES_TIMEZONE is an internal setting.
@@ -132,15 +124,11 @@ def _build_child_env(*, rpc_endpoint: str, rpc_token: str, tmpdir: str,
if _tz_name:
child_env["TZ"] = _tz_name
child_env.pop("HERMES_TIMEZONE", None)
apply_subprocess_home_env(child_env)
# PYTHONPATH: the staging dir (hermes_tools.py lives there) must always be
# importable, even when project mode changes CWD. Hermes's own root is
# added ONLY when the child runs in Hermes's Python environment — exposing
# Hermes's site-packages to an external project interpreter can mix
# incompatible compiled extensions (3.12 NumPy under a 3.9 venv). Inherited
# Hermes-owned entries (PYTHONPATH passes the scrub) are stripped first so
# they never shadow the child's sys.path.
# PYTHONPATH: the staging dir (hermes_tools.py) must always be importable even when project
# mode changes CWD. Hermes's root is added ONLY when the child runs in Hermes's Python env —
# exposing Hermes's site-packages to an external interpreter can mix incompatible compiled
# extensions (3.12 NumPy under a 3.9 venv). Inherited Hermes-owned entries are stripped first.
from tools.environments.local import _strip_hermes_owned_pythonpath
_strip_hermes_owned_pythonpath(child_env)
_existing_pp = child_env.get("PYTHONPATH", "")
@@ -148,8 +136,7 @@ def _build_child_env(*, rpc_endpoint: str, rpc_token: str, tmpdir: str,
if _uses_hermes_python_environment(child_python):
_pp_parts.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
elif child_python not in _external_env_logged:
# Import behavior changes silently otherwise — surface it once per
# interpreter path so "import hermes_constants fails" is diagnosable.
# Surface once per interpreter so "import hermes_constants fails" is diagnosable.
_external_env_logged.add(child_python)
logger.info("execute_code: child interpreter %s is outside the Hermes "
"environment; hermes root omitted from PYTHONPATH", child_python)
@@ -159,9 +146,8 @@ def _build_child_env(*, rpc_endpoint: str, rpc_token: str, tmpdir: str,
return child_env
# Interpreter-probe caches: success-only dicts (FIFO-evicted at the cap) rather
# than lru_cache — a transient probe failure (fork pressure, 5s timeout on a
# loaded host) must not stick for the process lifetime.
# Interpreter-probe caches: success-only dicts (FIFO-evicted at the cap) rather than lru_cache —
# a transient probe failure (fork pressure, 5s timeout) must not stick for the process lifetime.
_PROBE_CACHE_MAX = 32
_usable_python_cache: dict = {}
_python_prefix_cache: dict = {}
@@ -181,7 +167,6 @@ def _probe_python(python_path: str, code: str, *, text: bool = False):
"""Run ``python_path -c code``; None if missing, unspawnable, or past the 5s timeout."""
try:
from agent.delegation_context import delegated_child_subprocess_env
return subprocess.run(
[python_path, "-c", code], timeout=5, capture_output=True, text=text,
creationflags=subprocess.CREATE_NO_WINDOW if _IS_WINDOWS else 0,
@@ -218,13 +203,9 @@ def _python_environment_prefix(python_path: str) -> str:
def _uses_hermes_python_environment(python_path: str) -> bool:
"""Whether *python_path* belongs to Hermes's active Python environment.
Short-circuits when it IS the running interpreter (by path or realpath) so
no probe runs on the default strict path and a flaky probe of
sys.executable can never drop the hermes root; the realpath leg also covers
venvs whose bin/python resolves to the same binary (``uv run``).
"""
"""Whether *python_path* belongs to Hermes's active Python environment. Short-circuits when
it IS the running interpreter (by path or realpath — covers ``uv run`` venvs) so no probe
runs on the default strict path and a flaky probe can never drop the hermes root."""
if python_path == sys.executable or os.path.realpath(python_path) == os.path.realpath(sys.executable):
return True
return _python_environment_prefix(python_path) == os.path.realpath(sys.prefix)
@@ -236,7 +217,6 @@ def _resolve_child_python(mode: str) -> str:
3.8+ probe, else ``sys.executable``."""
if mode != "project":
return sys.executable
subdir, exe_names = ("Scripts", ("python.exe", "python3.exe")) if _IS_WINDOWS else ("bin", ("python", "python3"))
for var in ("VIRTUAL_ENV", "CONDA_PREFIX"):
root = os.environ.get(var, "").strip()
@@ -251,24 +231,18 @@ def _resolve_child_python(mode: str) -> str:
logger.info("execute_code: skipping %s=%s (Python version < 3.8 or broken). "
"Using sys.executable instead.", var, candidate)
return sys.executable
return sys.executable
def _resolve_child_cwd(mode: str, staging_dir: str, task_id: str = "") -> str:
"""Working directory for the child.
Strict mode: the staging dir. Project mode mirrors the terminal/file-tool
ladder so every file-writing path in a session agrees: the session's cwd
record (its `cd` state) → registered ``session.cwd.set`` override →
TERMINAL_CWD → os.getcwd() → staging dir (never Popen on a missing cwd).
"""
"""Child cwd. Strict: the staging dir. Project mirrors the terminal/file-tool ladder so every
file-writing path agrees: session cwd record (`cd` state) → registered ``session.cwd.set``
override → TERMINAL_CWD → os.getcwd() → staging dir (never Popen on a missing cwd)."""
if mode != "project":
return staging_dir
if task_id:
try:
from tools.terminal_tool import get_session_cwd
recorded = get_session_cwd(task_id)
except Exception:
recorded = None
@@ -276,14 +250,12 @@ def _resolve_child_cwd(mode: str, staging_dir: str, task_id: str = "") -> str:
return recorded
try:
from tools.file_tools import _registered_task_cwd_override
session_cwd = _registered_task_cwd_override(task_id)
except Exception:
session_cwd = None
if session_cwd and os.path.isdir(session_cwd):
return session_cwd
from agent.runtime_cwd import scope_terminal_cwd
raw = scope_terminal_cwd().strip()
if raw:
expanded = os.path.expanduser(raw)

View File

@@ -27,15 +27,12 @@ _TERMINAL_BLOCKED_PARAMS = {"background", "pty", "notify", "notify_on_complete",
def _default_dispatch(task_id):
from model_tools import handle_function_call
return lambda tool_name, tool_args: handle_function_call(tool_name, tool_args, task_id=task_id)
def _rpc_token_ok(request: dict, rpc_token: str) -> bool:
"""Constant-time token check; an empty server token fails closed.
Compared as bytes: compare_digest raises TypeError on a non-ASCII str, and
the token comes from script-supplied JSON."""
"""Constant-time token check; an empty server token fails closed. Compared as bytes:
compare_digest raises TypeError on a non-ASCII str, and the token is script-supplied JSON."""
return bool(rpc_token) and secrets.compare_digest(
str(request.get("token") or "").encode(), rpc_token.encode()
)
@@ -44,24 +41,19 @@ def _rpc_token_ok(request: dict, rpc_token: str) -> bool:
def _handle_rpc_request(request: dict, *, allowed_tools: frozenset, tool_call_counter: list,
max_tool_calls: int, dispatch, tool_call_log: list, call_start: float,
where: str) -> str:
"""Enforce allow-list + budget, then dispatch one authenticated request.
Only a dispatched call consumes budget and is logged; refusals are free.
"""
"""Enforce allow-list + budget, then dispatch one authenticated request. Only a dispatched
call consumes budget and is logged; refusals are free."""
tool_name = request.get("tool", "")
tool_args = request.get("args", {})
if tool_name not in allowed_tools:
return tool_error(f"Tool '{tool_name}' is not available in execute_code. "
f"Available: {', '.join(sorted(allowed_tools))}")
if tool_call_counter[0] >= max_tool_calls:
return tool_error(f"Tool call limit reached ({max_tool_calls}). "
"No more tool calls allowed in this execution.")
if tool_name == "terminal" and isinstance(tool_args, dict):
for param in _TERMINAL_BLOCKED_PARAMS:
tool_args.pop(param, None)
# Silence handler status prints so they don't leak into the CLI spinner.
try:
with thread_scoped_silence():
@@ -69,7 +61,6 @@ def _handle_rpc_request(request: dict, *, allowed_tools: frozenset, tool_call_co
except Exception as exc:
logger.error("Tool call failed in %s: %s", where, exc, exc_info=True)
result = tool_error(str(exc))
tool_call_counter[0] += 1
tool_call_log.append({"tool": tool_name, "args_preview": str(tool_args)[:80],
"duration": round(time.monotonic() - call_start, 2)})
@@ -79,18 +70,13 @@ def _handle_rpc_request(request: dict, *, allowed_tools: frozenset, tool_call_co
def _rpc_server_loop(server_sock: socket.socket, task_id: str, tool_call_log: list,
tool_call_counter: list, max_tool_calls: int, allowed_tools: frozenset,
stop_event: threading.Event, rpc_token: str, dispatch=None):
"""Accept one client and serve newline-delimited JSON requests until it
disconnects, idles 300s, or the call limit is reached.
``tool_call_counter`` is a mutable ``[int]`` so the thread can increment it.
``dispatch`` overrides how an allowed, budgeted call runs: per-call
sandboxes use the default (the thread already carries the cell's context),
while session kernels pass a dispatcher that rebinds each call to the
CURRENT cell's authority — the serving thread outlives many cells there.
"""Accept one client and serve newline-delimited JSON requests until it disconnects, idles
300s, or the call limit is reached. ``tool_call_counter`` is a mutable ``[int]``. ``dispatch``
overrides how an allowed, budgeted call runs: per-call sandboxes use the default (the thread
carries the cell's context); session kernels rebind each call to the CURRENT cell's authority.
"""
if dispatch is None:
dispatch = _default_dispatch(task_id)
conn = None
try:
server_sock.settimeout(0.05)
@@ -103,7 +89,6 @@ def _rpc_server_loop(server_sock: socket.socket, task_id: str, tool_call_log: li
if conn is None:
return
conn.settimeout(300)
buf = b""
while True:
try:
@@ -113,13 +98,11 @@ def _rpc_server_loop(server_sock: socket.socket, task_id: str, tool_call_log: li
if not chunk:
break
buf += chunk
while b"\n" in buf:
line, buf = buf.split(b"\n", 1)
line = line.strip()
if not line:
continue
call_start = time.monotonic()
try:
request = json.loads(line.decode())
@@ -135,7 +118,6 @@ def _rpc_server_loop(server_sock: socket.socket, task_id: str, tool_call_log: li
call_start=call_start, where="sandbox",
)
conn.sendall((resp + "\n").encode())
except socket.timeout:
logger.debug("RPC listener socket timeout")
except OSError as e:
@@ -151,15 +133,11 @@ def _rpc_server_loop(server_sock: socket.socket, task_id: str, tool_call_log: li
def _rpc_poll_loop(env, rpc_dir: str, task_id: str, tool_call_log: list, tool_call_counter: list,
max_tool_calls: int, allowed_tools: frozenset, stop_event: threading.Event,
rpc_token: str):
"""Poll the remote filesystem for request files and answer them.
Runs in a background thread; each ``env.execute()`` is an independent
process, so this is safe alongside the script-execution thread. Malformed
or unauthorized requests are removed without a response.
"""
"""Poll the remote filesystem for request files and answer them. Background thread; each
``env.execute()`` is an independent process, so this is safe alongside the script-execution
thread. Malformed or unauthorized requests are removed without a response."""
dispatch = _default_dispatch(task_id)
poll_interval = 0.1
quoted_rpc_dir = shlex.quote(rpc_dir)
while not stop_event.is_set():
try:
@@ -168,16 +146,13 @@ def _rpc_poll_loop(env, rpc_dir: str, task_id: str, tool_call_log: list, tool_ca
if not output:
stop_event.wait(poll_interval)
continue
req_files = sorted(
f for f in (line.strip() for line in output.split("\n"))
if f and not f.endswith(".tmp") and "/req_" in f
)
for req_file in req_files:
if stop_event.is_set():
break
call_start = time.monotonic()
quoted_req_file = shlex.quote(req_file)
read_result = env.execute(f"cat {quoted_req_file}", cwd="/", timeout=10)
@@ -191,13 +166,11 @@ def _rpc_poll_loop(env, rpc_dir: str, task_id: str, tool_call_log: list, tool_ca
logger.debug("Unauthorized RPC request in %s", req_file)
env.execute(f"rm -f {quoted_req_file}", cwd="/", timeout=5)
continue
tool_result = _handle_rpc_request(
request, allowed_tools=allowed_tools, tool_call_counter=tool_call_counter,
max_tool_calls=max_tool_calls, dispatch=dispatch, tool_call_log=tool_call_log,
call_start=call_start, where="remote sandbox",
)
# Write the response atomically (tmp + rename) via echo piping —
# Modal doesn't reliably deliver stdin_data to chained commands.
quoted_res_file = shlex.quote(f"{rpc_dir}/res_{request.get('seq', 0):06d}")
@@ -208,10 +181,8 @@ def _rpc_poll_loop(env, rpc_dir: str, task_id: str, tool_call_log: list, tool_ca
cwd="/", timeout=60,
)
env.execute(f"rm -f {quoted_req_file}", cwd="/", timeout=5)
except Exception as e:
if not stop_event.is_set():
logger.debug("RPC poll error: %s", e, exc_info=True)
if not stop_event.is_set():
stop_event.wait(poll_interval)

View File

@@ -1,19 +1,14 @@
#!/usr/bin/env python3
"""
Code Execution Tool -- Programmatic Tool Calling (PTC)
"""Code Execution Tool -- Programmatic Tool Calling (PTC).
Lets the LLM write a Python script that calls Hermes tools via RPC, collapsing
multi-step tool chains into a single inference turn. Only the script's stdout
is returned to the LLM; intermediate tool results never enter the context.
Two transports: the local backend runs a persistent per-conversation session
kernel (tools/code_kernel.py) talking to the parent's RPC thread over a Unix
domain socket (loopback TCP on Windows); remote backends (Docker/SSH/Modal/...)
run a remote session kernel (tools/code_kernel_remote.py), falling open to a
per-call script ship, with tool calls as request files that a polling thread on
the parent reads via env.execute(). Sibling modules: tools/code_execution_env.py
(env scrubbing, interpreter/cwd resolution) and tools/code_execution_rpc.py (RPC
servers). Remote execution requires Python 3 in the terminal backend.
The LLM writes a Python script that calls Hermes tools via RPC, collapsing
multi-step tool chains into one inference turn; only the script's stdout returns
to the LLM. Local backend: a persistent per-conversation session kernel
(tools/code_kernel.py) over a Unix socket (loopback TCP on Windows). Remote
backends: a remote session kernel (tools/code_kernel_remote.py) falling open to a
per-call script ship, tool calls as request files polled via env.execute()
(needs Python 3 on the backend). Siblings: tools/code_execution_env.py (env
scrubbing, interpreter/cwd), tools/code_execution_rpc.py (RPC servers).
"""
import base64
@@ -36,38 +31,19 @@ from tools.registry import registry, tool_error
# Env/interpreter resolution and RPC servers live in sibling modules; re-exported
# here so `from tools.code_execution_tool import X` / patch() targets keep working.
from tools.code_execution_env import ( # noqa: F401
_SAFE_ENV_PREFIXES,
_SECRET_SUBSTRINGS,
_HERMES_CHILD_ALLOWED,
_WINDOWS_ESSENTIAL_ENV_VARS,
_scrub_child_env,
_build_child_env,
_PROBE_CACHE_MAX,
_usable_python_cache,
_python_prefix_cache,
_external_env_logged,
_cache_probe_result,
_is_usable_python,
_probe_python,
_python_environment_prefix,
_uses_hermes_python_environment,
_resolve_child_python,
_resolve_child_cwd,
)
from tools.code_execution_rpc import ( # noqa: F401
_TERMINAL_BLOCKED_PARAMS,
_rpc_server_loop,
_rpc_poll_loop,
_SAFE_ENV_PREFIXES, _SECRET_SUBSTRINGS, _HERMES_CHILD_ALLOWED, _WINDOWS_ESSENTIAL_ENV_VARS,
_scrub_child_env, _build_child_env, _PROBE_CACHE_MAX, _usable_python_cache, _python_prefix_cache,
_external_env_logged, _cache_probe_result, _is_usable_python, _probe_python,
_python_environment_prefix, _uses_hermes_python_environment, _resolve_child_python, _resolve_child_cwd,
)
from tools.code_execution_rpc import _TERMINAL_BLOCKED_PARAMS, _rpc_server_loop, _rpc_poll_loop # noqa: F401
logger = logging.getLogger(__name__)
# Loopback TCP replaces AF_UNIX on Windows, so execute_code is available on
# every platform Hermes itself runs on.
# Loopback TCP replaces AF_UNIX on Windows, so execute_code runs on every platform Hermes does.
SANDBOX_AVAILABLE = True
# Tools allowed inside the sandbox; the intersection with the session's
# enabled tools determines which stubs are generated.
# Tools allowed inside the sandbox; ∩ the session's enabled tools decides which stubs are generated.
SANDBOX_ALLOWED_TOOLS = frozenset([
"web_search", "web_extract", "read_file", "write_file", "search_files", "patch", "terminal",
])
@@ -85,11 +61,9 @@ MAX_SPILLED_STDOUT_BYTES = 5_000_000
def _truncate_stdout_text(stdout_text: str) -> Tuple[str, Dict[str, Any]]:
"""Cap stdout by bytes (40% head / 60% tail) with explicit truncation metadata.
The agent receives execute_code results as JSON; a textual marker can be
missed or re-truncated by a client layer, so byte counts ride alongside it.
The omitted middle is not discarded: the complete output is spilled to
cache/exec and the result carries the path — the same recover-don't-rerun
pattern as web_extract's cache/web full-text store.
Byte counts ride alongside the textual marker because a client layer can miss
or re-truncate the marker. The omitted middle is spilled to cache/exec and the
result carries the path (recover-don't-rerun, as web_extract's cache/web).
"""
stdout_bytes = stdout_text.encode("utf-8", errors="replace")
total = len(stdout_bytes)
@@ -98,7 +72,6 @@ def _truncate_stdout_text(stdout_text: str) -> Tuple[str, Dict[str, Any]]:
"stdout_truncated": False, "stdout_bytes_captured": total,
"stdout_bytes_total": total, "stdout_bytes_omitted": 0,
}
head_bytes = int(MAX_STDOUT_BYTES * 0.4)
omitted = total - MAX_STDOUT_BYTES
text = (
@@ -129,18 +102,13 @@ def _truncate_stdout_text(stdout_text: str) -> Tuple[str, Dict[str, Any]]:
def _spill_full_stdout(stdout_text: str) -> Optional[str]:
"""Write full stdout to cache/exec; return its path (None on failure).
Best-effort by design — truncated inline output is still returned when
storage fails. Files are keyed by content digest so identical reruns
coalesce; the directory rides the same remote bind-mount list as
cache/web (credential_files._CACHE_DIRS) if present there.
"""
"""Write full stdout to cache/exec; return its path (None on failure — best-effort,
the truncated inline output is still returned). Keyed by content digest so identical
reruns coalesce; the dir rides the cache/web remote bind-mount list (credential_files)."""
try:
import hashlib
from hermes_constants import get_hermes_dir
from tools.spill_safety import write_text_exclusive
if len(stdout_text) > MAX_SPILLED_STDOUT_BYTES:
stdout_text = (stdout_text[:MAX_SPILLED_STDOUT_BYTES]
+ f"\n\n[... spill capped at {MAX_SPILLED_STDOUT_BYTES:,} bytes ...]")
@@ -161,7 +129,6 @@ def check_sandbox_requirements() -> bool:
return False
try:
from tools.terminal_tool import _check_vercel_sandbox_requirements, _get_env_config
config = _get_env_config()
except Exception:
logger.debug("Could not resolve terminal config for execute_code availability", exc_info=True)
@@ -175,8 +142,7 @@ def check_sandbox_requirements() -> bool:
# hermes_tools.py code generator
# ---------------------------------------------------------------------------
# Per-tool stub templates: (signature, docstring, args_dict_expr); the
# args_dict_expr builds the JSON payload sent over the RPC channel.
# Per-tool stub templates: (signature, docstring, args_dict_expr — the JSON payload sent over RPC).
_TOOL_STUBS = {
"web_search": (
"query: str, limit: int = 5",
@@ -227,10 +193,9 @@ def _missing_hermes_tools_import_hint(m, enabled_tools) -> str:
"else, use the normal tool call instead of execute_code.")
# (regex, formatter(match, enabled_tools)) — first match wins. Production
# mining (state.db) ranked these as the top execute_code failure classes:
# hermes_tools import misuse, importing the built-in helpers, treating tool
# results as strings, importing third-party packages absent from the sandbox.
# (regex, formatter(match, enabled_tools)) — first match wins. Production mining (state.db) ranked
# these as the top execute_code failure classes: hermes_tools import misuse, importing the built-in
# helpers, treating tool results as strings, importing third-party packages absent from the sandbox.
_FAILURE_HINT_RULES = (
(r"cannot import name '(\w+)' from 'hermes_tools'", _missing_hermes_tools_import_hint),
(r"NameError: name '(json_parse|shell_quote|retry)' is not defined",
@@ -242,8 +207,7 @@ _FAILURE_HINT_RULES = (
"terminal() with the project venv's python instead."),
(r"TypeError: string indices must be integers|AttributeError: 'str' object has no attribute 'get'",
lambda m, _: "Tool functions in the sandbox return DICTS (already parsed) — "
"do not json.loads() them or index them like strings. "
"Example: read_file(path)['content']."),
"do not json.loads() them or index them like strings. Example: read_file(path)['content']."),
)
@@ -265,19 +229,13 @@ def _sandbox_failure_hint(stderr_text: str, enabled_tools=None) -> Optional[str]
def generate_hermes_tools_module(enabled_tools: List[str],
transport: str = "uds") -> str:
"""Source of the hermes_tools.py stub module for tools in both
SANDBOX_ALLOWED_TOOLS and *enabled_tools*. ``transport``: ``"uds"`` (local
socket client) or ``"file"`` (file-based RPC client for remote backends)."""
"""Source of the hermes_tools.py stub module for SANDBOX_ALLOWED_TOOLS ∩ *enabled_tools*.
``transport``: ``"uds"`` (local socket client) or ``"file"`` (file RPC, remote backends)."""
header = _FILE_TRANSPORT_HEADER if transport == "file" else _UDS_TRANSPORT_HEADER
stubs = []
for name in sorted(SANDBOX_ALLOWED_TOOLS & set(enabled_tools)):
sig, doc, args_expr = _TOOL_STUBS[name]
stubs.append(
f"def {name}({sig}):\n"
f" {doc}\n"
f" return _call({name!r}, {args_expr})\n"
)
return header + "\n".join(stubs)
return header + "\n".join(
f"def {name}({sig}):\n {doc}\n return _call({name!r}, {args_expr})\n"
for name, (sig, doc, args_expr) in sorted(_TOOL_STUBS.items()) if name in set(enabled_tools)
)
# ---- Shared helpers section (embedded in both transport headers) ----------
@@ -480,41 +438,33 @@ def _call(tool_name, args):
# ---------------------------------------------------------------------------
def _get_or_create_env(task_id: str):
"""Return ``(env, env_type)`` — the same environment the terminal and file
tools use for *task_id*, created on first use (same double-checked
per-task lock pattern as file_tools._get_file_ops)."""
"""``(env, env_type)`` — the environment the terminal/file tools share for *task_id*, created on
first use (same double-checked per-task lock pattern as file_tools._get_file_ops)."""
from tools.terminal_tool import (
_active_environments, _env_lock, _create_environment, _get_env_config, _last_activity,
_start_cleanup_thread, _creation_locks, _creation_locks_lock, _task_env_overrides,
_resolve_container_task_id, _resolve_task_host_cwd, _is_container_backend, _select_image,
_ssh_config_from_config,
)
effective_task_id = _resolve_container_task_id(task_id)
def _cached():
with _env_lock:
env = _active_environments.get(effective_task_id)
if env is not None:
_last_activity[effective_task_id] = time.time()
return env
env = _cached()
if env is not None:
return env, _get_env_config()["env_type"]
with _creation_locks_lock:
task_lock = _creation_locks.setdefault(effective_task_id, threading.Lock())
with task_lock:
env = _cached()
if env is not None:
return env, _get_env_config()["env_type"]
config = _get_env_config()
env_type = config["env_type"]
overrides = _task_env_overrides.get(effective_task_id, {})
container_config = None
if _is_container_backend(env_type):
container_config = {
@@ -527,25 +477,19 @@ def _get_or_create_env(task_id: str):
"docker_run_as_host_user": config.get("docker_run_as_host_user", False),
"docker_network": config.get("docker_network", True),
}
logger.info("Creating new %s environment for execute_code task %s...",
env_type, effective_task_id[:8])
env = _create_environment(
env_type=env_type,
image=_select_image(env_type, overrides, config),
cwd=overrides.get("cwd") or config["cwd"],
timeout=config["timeout"],
env_type=env_type, image=_select_image(env_type, overrides, config),
cwd=overrides.get("cwd") or config["cwd"], timeout=config["timeout"],
ssh_config=_ssh_config_from_config(config) if env_type == "ssh" else None,
container_config=container_config,
local_config={"persistent": config.get("local_persistent", False)} if env_type == "local" else None,
task_id=effective_task_id,
host_cwd=_resolve_task_host_cwd(config, task_id),
task_id=effective_task_id, host_cwd=_resolve_task_host_cwd(config, task_id),
)
with _env_lock:
_active_environments[effective_task_id] = env
_last_activity[effective_task_id] = time.time()
_start_cleanup_thread()
logger.info("%s environment ready for execute_code task %s",
env_type, effective_task_id[:8])
@@ -553,9 +497,8 @@ def _get_or_create_env(task_id: str):
def _ship_file_to_remote(env, remote_path: str, content: str) -> None:
"""Write *content* to *remote_path* via ``echo … | base64 -d`` — some
backends (Modal) don't reliably deliver stdin_data to chained commands, and
base64 is shell-safe inside single quotes."""
"""Write *content* to *remote_path* via ``echo … | base64 -d`` — some backends (Modal) don't
reliably deliver stdin_data to chained commands; base64 is shell-safe inside single quotes."""
encoded = base64.b64encode(content.encode("utf-8")).decode("ascii")
env.execute(f"echo '{encoded}' | base64 -d > {shlex.quote(remote_path)}", cwd="/", timeout=30)
@@ -578,38 +521,29 @@ def _env_temp_dir(env: Any) -> str:
def _format_interrupted_output(stdout_text: str) -> str:
"""Append an interruption marker without guessing who caused it."""
from tools.interrupt import get_interrupt_reason
reason = get_interrupt_reason()
marker = f"[execution interrupted — {reason}]" if reason else "[execution interrupted]"
return f"{stdout_text}\n{marker}" if stdout_text else marker
def _clean_output(stdout_text: str) -> Tuple[str, Dict[str, Any]]:
"""Shared output pipeline: byte-cap (with spill), ANSI strip, secret redaction.
code_file=True: execution output often echoes source/config — skip the
ENV/JSON/f-string-template false positives while still masking real credentials.
"""
"""Shared output pipeline: byte-cap (with spill), ANSI strip, secret redaction. code_file=True:
output often echoes source/config — skip ENV/JSON/f-string false positives, still mask credentials."""
from tools.ansi_strip import strip_ansi
from agent.redact import redact_sensitive_text
stdout_text, metadata = _truncate_stdout_text(stdout_text)
return redact_sensitive_text(strip_ansi(stdout_text), code_file=True), metadata
def _with_timeout_notice(stdout_text: str, timeout_msg: str) -> str:
"""Put the timeout message in the output too — an empty result makes models
answer as if nothing happened, and the gateway drops empty replies."""
"""Timeout message goes in the output too — an empty result makes models answer as if
nothing happened, and the gateway drops empty replies."""
return stdout_text + f"\n\n⏰ {timeout_msg}" if stdout_text else f"⏰ {timeout_msg}"
def _error_result(error: str, *, tool_calls_made: int = 0, duration: float = 0) -> str:
return json.dumps({
"status": "error",
"error": error,
"tool_calls_made": tool_calls_made,
"duration_seconds": duration,
}, ensure_ascii=False)
return json.dumps({"status": "error", "error": error, "tool_calls_made": tool_calls_made,
"duration_seconds": duration}, ensure_ascii=False)
def _remote_failure(exc: BaseException, exec_start: float, tool_calls_made: int) -> str:
@@ -622,11 +556,28 @@ def _remote_failure(exc: BaseException, exec_start: float, tool_calls_made: int)
_REMOTE_EXIT_STATUS = {124: "timeout", 130: "interrupted"}
def _remote_result(status: str, raw_stdout: str, exec_start: float, fields: Dict[str, Any],
kernel: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Common remote reply shape: status, cleaned output, *fields*, duration, optional kernel
info, then truncation metadata (key order is part of the result contract)."""
stdout_text, stdout_metadata = _clean_output(raw_stdout)
result: Dict[str, Any] = {"status": status, "output": stdout_text, **fields,
"duration_seconds": round(time.monotonic() - exec_start, 2)}
if kernel is not None:
result["kernel"] = kernel
result.update(stdout_metadata)
return result
def _apply_timeout(result: Dict[str, Any], timeout_msg: str) -> None:
result["error"] = timeout_msg
result["output"] = _with_timeout_notice(result["output"], timeout_msg)
def _finish_remote_kernel_result(kernel_result: Dict[str, Any], *,
timeout: int, exec_start: float) -> str:
"""Post-process a remote-kernel cell result into the tool's JSON reply.
Timeout messaging mirrors the local kernel contract (kernel killed, state
lost, next call fresh)."""
"""Post-process a remote-kernel cell result into the tool's JSON reply. Timeout messaging
mirrors the local kernel contract (kernel killed, state lost, next call fresh)."""
stdout_text = kernel_result.get("stdout", "") or ""
stderr_text = kernel_result.get("stderr", "") or ""
traceback_text = kernel_result.get("traceback", "") or ""
@@ -634,42 +585,30 @@ def _finish_remote_kernel_result(kernel_result: Dict[str, Any], *,
# Same joining shape as the local kernel: stderr and traceback ride in
# the output under one marker so the model sees the failure inline.
stdout_text = stdout_text + "\n--- stderr ---\n" + stderr_text + traceback_text
stdout_text, stdout_metadata = _clean_output(stdout_text)
result: Dict[str, Any] = {
"status": kernel_result.get("status", "error"),
"output": stdout_text,
"tool_calls_made": kernel_result.get("tool_calls_made", 0),
"duration_seconds": round(time.monotonic() - exec_start, 2),
"kernel": kernel_result.get("kernel", {"remote": True}),
}
result.update(stdout_metadata)
result = _remote_result(kernel_result.get("status", "error"), stdout_text, exec_start,
{"tool_calls_made": kernel_result.get("tool_calls_made", 0)},
kernel=kernel_result.get("kernel", {"remote": True}))
if result["status"] == "timeout":
timeout_msg = (f"Cell timed out after {timeout}s; the remote session kernel was "
"killed and its state was lost. The next call starts fresh.")
result["error"] = timeout_msg
result["output"] = _with_timeout_notice(stdout_text, timeout_msg)
_apply_timeout(result, f"Cell timed out after {timeout}s; the remote session kernel was "
"killed and its state was lost. The next call starts fresh.")
elif result["status"] == "error" and kernel_result.get("error"):
result["error"] = kernel_result["error"]
return json.dumps(result, ensure_ascii=False)
def _sandbox_tools_for(enabled_tools: Optional[List[str]]) -> frozenset:
"""Tools the sandbox may call: the session's enabled set ∩ SANDBOX_ALLOWED_TOOLS,
or every sandbox tool when the intersection is empty."""
"""Enabled ∩ SANDBOX_ALLOWED_TOOLS, or every sandbox tool when the intersection is empty."""
return frozenset(SANDBOX_ALLOWED_TOOLS & set(enabled_tools or ())) or SANDBOX_ALLOWED_TOOLS
def _run_remote_per_call(env, env_type: str, code: str, effective_task_id: str,
sandbox_tools: frozenset, *, timeout: int, max_tool_calls: int,
exec_start: float) -> str:
"""Per-call script ship: stage hermes_tools.py + script.py in a fresh remote
sandbox dir, serve file-RPC from a polling thread, run, clean up."""
"""Per-call script ship: stage hermes_tools.py + script.py in a fresh remote sandbox dir,
serve file-RPC from a polling thread, run, clean up."""
sandbox_dir = f"{_env_temp_dir(env)}/hermes_exec_{uuid.uuid4().hex[:12]}"
quoted_sandbox_dir = shlex.quote(sandbox_dir)
quoted_rpc_dir = shlex.quote(f"{sandbox_dir}/rpc")
tool_call_log: list = []
tool_call_counter = [0]
stop_event = threading.Event()
@@ -680,7 +619,6 @@ def _run_remote_per_call(env, env_type: str, code: str, effective_task_id: str,
_ship_file_to_remote(env, f"{sandbox_dir}/hermes_tools.py",
generate_hermes_tools_module(list(sandbox_tools), transport="file"))
_ship_file_to_remote(env, f"{sandbox_dir}/script.py", code)
# Wrapped so the thread inherits the turn's approval context + callbacks
# (tools.thread_context) — else sandbox RPC tool calls lose approval routing.
rpc_thread = threading.Thread(
@@ -690,14 +628,12 @@ def _run_remote_per_call(env, env_type: str, code: str, effective_task_id: str,
daemon=True,
)
rpc_thread.start()
env_prefix = (f"HERMES_RPC_DIR={quoted_rpc_dir} "
f"HERMES_RPC_TOKEN={shlex.quote(rpc_token)} "
f"PYTHONDONTWRITEBYTECODE=1")
tz = os.getenv("HERMES_TIMEZONE", "").strip()
if tz:
env_prefix += f" TZ={shlex.quote(tz)}"
logger.info("Executing code on %s backend (task %s)...", env_type, effective_task_id[:8])
script_result = env.execute(f"cd {quoted_sandbox_dir} && {env_prefix} python3 script.py",
timeout=timeout)
@@ -715,26 +651,14 @@ def _run_remote_per_call(env, env_type: str, code: str, effective_task_id: str,
env.execute(f"rm -rf {quoted_sandbox_dir}", cwd="/", timeout=15)
except Exception:
logger.debug("Failed to clean up remote sandbox %s", sandbox_dir)
duration = round(time.monotonic() - exec_start, 2)
stdout_text, stdout_metadata = _clean_output(stdout_text)
result: Dict[str, Any] = {
"status": status,
"output": stdout_text,
"exit_code": exit_code,
"tool_calls_made": tool_call_counter[0],
"duration_seconds": duration,
}
result.update(stdout_metadata)
result = _remote_result(status, stdout_text, exec_start,
{"exit_code": exit_code, "tool_calls_made": tool_call_counter[0]})
if status == "timeout":
timeout_msg = f"Script timed out after {timeout}s and was killed."
result["error"] = timeout_msg
result["output"] = _with_timeout_notice(stdout_text, timeout_msg)
_apply_timeout(result, f"Script timed out after {timeout}s and was killed.")
logger.warning("execute_code (remote) timed out after %ss (limit %ss) with %d tool calls",
duration, timeout, tool_call_counter[0])
result["duration_seconds"], timeout, tool_call_counter[0])
elif status == "interrupted":
result["output"] = _format_interrupted_output(stdout_text)
result["output"] = _format_interrupted_output(result["output"])
elif exit_code != 0:
result["status"] = "error"
result["error"] = f"Script exited with code {exit_code}"
@@ -743,43 +667,30 @@ def _run_remote_per_call(env, env_type: str, code: str, effective_task_id: str,
def _execute_remote(code: str, task_id: Optional[str], enabled_tools: Optional[List[str]],
reset: bool = False) -> str:
"""Run code on the remote terminal backend.
Preferred path: the owner's persistent remote session kernel
(tools/code_kernel_remote.py — detached runner + file cell protocol).
Fallback: the per-call script ship — the fail-open route when a kernel
cannot be spawned, and the only route for hosts that cannot sustain a
background process.
"""
"""Run code on the remote terminal backend: the owner's persistent remote session kernel
(tools/code_kernel_remote.py) first, else the per-call script ship — the fail-open route when
a kernel cannot be spawned and the only route for hosts that cannot sustain a background process."""
_cfg = _load_config()
timeout = _cfg.get("timeout", DEFAULT_TIMEOUT)
max_tool_calls = _cfg.get("max_tool_calls", DEFAULT_MAX_TOOL_CALLS)
sandbox_tools = _sandbox_tools_for(enabled_tools)
effective_task_id = task_id or "default"
env, env_type = _get_or_create_env(effective_task_id)
exec_start = time.monotonic()
try:
py_check = env.execute("command -v python3 >/dev/null 2>&1 && echo OK", cwd="/", timeout=15)
if "OK" not in py_check.get("output", ""):
return json.dumps({
"status": "error",
"error": (
f"Python 3 is not available in the {env_type} terminal "
"environment. Install Python to use execute_code with "
"remote backends."
),
"tool_calls_made": 0,
"duration_seconds": 0,
"error": (f"Python 3 is not available in the {env_type} terminal "
"environment. Install Python to use execute_code with remote backends."),
"tool_calls_made": 0, "duration_seconds": 0,
})
# Session-kernel path: one persistent kernel per owner on the
# run-to-completion transport. Spawn failure falls OPEN to the per-call
# path below so a degraded remote host never blocks execution.
try:
from tools.code_kernel_remote import execute_in_remote_kernel
kernel_result = execute_in_remote_kernel(
code, env=env, env_type=env_type, task_env_id=effective_task_id,
sandbox_tools=frozenset(sandbox_tools), timeout=timeout,
@@ -789,13 +700,11 @@ def _execute_remote(code: str, task_id: Optional[str], enabled_tools: Optional[L
except Exception:
logger.warning("remote session-kernel path failed; falling back to per-call", exc_info=True)
kernel_result = None
if kernel_result is not None:
return _finish_remote_kernel_result(kernel_result, timeout=timeout, exec_start=exec_start)
logger.info("remote session kernel unavailable on %s; using per-call path", env_type)
except Exception as exc:
return _remote_failure(exc, exec_start, 0)
return _run_remote_per_call(env, env_type, code, effective_task_id, sandbox_tools,
timeout=timeout, max_tool_calls=max_tool_calls, exec_start=exec_start)
@@ -811,47 +720,37 @@ def execute_code(
enabled_tools: Optional[List[str]] = None,
reset: bool = False,
) -> str:
"""Run Python in the session's persistent kernel (local) or on the remote
terminal backend, with RPC access to a subset of Hermes tools; returns the
JSON result string.
"""Run Python in the session's persistent kernel (local) or on the remote terminal backend,
with RPC access to a subset of Hermes tools; returns the JSON result string.
"Sandbox" here means the security envelope (env scrubbing, tool whitelist +
call budget, output redaction) — not an isolation jail: in the default
`project` mode, code runs in the session's cwd with the project venv.
``enabled_tools`` is intersected with SANDBOX_ALLOWED_TOOLS; ``reset`` kills
the existing kernel first (ignored on per-call paths).
"Sandbox" means the security envelope (env scrubbing, tool whitelist + call budget, output
redaction), not an isolation jail: default `project` mode runs in the session's cwd with the
project venv. ``enabled_tools`` ∩ SANDBOX_ALLOWED_TOOLS; ``reset`` kills the existing kernel
first (ignored on per-call paths).
"""
if not SANDBOX_AVAILABLE:
return tool_error(
"execute_code sandbox is unavailable in this environment. "
"Use normal tool calls (terminal, read_file, write_file, ...) instead."
)
# Fail closed under a terminal-policy refusal scope: the routed profile's
# terminal policy is unresolved, so refuse rather than inherit the launch
# process's ambient policy.
# Fail closed under a terminal-policy refusal scope: the routed profile's terminal
# policy is unresolved, so refuse rather than inherit the launch process's ambient policy.
try:
from tools.terminal_scope import enforce_no_refusal
enforce_no_refusal()
except Exception as refusal:
return tool_error(
f"execute_code refused: {refusal} "
"(profile terminal policy unresolved; fix the profile's "
"config.yaml / .env and retry)"
"(profile terminal policy unresolved; fix the profile's config.yaml / .env and retry)"
)
if not code or not code.strip():
return tool_error(
"No code provided. execute_code requires a non-empty 'code' "
"parameter containing Python source. To run shell commands, use "
"terminal(command=...) instead."
"parameter containing Python source. To run shell commands, use terminal(command=...) instead."
)
# Hard-block gateway-lifecycle commands (mirrors the terminal_tool guard —
# otherwise `os.system("launchctl bootout ...")` here bypasses it and
# SIGTERMs the gateway mid-task). Gated on PID-file ownership, not the
# inherited env marker.
# Hard-block gateway-lifecycle commands (mirrors the terminal_tool guard — otherwise
# `os.system("launchctl bootout ...")` here bypasses it and SIGTERMs the gateway mid-task).
# Gated on PID-file ownership, not the inherited env marker.
from tools.process_registry import _is_supervised_gateway_process
if _is_supervised_gateway_process():
from cron.lifecycle_guard import contains_gateway_lifecycle_command
@@ -862,64 +761,47 @@ def execute_code(
"it could complete (SIGTERM propagates to child processes). "
"Run the lifecycle command from a shell outside the gateway."
)
from tools.terminal_tool import _get_env_config, _docker_has_host_access
_env_config = _get_env_config()
env_type = _env_config["env_type"]
# Arbitrary Python never passes through terminal()/DANGEROUS_PATTERNS, so
# guard the whole script before either dispatch path spawns it — in this
# (tool-executor) thread, which holds the session context. A Docker sandbox
# with host bind mounts is not isolated, so it gets no container fast-path.
# Arbitrary Python never passes through terminal()/DANGEROUS_PATTERNS, so guard the whole
# script before either dispatch path spawns it — in this (tool-executor) thread, which holds
# the session context. A Docker sandbox with host bind mounts gets no container fast-path.
from tools.approval import check_execute_code_guard
_guard = check_execute_code_guard(code, env_type, has_host_access=_docker_has_host_access(_env_config))
if not _guard.get("approved", False):
return _error_result(_guard.get("message") or "execute_code blocked by approval guard.")
# Clear a stale interrupt bit that landed on this thread during the blocking
# approval-wait so it can't kill the just-approved run on the first poll
# (either dispatch path). A genuine post-clear interrupt re-sets the bit.
# Clear a stale interrupt bit that landed during the blocking approval-wait so it can't
# kill the just-approved run on the first poll. A genuine post-clear interrupt re-sets it.
if _guard.get("user_approved"):
from tools.interrupt import clear_current_thread_interrupt
clear_current_thread_interrupt()
if env_type != "local":
return _execute_remote(code, task_id, enabled_tools, reset=bool(reset))
from tools.interrupt import is_interrupted as _is_interrupted
# Session kernels are always on locally: one interpreter per conversation;
# the guards above already ran for this cell, and the kernel path reuses the
# same env builder, RPC server, and output redaction as the remote path.
# Session kernels are always on locally (one interpreter per conversation); the guards above
# already ran for this cell, and the kernel path shares env builder, RPC server and redaction.
from tools.code_kernel import execute_in_session_kernel
_cfg = _load_config()
_mode = _get_execution_mode()
return execute_in_session_kernel(
code,
task_id=task_id or "",
mode=_mode,
child_python=_resolve_child_python(_mode),
code, task_id=task_id or "", mode=_mode, child_python=_resolve_child_python(_mode),
child_cwd=_resolve_child_cwd(_mode, "", task_id=task_id or ""),
sandbox_tools=frozenset(_sandbox_tools_for(enabled_tools)),
timeout=_cfg.get("timeout", DEFAULT_TIMEOUT),
max_tool_calls=_cfg.get("max_tool_calls", DEFAULT_MAX_TOOL_CALLS),
reset=bool(reset),
is_interrupted=_is_interrupted,
reset=bool(reset), is_interrupted=_is_interrupted,
)
def _kill_process_group(proc, escalate: bool = False):
"""Kill the child and its whole process tree (cross-platform) via
agent.deadline.kill_process_tree: SIGTERM the tree (killpg + psutil
descendant sweep for setsid'd grandchildren; ``taskkill /T /F`` on Windows);
with ``escalate=True`` wait 5s then SIGKILL survivors. Never raises — a
delegation failure degrades to a plain ``proc.kill()``."""
"""Kill the child and its whole process tree via agent.deadline.kill_process_tree: SIGTERM
(killpg + psutil descendant sweep; ``taskkill /T /F`` on Windows, where sig is ignored); with
``escalate=True`` wait 5s then SIGKILL survivors. Never raises — falls back to ``proc.kill()``."""
import signal as _signal
def _tree_signal(sig) -> None:
try:
from agent.deadline import kill_process_tree as _deadline_kill_tree
_deadline_kill_tree(proc.pid, sig=sig)
except Exception as e:
logger.debug("Could not terminate process tree: %s", e, exc_info=True)
@@ -927,8 +809,6 @@ def _kill_process_group(proc, escalate: bool = False):
proc.kill()
except Exception as e2:
logger.debug("Could not kill process: %s", e2, exc_info=True)
# sig is ignored on Windows (taskkill /F is already forceful).
_tree_signal(getattr(_signal, "SIGTERM", None))
if escalate:
try:
@@ -938,14 +818,10 @@ def _kill_process_group(proc, escalate: bool = False):
def _load_config() -> dict:
"""Load the ``code_execution`` config section via the lightweight raw reader.
Runs while the module-level schema is built at tool discovery, so it must
not import ``cli`` (prompt_toolkit/Rich on every startup path).
"""
"""``code_execution`` config section via the lightweight raw reader — runs while the
module-level schema is built at tool discovery, so it must not import ``cli``."""
try:
from hermes_cli.config import read_raw_config
cfg = read_raw_config().get("code_execution", {})
return cfg if isinstance(cfg, dict) else {}
except Exception:
@@ -956,18 +832,15 @@ def _load_config() -> dict:
# Execution mode resolution (strict vs project)
# ---------------------------------------------------------------------------
# Canonical code_execution.mode values (referenced by tests and the config layer).
# Session kernels are the only local execution model; a leftover
# code_execution.kernel_mode config key is silently ignored.
# Canonical code_execution.mode values (referenced by tests and the config layer). Session
# kernels are the only local execution model; a leftover kernel_mode config key is ignored.
EXECUTION_MODES = ("project", "strict")
DEFAULT_EXECUTION_MODE = "project"
def _get_execution_mode() -> str:
"""Active execute_code mode from ``code_execution.mode`` (invalid → default
with a warning). ``project``: session cwd + active venv python so project
deps/files resolve; ``strict``: isolated temp dir + ``sys.executable``.
Env scrubbing and the tool whitelist apply identically in both."""
"""``code_execution.mode`` (invalid → default with a warning). ``project``: session cwd + active
venv python; ``strict``: isolated temp dir + ``sys.executable``. Scrubbing/whitelist apply to both."""
cfg_value = str(_load_config().get("mode", DEFAULT_EXECUTION_MODE)).strip().lower()
if cfg_value in EXECUTION_MODES:
return cfg_value
@@ -982,8 +855,7 @@ def _get_execution_mode() -> str:
# OpenAI Function-Calling Schema
# ---------------------------------------------------------------------------
# Per-tool documentation lines for the execute_code description.
# Ordered to match the canonical display order.
# Per-tool documentation lines for the execute_code description, in canonical display order.
_TOOL_DOC_LINES = [
("web_search",
" web_search(query: str, limit: int = 5) -> dict\n"
@@ -996,8 +868,7 @@ _TOOL_DOC_LINES = [
" read_file(path: str, offset: int = 1, limit: int = 2000) -> dict\n"
" Lines are 1-indexed. Returns {\"content\": \"...\", \"total_lines\": N}"),
("write_file",
" write_file(path: str, content: str) -> dict\n"
" Always overwrites the entire file."),
" write_file(path: str, content: str) -> dict\n Always overwrites the entire file."),
("search_files",
" search_files(pattern: str, target=\"content\", path=\".\", file_glob=None, limit=50) -> dict\n"
" target: \"content\" (search inside files) or \"files\" (find files by name). Returns {\"matches\": [...]}"),
@@ -1012,22 +883,17 @@ _TOOL_DOC_LINES = [
def build_execute_code_schema(enabled_sandbox_tools: set = None,
mode: str = None) -> dict:
"""Build the execute_code schema listing only *enabled_sandbox_tools* — a
disabled tool (e.g. web off) must not appear or the model keeps trying it.
``mode`` (None → current config) selects the working-directory sentence.
"""
"""execute_code schema listing only *enabled_sandbox_tools* — a disabled tool (e.g. web off)
must not appear or the model keeps trying it. ``mode`` (None → config) picks the cwd sentence."""
if enabled_sandbox_tools is None:
enabled_sandbox_tools = SANDBOX_ALLOWED_TOOLS
if mode is None:
mode = _get_execution_mode()
tool_lines = "\n".join(doc for name, doc in _TOOL_DOC_LINES if name in enabled_sandbox_tools)
import_examples = [n for n in ("web_search", "terminal") if n in enabled_sandbox_tools]
if not import_examples:
import_examples = sorted(enabled_sandbox_tools)[:2]
import_str = ", ".join(import_examples) + ", ..." if import_examples else "..."
if mode == "strict":
cwd_note = (
"Scripts run in their own temp dir, not the session's CWD — use absolute paths "
@@ -1041,32 +907,26 @@ def build_execute_code_schema(enabled_sandbox_tools: set = None,
"Hermes's own python (the common case — stdlib plus Hermes's "
"deps; check `import x` before relying on project packages)."
)
# Remote hosts that fail open to per-call are not worth schema words; the
# result's `kernel` field tells the truth per call.
# Remote hosts that fail open to per-call are not worth schema words; the result's
# `kernel` field tells the truth per call.
description = (
"Run Python that calls Hermes tools programmatically. Use when you "
"need 3+ tool calls with logic between them: filtering/reducing "
"large outputs before they enter context, branching, or loops "
"(N pages/files, retry on failure). Use normal tool calls for "
"single calls, results you must reason over in full, or anything "
"needing user interaction.\n\n"
"single calls, results you must reason over in full, or anything needing user interaction.\n\n"
"Calls run in a persistent session kernel: variables, imports, and "
"loaded data survive across execute_code calls, so build on earlier "
"work instead of re-loading it. A timed-out or interrupted call "
"loses that state.\n\n"
"work instead of re-loading it. A timed-out or interrupted call loses that state.\n\n"
f"Available via `from hermes_tools import ...`:\n\n"
f"{tool_lines}\n\n"
"Limits: 5-minute timeout, max 50 tool calls per call. Stdout over "
"50KB shows head/tail inline; the FULL text is auto-saved to a file "
"whose path rides in the result.\n\n"
"50KB shows head/tail inline; the FULL text is auto-saved to a file whose path rides in the result.\n\n"
f"{cwd_note}\n\n"
"Built-in helpers (no import): json_parse(text) — tolerant "
"json.loads for terminal() output; shell_quote(s) — shlex.quote for "
"dynamic shell args; retry(fn, max_attempts=3, delay=2) — "
"exponential backoff."
"dynamic shell args; retry(fn, max_attempts=3, delay=2) — exponential backoff."
)
return {
"name": "execute_code",
"description": description,
@@ -1084,8 +944,7 @@ def build_execute_code_schema(enabled_sandbox_tools: set = None,
"reset": {
"type": "boolean",
"description": (
"Discard the kernel's persistent state and start "
"fresh before running this code."
"Discard the kernel's persistent state and start fresh before running this code."
),
},
},
@@ -1094,14 +953,13 @@ def build_execute_code_schema(enabled_sandbox_tools: set = None,
}
# Default schema used at registration time (all sandbox tools listed,
# current configured mode). model_tools.py rebuilds per-session anyway.
# Registration-time schema (all sandbox tools, configured mode); model_tools.py rebuilds per-session.
EXECUTE_CODE_SCHEMA = build_execute_code_schema()
def _execute_code_handler(args: dict, **kwargs) -> str:
"""Redirect misdirected calls (terminal's ``command`` arg, non-string
``code``) with an actionable error before dispatching to ``execute_code``."""
"""Redirect misdirected calls (terminal's ``command`` arg, non-string ``code``) with an
actionable error before dispatching to ``execute_code``."""
if "code" not in args and "command" in args:
logger.warning("execute_code received 'command' instead of the required 'code' argument")
return tool_error(
@@ -1109,29 +967,18 @@ def _execute_code_handler(args: dict, **kwargs) -> str:
"Python source in 'code'. Use terminal(command=...) for shell "
"commands; for Python, retry as execute_code(code=...)."
)
code = args.get("code", "")
if code is not None and not isinstance(code, str):
return tool_error(
f"execute_code received a {type(code).__name__} in 'code', but it "
"requires Python source as a string. Retry as "
"execute_code(code=\"...\")."
"requires Python source as a string. Retry as execute_code(code=\"...\")."
)
return execute_code(
code=code or "",
task_id=kwargs.get("task_id"),
enabled_tools=kwargs.get("enabled_tools"),
reset=bool(args.get("reset", False)),
)
return execute_code(code=code or "", task_id=kwargs.get("task_id"),
enabled_tools=kwargs.get("enabled_tools"), reset=bool(args.get("reset", False)))
registry.register(
name="execute_code",
toolset="code_execution",
schema=EXECUTE_CODE_SCHEMA,
handler=_execute_code_handler,
check_fn=check_sandbox_requirements,
emoji="🐍",
name="execute_code", toolset="code_execution", schema=EXECUTE_CODE_SCHEMA,
handler=_execute_code_handler, check_fn=check_sandbox_requirements, emoji="🐍",
max_result_size_chars=100_000,
)

View File

@@ -1,28 +1,18 @@
"""Session-persistent Python kernels for execute_code.
"""Session-persistent Python kernels for execute_code: one child per (owner, mode,
interpreter, cwd, tool-set), one code cell per call, state survives across calls.
One Python child stays alive per (owner, mode, interpreter, cwd, tool-set) and
runs one code cell per call, so variables/imports/data survive across calls.
Constraints, in order: (1) SAME security envelope as per-call (``_build_child_env``
scrubbing, ``_rpc_server_loop`` token + per-cell tool budget, ANSI strip + secret
redaction) — only lifetime widens. (2) A wedged kernel dies, never hangs the agent:
timeout/interrupt kills the process tree and drops the registry entry; state loss is
deliberate (a cell cannot be interrupted in place safely). (3) Env frozen at spawn:
later passthrough is invisible until ``reset=true`` (the result names the kernel).
Design constraints, in order: (1) the SAME security envelope as per-call —
``_build_child_env`` scrubbing, ``_rpc_server_loop`` with the same token and
per-cell tool budget, the same ANSI strip + secret redaction; nothing here widens
what a script can reach, only how long it lives. (2) A wedged kernel dies, never
hangs the agent: timeout or interrupt kills the whole process tree and drops the
registry entry; losing state is deliberate since one cell cannot be interrupted
in place without leaving the interpreter unknown. (3) The env is frozen at spawn:
passthrough registered later is invisible until ``reset=true`` (the result names
the kernel so this is diagnosable).
Wire protocol (host <-> child): requests are one JSON object per stdin line
``{"id", "code"}``; responses are framed on stdout as
``<SENTINEL> <byte-length>\\n<json>`` with a per-kernel random SENTINEL from the
environment. Bytes outside frames are raw fd-level output (subprocesses inherit
the real stdout), attributed to the running cell — calls are serialized per
kernel. A script forging a frame can only fake its own cell result (same trust
position as a per-call script printing a forged success message).
Also hosts what ``tools.code_kernel_remote`` shares: owner resolution, the
registry lifecycle, and the runner's cell-exec core.
Wire protocol: one JSON request per stdin line ``{"id", "code"}``; replies framed on
stdout as ``<SENTINEL> <byte-length>\\n<json>`` with a per-kernel random SENTINEL from
the env. Bytes outside frames are raw fd output attributed to the running cell (calls
are serialized per kernel). A forged frame can only fake its own cell result.
Also hosts what ``tools.code_kernel_remote`` shares: owner resolution, registry, cell core.
"""
from __future__ import annotations
@@ -46,13 +36,11 @@ logger = logging.getLogger(__name__)
_IS_WINDOWS = sys.platform == "win32"
# Runner-side cap on captured python-level output; the host applies its own
# MAX_STDOUT truncation again.
# Runner-side cap on captured python-level output; the host re-applies its own MAX_STDOUT cap.
_RUNNER_CAPTURE_BYTES = 1_000_000
# Shared by both generated runners (which define _CAPTURE_LIMIT first): exec one
# request in the persistent GLOBALS namespace and build the response payload.
# `__name__` is `__main__` so scripts behave like the per-call path.
# Shared by both generated runners (which define _CAPTURE_LIMIT first): exec one request in the
# persistent GLOBALS namespace, build the payload. `__name__` is `__main__` as on the per-call path.
RUNNER_CELL_SOURCE = '''\
GLOBALS = {"__name__": "__main__", "__builtins__": __builtins__}
@@ -150,17 +138,15 @@ if __name__ == "__main__":
class CellAuthority:
"""The approval/context identity of exactly one execute_code cell.
Interpreter state persists across cells; RPC authority must not. Each cell
installs a fresh authority — captured from the CALLING thread at cell start,
exactly what ``propagate_context_to_thread`` would capture for a per-call RPC
thread — and retires it when the cell settles, so a late tool call (a
background thread the cell left behind, a raced client write) is refused
instead of running under a stale approval/session/turn identity.
Interpreter state persists across cells; RPC authority must not. Each cell installs a
fresh authority captured from the CALLING thread at cell start (what
``propagate_context_to_thread`` captures for a per-call RPC thread) and retires it when
the cell settles, so a late tool call (leaked background thread, raced client write) is
refused instead of running under a stale approval/session/turn identity.
"""
def __init__(self, task_id: str):
import contextvars
self.task_id = task_id
self.ctx = contextvars.copy_context()
self.active = True
@@ -168,12 +154,10 @@ class CellAuthority:
self._callbacks = (None, None)
try:
from tools.thread_context import _callback_api
self._api = _callback_api()
self._callbacks = (self._api[0](), self._api[1]())
except Exception:
# Fail-closed, mirroring propagate_context_to_thread: with no
# callbacks installed, dangerous approvals deny.
# Fail-closed like propagate_context_to_thread: no callbacks → dangerous approvals deny.
self._api = None
def retire(self) -> None:
@@ -182,17 +166,13 @@ class CellAuthority:
def dispatch(self, tool_name: str, tool_args: dict) -> str:
"""Run one tool call under THIS cell's context and callbacks."""
from tools.code_execution_tool import tool_error
if not self.active:
return tool_error(
"No active execute_code cell: the cell this kernel call "
"belonged to has settled, so its tool authority is retired."
)
return tool_error("No active execute_code cell: the cell this kernel call "
"belonged to has settled, so its tool authority is retired.")
return self.ctx.run(self._invoke, tool_name, tool_args)
def _invoke(self, tool_name: str, tool_args: dict) -> str:
from model_tools import handle_function_call
previous = None
if self._api is not None:
get_approval, get_sudo, set_approval, set_sudo = self._api
@@ -259,7 +239,6 @@ class SessionKernel:
self.stop_event.set()
if self.alive():
from tools.code_execution_tool import _kill_process_group
_kill_process_group(self.proc, escalate=True)
sock, self.server_sock = self.server_sock, None
try:
@@ -271,16 +250,12 @@ class SessionKernel:
pass
if self.tmpdir:
import shutil
shutil.rmtree(self.tmpdir, ignore_errors=True)
class KernelRegistry:
"""Key -> kernel map plus its lock (shared with the remote registry).
Kernels are popped under the lock and torn down outside it — teardown
may block on the child process or the remote transport.
"""
"""Key -> kernel map plus its lock (shared with the remote registry). Kernels are popped
under the lock and torn down outside it — teardown may block on the child or the transport."""
def __init__(self, teardown: Callable[[Any], None]):
self.kernels: Dict[Tuple, Any] = {}
@@ -305,57 +280,45 @@ class KernelRegistry:
_REGISTRY = KernelRegistry(lambda kernel: kernel.teardown())
_KERNELS: Dict[Tuple, SessionKernel] = _REGISTRY.kernels
# Bounded lifecycle defaults (config: code_execution.max_session_kernels /
# code_execution.kernel_idle_timeout). A long-lived gateway must never
# accumulate one live child per finished conversation: stable owner id,
# owner-teardown disposal, idle reaping, max-live bound.
# Bounded lifecycle defaults (config: code_execution.max_session_kernels / kernel_idle_timeout).
# A long-lived gateway must never accumulate one live child per finished conversation:
# stable owner id, owner-teardown disposal, idle reaping, max-live bound.
DEFAULT_MAX_SESSION_KERNELS = 4
DEFAULT_KERNEL_IDLE_TIMEOUT = 1800
def _lifecycle_limits() -> Tuple[int, int]:
from tools.code_execution_tool import _load_config
config = _load_config()
def limit(key: str, default: int) -> int:
try:
return max(1, int(config.get(key, default)))
except (TypeError, ValueError):
return default
return (limit("max_session_kernels", DEFAULT_MAX_SESSION_KERNELS),
limit("kernel_idle_timeout", DEFAULT_KERNEL_IDLE_TIMEOUT))
def _resolve_owner(task_id: str) -> str:
"""The stable identity a session kernel belongs to.
"""The stable identity a session kernel belongs to: the conversation's approval session key
(context-propagated, stable across turns, distinct per session). ``run_agent`` mints a fresh
task id per turn, so a task-keyed kernel would neither survive the next turn nor be torn down
with anything; the task id is only the last-resort owner (embeds/tests without a session).
The conversation's approval session key: context-propagated, stable across
turns, distinct per session. ``run_agent`` mints a fresh task id per
top-level turn, so a task-keyed kernel would neither survive the next turn
nor ever be torn down with anything; the task id is only the last-resort
owner for embeds and tests with no session context.
Delegated children run in a copy of the parent's context and INHERIT its
approval session key — without the ``::child::`` qualifier a child's
execute_code would attach to the parent's kernel and read its in-memory
state (verified live, both directions). Children get their own kernels,
keyed by their delegation session id.
Delegated children INHERIT the parent's approval session key — without the ``::child::``
qualifier a child's execute_code would attach to the parent's kernel and read its state
(verified live, both directions). Children get their own kernels keyed by delegation session id.
"""
try:
from tools.approval import get_current_session_key
session_key = get_current_session_key(default="")
except Exception:
session_key = ""
owner = session_key or (task_id or "")
try:
from agent.delegation_context import is_delegated_child_context
if is_delegated_child_context():
from gateway.session_context import get_session_env
child_id = get_session_env("HERMES_SESSION_ID", "") or (task_id or "")
owner = f"{owner}::child::{child_id}"
except Exception:
@@ -380,25 +343,16 @@ atexit.register(shutdown_all_kernels)
def _rpc_forever(kernel: SessionKernel, max_tool_calls: int,
sandbox_tools: frozenset) -> None:
"""Serve tool RPC for the kernel's whole life.
``_rpc_server_loop`` serves one connection and returns on disconnect or its
300s idle timeout; a kernel legitimately idles longer between cells, so
re-accept until teardown (the client stub reconnects: HERMES_RPC_PERSISTENT).
The serving thread carries NO frozen authority: every dispatch routes
through the CURRENT cell's ``CellAuthority``, so a later cell's tool calls
run under that cell's context, not whatever the first cell captured.
"""
"""Serve tool RPC for the kernel's whole life: ``_rpc_server_loop`` returns on disconnect or
its 300s idle timeout, and a kernel idles longer between cells, so re-accept until teardown
(the client stub reconnects: HERMES_RPC_PERSISTENT). The serving thread carries NO frozen
authority — every dispatch routes through the CURRENT cell's ``CellAuthority``."""
from tools.code_execution_tool import _rpc_server_loop, tool_error
def _dispatch(tool_name: str, tool_args: dict) -> str:
authority = kernel.cell_authority
if authority is None:
return tool_error("No active execute_code cell: this kernel has no cell "
"authority installed.")
return tool_error("No active execute_code cell: this kernel has no cell authority installed.")
return authority.dispatch(tool_name, tool_args)
while not kernel.stop_event.is_set():
_rpc_server_loop(kernel.server_sock, "", kernel.tool_call_log, kernel.tool_call_counter,
max_tool_calls, sandbox_tools, kernel.stop_event, kernel.rpc_token,
@@ -408,19 +362,15 @@ def _rpc_forever(kernel: SessionKernel, max_tool_calls: int,
def _stdout_reader(kernel: SessionKernel) -> None:
"""Split the child's stdout into protocol frames and raw passthrough."""
from tools.code_execution_tool import MAX_STDOUT_BYTES
assert kernel.proc is not None and kernel.proc.stdout is not None
stream = kernel.proc.stdout
marker = ("\n" + kernel.sentinel + " ").encode("utf-8")
def raw(data: bytes) -> None:
kernel.raw.append(data, MAX_STDOUT_BYTES)
buf = b""
while True:
# read1: return as soon as any bytes arrive. A plain read(n) on a
# BufferedReader blocks until n bytes or EOF, which would sit on a
# complete frame smaller than the buffer forever.
# read1 returns as soon as any bytes arrive; a plain read(n) on a BufferedReader
# blocks until n bytes or EOF and would sit on a complete small frame forever.
chunk = stream.read1(4096)
if not chunk:
if buf:
@@ -431,8 +381,7 @@ def _stdout_reader(kernel: SessionKernel) -> None:
while True:
index = buf.find(marker)
if index < 0:
# Keep a marker-sized tail in case the marker is split
# across reads; everything before it is raw output.
# Keep a marker-sized tail (marker may be split across reads); the rest is raw.
spill = buf[: -len(marker)] if len(buf) > len(marker) else b""
if spill:
raw(spill)
@@ -448,8 +397,7 @@ def _stdout_reader(kernel: SessionKernel) -> None:
try:
length = int(rest[:newline])
except ValueError:
# Not a real frame header (user output that happens to
# contain the marker bytes); treat the marker as raw.
# Not a real frame header (user output containing the marker bytes): raw.
raw(marker)
buf = rest
continue
@@ -469,7 +417,6 @@ def _stdout_reader(kernel: SessionKernel) -> None:
def _stderr_reader(kernel: SessionKernel) -> None:
from tools.code_execution_tool import MAX_STDERR_BYTES
assert kernel.proc is not None and kernel.proc.stderr is not None
while True:
chunk = kernel.proc.stderr.read1(4096)
@@ -501,52 +448,40 @@ def _bind_rpc_socket(kernel: SessionKernel) -> str:
def _spawn(kernel: SessionKernel, *, child_python: str, child_cwd: str,
sandbox_tools: frozenset, max_tool_calls: int) -> None:
from tools.code_execution_tool import _build_child_env, generate_hermes_tools_module
kernel.tmpdir = tempfile.mkdtemp(prefix="hermes_kernel_")
kernel.rpc_token = secrets.token_urlsafe(32)
kernel.sentinel = "@@HERMES-KERNEL-" + secrets.token_urlsafe(16) + "@@"
rpc_endpoint = _bind_rpc_socket(kernel)
for name, src in (("hermes_tools.py", generate_hermes_tools_module(list(sandbox_tools))),
("hermes_kernel_runner.py", KERNEL_RUNNER_SOURCE)):
with open(os.path.join(kernel.tmpdir, name), "w", encoding="utf-8") as f:
f.write(src)
child_env = _build_child_env(rpc_endpoint=rpc_endpoint, rpc_token=kernel.rpc_token,
tmpdir=kernel.tmpdir, child_python=child_python)
child_env["HERMES_KERNEL_SENTINEL"] = kernel.sentinel
# Cells clip stdout to the inline cap; the full text spills to the kernel's
# own tmpdir so the agent can read_file the middle instead of re-running.
# Full clipped stdout spills to the kernel's tmpdir so the agent can read_file the middle.
child_env["HERMES_KERNEL_SPILL_DIR"] = kernel.tmpdir
# Tell the generated client to reconnect after the RPC server's idle
# timeout — a kernel outlives the 300s window between cells.
# Generated client reconnects after the RPC server's 300s idle timeout between cells.
child_env["HERMES_RPC_PERSISTENT"] = "1"
kernel.proc = subprocess.Popen(
[child_python, os.path.join(kernel.tmpdir, "hermes_kernel_runner.py")],
# Strict mode resolves an empty cwd: the kernel's own staging dir
# then plays the per-call tmpdir's role.
# Strict mode passes an empty cwd: the kernel's staging dir plays the per-call tmpdir's role.
cwd=child_cwd or kernel.tmpdir, env=child_env,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE,
start_new_session=True,
creationflags=subprocess.CREATE_NO_WINDOW if _IS_WINDOWS else 0,
)
# Deliberately NOT propagate_context_to_thread: that would freeze the
# spawning cell's context/callbacks into the server thread for the
# kernel's whole life. Authority is rebound per cell via CellAuthority.
# Deliberately NOT propagate_context_to_thread: that would freeze the spawning cell's
# context/callbacks into the server thread for life. Authority is rebound per cell.
for target, args in ((_rpc_forever, (kernel, max_tool_calls, sandbox_tools)),
(_stdout_reader, (kernel,)), (_stderr_reader, (kernel,))):
threading.Thread(target=target, args=args, daemon=True).start()
def _acquire_kernel(key: Tuple, reset: bool) -> Tuple[SessionKernel, bool]:
"""Look up or register the kernel for *key*; returns (kernel, state_reset).
Every entry also sweeps idle-expired kernels and enforces the process-wide
LRU cap, so a long-lived host stays bounded even for owners that never
toggle or reset. Doomed kernels are popped under the lock, torn down outside it.
"""
"""Look up or register the kernel for *key*; returns (kernel, state_reset). Every entry also
sweeps idle-expired kernels and enforces the process-wide LRU cap (doomed kernels are popped
under the lock, torn down outside it), so a long-lived host stays bounded."""
cap, idle_timeout = _lifecycle_limits()
with _REGISTRY.lock:
now = time.monotonic()
@@ -594,23 +529,17 @@ def _cell_result(kernel: SessionKernel, key: Tuple, status: str, payload: Dict[s
from tools.code_execution_tool import _sandbox_failure_hint, _truncate_stdout_text
from agent.redact import redact_sensitive_text
from tools.ansi_strip import strip_ansi
def clean(text: str) -> str:
return redact_sensitive_text(strip_ansi(text), code_file=True)
if status in ("timeout", "interrupted"):
# No safe way to interrupt one cell in place: kill the kernel,
# report the state loss, let the next call respawn.
# No safe way to interrupt one cell in place: kill the kernel, report the loss, respawn next call.
_REGISTRY.discard(key, kernel)
duration = round(time.monotonic() - exec_start, 2)
kernel.execution_count = int(payload.get("execution_count", kernel.execution_count + 1))
stderr_raw = kernel.stderr.drain()
stdout_text = clean(str(payload.get("stdout", "")) + kernel.raw.drain())
cell_stderr = clean(str(payload.get("stderr", "")) + stderr_raw)
stdout_text, stdout_metadata = _truncate_stdout_text(stdout_text)
cell_status = payload.get("status", "")
result: Dict[str, Any] = {
"status": status, "output": stdout_text, "exit_code": 0,
@@ -619,9 +548,7 @@ def _cell_result(kernel: SessionKernel, key: Tuple, status: str, payload: Dict[s
"execution_count": kernel.execution_count, "state_reset": state_reset},
}
result.update(stdout_metadata)
# Cell-side spill (runner clipped before replying): surface the full-output
# path with the same read_file recipe as the host-side spill.
# Cell-side spill (runner clipped before replying): same read_file recipe as the host-side spill.
cell_spill = str(payload.get("stdout_spill_path", "") or "")
if cell_spill and payload.get("stdout_clipped"):
result["stdout_spill_path"] = cell_spill
@@ -630,7 +557,6 @@ def _cell_result(kernel: SessionKernel, key: Tuple, status: str, payload: Dict[s
f'— page it with read_file(path="{cell_spill}", offset=...) instead of re-running. '
"(Kernel state persists: printing a narrower slice next call is often cheaper.)"
)
if status == "timeout":
message = (f"Cell timed out after {timeout}s; the session kernel was killed and its "
"state was lost. The next execute_code call starts a fresh kernel.")
@@ -638,7 +564,6 @@ def _cell_result(kernel: SessionKernel, key: Tuple, status: str, payload: Dict[s
output=(stdout_text + "\n\n⏰ " + message) if stdout_text else ("⏰ " + message))
elif status == "interrupted":
from tools.code_execution_tool import _format_interrupted_output
result.update(exit_code=-1, output=_format_interrupted_output(stdout_text),
error="Interrupted; the session kernel was killed and its state was lost.")
elif cell_status == "error":
@@ -667,41 +592,29 @@ def execute_in_session_kernel(
code: str, *, task_id: str, mode: str, child_python: str, child_cwd: str,
sandbox_tools: frozenset, timeout: int, max_tool_calls: int, reset: bool, is_interrupted,
) -> str:
"""Run one cell in the (owner, mode, python, cwd, tools) session kernel.
The owner is the conversation's session key (``_resolve_owner``), not the
per-turn task id, so state survives across user turns of one conversation
and dies with the session.
"""
"""Run one cell in the (owner, mode, python, cwd, tools) session kernel. The owner is the
session key (``_resolve_owner``), not the per-turn task id, so state survives across turns."""
key = (_resolve_owner(task_id) or "", mode, child_python, child_cwd, tuple(sorted(sandbox_tools)))
exec_start = time.monotonic()
kernel, state_reset = _acquire_kernel(key, reset)
reused = kernel.proc is not None
# Captured on the calling thread BEFORE the cell runs — the same snapshot a
# per-call RPC thread would have received — and installed atomically on the
# kernel so the serving thread dispatches this cell's tool calls under this
# cell's approval/session/turn identity.
# Captured on the calling thread BEFORE the cell runs (the snapshot a per-call RPC thread
# would get) and installed on the kernel so RPC dispatches under THIS cell's identity.
authority = CellAuthority(task_id)
with kernel.lock:
try:
if kernel.proc is None:
_spawn(kernel, child_python=child_python, child_cwd=child_cwd,
sandbox_tools=sandbox_tools, max_tool_calls=max_tool_calls)
assert kernel.proc is not None and kernel.proc.stdin is not None
# Per-cell tool budget: the RPC loop enforces counter < max, so a
# fresh cell starts from zero without restarting the server.
# Per-cell tool budget: the RPC loop enforces counter < max; reset without restarting.
kernel.tool_call_counter[0] = 0
# Anything raw that leaked between cells belongs to no cell.
kernel.raw.drain()
kernel.stderr.drain()
kernel.cell_authority = authority
kernel.proc.stdin.write((json.dumps({"id": uuid.uuid4().hex, "code": code}) + "\n").encode("utf-8"))
kernel.proc.stdin.flush()
status, payload = _await_cell(kernel, timeout, is_interrupted)
result = _cell_result(
kernel, key, status, payload,
@@ -718,7 +631,6 @@ def execute_in_session_kernel(
"duration_seconds": round(time.monotonic() - exec_start, 2),
}, ensure_ascii=False)
finally:
# The cell has settled on every path (success, exception, timeout,
# exit, kernel death): its tool authority retires with it, so
# The cell has settled on every path: its tool authority retires with it, so
# nothing the cell left running can dispatch under it.
authority.retire()

View File

@@ -32,15 +32,13 @@ from tools.code_kernel import RUNNER_CELL_SOURCE, KernelRegistry
logger = logging.getLogger(__name__)
# How often the host polls the remote for a cell result file. Each poll is
# one env.execute round-trip (typically 0.1-0.4s on ssh/docker), so this is
# a floor, not a rate.
# Host poll interval for a cell result file; each poll is one env.execute round-trip
# (0.1-0.4s on ssh/docker), so this is a floor, not a rate.
_CELL_POLL_INTERVAL = 0.5
# The remote runner: a tiny forever-loop that polls for cell request files,
# execs them in one persistent namespace, and writes response files. It is
# deliberately transport-agnostic (pure files) and stdlib-only. Cells and
# tool-RPC share the kernel dir but use distinct prefixes.
# The remote runner: a forever-loop that polls for cell request files, execs them in one
# persistent namespace, writes response files. Pure files + stdlib only (transport-agnostic);
# cells and tool-RPC share the kernel dir under distinct prefixes.
REMOTE_KERNEL_RUNNER_SOURCE = '''\
"""Auto-generated Hermes REMOTE session-kernel runner (file cell protocol)."""
import contextlib
@@ -173,7 +171,6 @@ def _spawn_remote_kernel(env, env_type: str, owner: str, task_env_id: str,
from tools.code_execution_tool import (
MAX_STDOUT_BYTES, _ship_file_to_remote, _env_temp_dir, generate_hermes_tools_module,
)
kernel_dir = f"{_env_temp_dir(env)}/hermes_rkernel_{uuid.uuid4().hex[:12]}"
q_dir = shlex.quote(kernel_dir)
kernel = None
@@ -184,7 +181,6 @@ def _spawn_remote_kernel(env, env_type: str, owner: str, task_env_id: str,
cell_source=RUNNER_CELL_SOURCE, capture_limit=MAX_STDOUT_BYTES, idle_exit=idle_exit))
_ship_file_to_remote(env, f"{kernel_dir}/hermes_tools.py",
generate_hermes_tools_module(list(sandbox_tools), transport="file"))
env_prefix = (
f"HERMES_KERNEL_DIR={q_dir} "
f"HERMES_RPC_DIR={shlex.quote(kernel_dir + '/rpc')} "
@@ -226,10 +222,8 @@ def _acquire_remote_kernel(env, env_type: str, owner: str, task_env_id: str,
"""Find/respawn the owner's kernel: (kernel|None, reused, state_reset, state_lost)."""
key = _kernel_key(owner, env_type, task_env_id)
state_lost = state_reset = False
with _REGISTRY.lock:
kernel = _REMOTE_KERNELS.get(key)
if kernel is not None and reset:
_REGISTRY.discard(key, kernel)
kernel, state_reset = None, True
@@ -239,7 +233,6 @@ def _acquire_remote_kernel(env, env_type: str, owner: str, task_env_id: str,
# best-effort dir cleanup; the process is already gone).
_REGISTRY.discard(key, kernel)
kernel, state_lost = None, True
reused = kernel is not None
if kernel is None:
kernel = _spawn_remote_kernel(env, env_type, owner, task_env_id, sandbox_tools, idle_exit=idle_exit)
@@ -252,7 +245,6 @@ def _acquire_remote_kernel(env, env_type: str, owner: str, task_env_id: str,
def _run_remote_cell(kernel: RemoteKernel, code: str, timeout: int) -> Tuple[str, Dict[str, Any]]:
"""Ship one cell request and poll for its result: (cell status, payload)."""
from tools.code_execution_tool import _ship_file_to_remote
kernel.cell_seq += 1
seq = f"{kernel.cell_seq:06d}"
q_cells = shlex.quote(f"{kernel.kernel_dir}/cells")
@@ -260,7 +252,6 @@ def _run_remote_cell(kernel: RemoteKernel, code: str, timeout: int) -> Tuple[str
_ship_file_to_remote(kernel.env, f"{kernel.kernel_dir}/cells/cell_req_{seq}.json.tmp",
json.dumps({"id": seq, "code": code}, ensure_ascii=False))
kernel.sh(f"mv {q_cells}/cell_req_{seq}.json.tmp {q_cells}/cell_req_{seq}.json", timeout=10)
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
@@ -286,15 +277,13 @@ def execute_in_remote_kernel(
) -> Optional[Dict[str, Any]]:
"""Run one cell in the owner's remote kernel.
Returns the raw cell result dict (caller does output post-processing),
or ``None`` when no kernel could be spawned — the caller falls open to
the per-call path. ``state_lost`` / ``state_reset`` / ``reused`` ride in
the ``kernel`` sub-dict, matching the local kernel's result shape.
Returns the raw cell result dict (caller post-processes output), or ``None`` when no
kernel could be spawned (caller falls open to per-call). ``state_lost`` / ``state_reset`` /
``reused`` ride in the ``kernel`` sub-dict, matching the local kernel's result shape.
"""
from tools.code_kernel import _resolve_owner
from tools.code_execution_tool import _rpc_poll_loop
from tools.thread_context import propagate_context_to_thread
owner = _resolve_owner(task_env_id)
kernel, reused, state_reset, state_lost = _acquire_remote_kernel(
env, env_type, owner, task_env_id, sandbox_tools, reset=reset, idle_exit=idle_exit)
@@ -302,21 +291,17 @@ def execute_in_remote_kernel(
return None # fail open to per-call
key = _kernel_key(owner, env_type, task_env_id)
kernel.last_used = time.monotonic()
# Clean stale tool-RPC requests from a previous cell before arming this
# cell's poll loop, so a background thread the last cell leaked cannot
# smuggle a call into this cell's authority window.
# Clean stale tool-RPC requests from a previous cell before arming this cell's poll loop, so
# a background thread the last cell leaked cannot smuggle a call into this authority window.
q_rpc = shlex.quote(kernel.kernel_dir + '/rpc')
try:
kernel.sh(f"rm -f {q_rpc}/req_* {q_rpc}/res_*", timeout=10)
except Exception:
pass
tool_call_log: list = []
tool_call_counter, stop_event = [0], threading.Event()
# Per-cell RPC thread carrying THIS call's approval/session context —
# the remote analogue of CellAuthority: authority lives exactly as long
# as the cell's poll loop.
# Per-cell RPC thread carrying THIS call's approval/session context — the remote analogue
# of CellAuthority: authority lives exactly as long as the cell's poll loop.
rpc_thread = threading.Thread(
target=propagate_context_to_thread(_rpc_poll_loop),
args=(env, f"{kernel.kernel_dir}/rpc", task_env_id, tool_call_log, tool_call_counter,
@@ -324,14 +309,12 @@ def execute_in_remote_kernel(
daemon=True,
)
rpc_thread.start()
cell_status, cell_payload = "no-result", {}
try:
cell_status, cell_payload = _run_remote_cell(kernel, code, timeout)
finally:
stop_event.set()
rpc_thread.join(timeout=5)
kernel_info: Dict[str, Any] = {"reused": reused, "remote": True}
result: Dict[str, Any] = {
"status": "error", "stdout": cell_payload.get("stdout", ""),
@@ -339,8 +322,7 @@ def execute_in_remote_kernel(
"tool_calls_made": tool_call_counter[0], "kernel": kernel_info,
}
if cell_status in ("timeout", "protocol-error", "no-result"):
# No safe way to interrupt one cell in place (same contract as
# local): kill the kernel, report the loss, respawn next call.
# No safe way to interrupt one cell in place (same contract as local): kill, report, respawn.
_REGISTRY.discard(key, kernel)
if cell_status == "timeout":
result["status"] = "timeout"
@@ -350,7 +332,6 @@ def execute_in_remote_kernel(
note = "Remote kernel protocol failure; kernel killed, state lost."
kernel_info.update(ended=True, state_lost=True, note=note)
return result
if cell_status == "exit":
_REGISTRY.discard(key, kernel)
kernel_info["ended"] = True
@@ -365,8 +346,7 @@ def execute_in_remote_kernel(
if state_lost:
kernel_info.update(state_lost=True, note=(
"The previous remote kernel was gone (transport drop, container "
"restart, or idle self-exit); state from earlier calls was lost "
"and a fresh kernel was started."))
"restart, or idle self-exit); state from earlier calls was lost and a fresh kernel was started."))
if cell_status == "error" and result["traceback"]:
result["error"] = result["traceback"].strip().splitlines()[-1]
return result