# Conflicts: # .gitignore # Dockerfile # agent/onboarding.py # apps/desktop/electron/main.ts # apps/desktop/electron/pool-stop.ts # apps/desktop/src/components/model-picker.test.tsx # apps/desktop/src/store/updates.ts # apps/desktop/vite.config.ts # datagen-config-examples/run_browser_tasks.sh # docs/rca-ssl-cacert-post-git-pull.md # gateway/run.py # hermes_cli/backup.py # hermes_cli/credential_lifecycle.py # hermes_cli/dashboard_procs.py # hermes_cli/doctor_state.py # hermes_cli/env_loader.py # hermes_cli/gateway_windows.py # hermes_cli/local_runtime/endpoint.py # hermes_cli/psutil_android.py # hermes_cli/update_cmd.py # hermes_cli/update_cmd_windows.py # hermes_cli/web_routers/local_models.py # hermes_cli/web_server_config.py # hermes_cli/web_server_cron.py # plugins/memory/hindsight/__init__.py # plugins/memory/holographic/__init__.py # plugins/memory/honcho/cli.py # plugins/memory/mem0/__init__.py # plugins/platforms/google_chat/oauth.py # plugins/platforms/photon/adapter.py # scripts/ci/list_os_marked_tests.py # scripts/run_tests.sh # tests/agent/test_compression_stall_fallback.py # tests/agent/test_create_openai_client_ssl_verify.py # tests/gateway/test_google_chat_oauth_dependencies.py # tests/hermes_cli/conftest.py # tests/hermes_cli/test_cli_init.py # tests/hermes_cli/test_gateway_migrate_multiplex.py # tests/hermes_cli/test_psutil_android_extract.py # tests/hermes_cli/test_relaunch.py # tests/hermes_cli/test_update_check.py # tests/hermes_cli/test_update_handoff_desktop_rebuild.py # tests/hermes_cli/test_worktree_gc.py # tests/scripts/desktop_update/test_desktop_update_windows_python_handoff.py # tests/scripts/desktop_update/test_desktop_update_windows_retry_policy.py # tests/scripts/desktop_update/test_desktop_update_windows_timestamp.py # tests/scripts/install/test_install_autostash_conflict_recovery.py # tests/scripts/install/test_install_clone_throttle_fallback.py # tests/scripts/install/test_install_commit_pin_rollback.py # tests/scripts/install/test_install_diverged_update.py # tests/scripts/install/test_install_lockfile_churn.py # tests/scripts/install/test_install_macos_launcher.py # tests/scripts/install/test_install_no_initial_commit.py # tests/scripts/install/test_install_ps1_ascii_only.py # tests/scripts/install/test_install_ps1_browser_install.py # tests/scripts/install/test_install_ps1_managed_node_swap.py # tests/scripts/install/test_install_ps1_native_stderr_eap.py # tests/scripts/install/test_install_ps1_node_path_for_npm.py # tests/scripts/install/test_install_ps1_python_fallback_venv.py # tests/scripts/install/test_install_ps1_resolver_strictmode.py # tests/scripts/install/test_install_ps1_uv_install_fallback.py # tests/scripts/install/test_install_ps1_uv_powershell_host.py # tests/scripts/install/test_install_ps1_venv_process_tree.py # tests/scripts/install/test_install_ps1_venv_recreate_safety.py # tests/scripts/install/test_install_ps1_venv_rename_abort.py # tests/scripts/install/test_install_ps1_venv_transaction_boundary.py # tests/scripts/install/test_install_ps1_web_server_syntax_probe.py # tests/scripts/install/test_install_scripts_computer_use.py # tests/scripts/install/test_install_sh_acp_launcher.py # tests/scripts/install/test_install_sh_bootstrap_marker.py # tests/scripts/install/test_install_sh_browser_install.py # tests/scripts/install/test_install_sh_install_method_stamp.py # tests/scripts/install/test_install_sh_node_deps_failure.py # tests/scripts/install/test_install_sh_node_deps_workspaces.py # tests/scripts/install/test_install_sh_node_global_prefix.py # tests/scripts/install/test_install_sh_node_npm_check.py # tests/scripts/install/test_install_sh_node_prerelease.py # tests/scripts/install/test_install_sh_node_probe.py # tests/scripts/install/test_install_sh_node_tarball_without_xz.py # tests/scripts/install/test_install_sh_pythonpath_sanitization.py # tests/scripts/install/test_install_sh_reuse_supported_python.py # tests/scripts/install/test_install_sh_root_fhs_uv_python_path.py # tests/scripts/install/test_install_sh_setup_wizard_tty_probe.py # tests/scripts/install/test_install_sh_symlink_stomp.py # tests/scripts/install/test_install_sh_termux_network_prereqs.py # tests/scripts/install/test_install_sh_termux_python_bounds.py # tests/scripts/install/test_install_sh_uv_lock_config.py # tests/scripts/install/test_install_unmerged_index.py # tests/scripts/test_run_tests_parallel.py # tests/test_managed_runtime_resolution.py # tests/test_project_metadata.py # tests/tools/test_browser_use_cli.py # tests/tools/test_tts_pythonpath_fallback.py # tests/tui_gateway/test_hosted_room_driver_runtime.py # tests/tui_gateway/test_tui_gateway_server.py # tools/lazy_deps.py # tools/voice_mode.py # uv.lock # website/docs/developer-guide/macos-bundle-updates.md # website/docs/developer-guide/pm-audit-status.md # website/docs/developer-guide/shared-bundle-builds.md # website/docs/developer-guide/source-update-completion.md # website/docs/developer-guide/stable-releases.md
906 lines
46 KiB
Python
906 lines
46 KiB
Python
#!/usr/bin/env python3
|
|
"""Code Execution Tool -- Programmatic Tool Calling (PTC).
|
|
|
|
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
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import secrets
|
|
import shlex
|
|
import subprocess
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
from tools.thread_context import propagate_context_to_thread
|
|
from tools.registry import registry, tool_error
|
|
|
|
from hermes_time import get_timezone_name
|
|
from tools.code_execution_env import _resolve_child_cwd, _resolve_child_python
|
|
from tools.code_execution_rpc import _rpc_poll_loop
|
|
from tools.tool_output_truncate import head_tail_split, truncation_notice
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# 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 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",
|
|
])
|
|
|
|
# Resource limit defaults (overridable via config.yaml → code_execution.*)
|
|
DEFAULT_TIMEOUT = 300 # 5 minutes
|
|
DEFAULT_MAX_TOOL_CALLS = 50
|
|
MAX_STDOUT_BYTES = 50_000 # 50 KB
|
|
MAX_STDERR_BYTES = 10_000 # 10 KB
|
|
# Hard ceiling on the spilled file (as web_tools' MAX_STORED_TEXT_CHARS): a runaway print loop must not fill the disk.
|
|
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: byte counts
|
|
ride alongside the textual marker because a client layer can miss or re-truncate it. The
|
|
omitted middle is spilled to cache/exec and the result carries the path (recover-don't-rerun)."""
|
|
stdout_bytes = stdout_text.encode("utf-8", errors="replace")
|
|
total = len(stdout_bytes)
|
|
captured = min(total, MAX_STDOUT_BYTES)
|
|
metadata: Dict[str, Any] = {"stdout_truncated": total > captured, "stdout_bytes_captured": captured,
|
|
"stdout_bytes_total": total, "stdout_bytes_omitted": total - captured}
|
|
if total <= MAX_STDOUT_BYTES:
|
|
return stdout_bytes.decode("utf-8", errors="replace"), metadata
|
|
head_bytes, tail_bytes = head_tail_split(MAX_STDOUT_BYTES)
|
|
text = (stdout_bytes[:head_bytes].decode("utf-8", errors="replace")
|
|
+ truncation_notice(total - captured, total, unit="bytes")
|
|
+ stdout_bytes[-tail_bytes:].decode("utf-8", errors="replace"))
|
|
metadata["warning"] = ("execute_code stdout was truncated; the script did run, but only "
|
|
"the captured head/tail output is included. Re-run only with "
|
|
"narrower output if the omitted data is required.")
|
|
spill_path = _spill_full_stdout(stdout_text)
|
|
if spill_path:
|
|
metadata["stdout_spill_path"] = spill_path
|
|
metadata["warning"] = ("execute_code stdout was truncated (head/tail shown); the "
|
|
f"script did run. FULL output saved to {spill_path} — page it "
|
|
f'with read_file(path="{spill_path}", offset=...) instead of re-running.')
|
|
return text, metadata
|
|
|
|
|
|
def _spill_full_stdout(stdout_text: str) -> Optional[str]:
|
|
"""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 ...]")
|
|
cache_dir = get_hermes_dir("cache/exec", "exec_spill")
|
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
digest = hashlib.sha256(stdout_text.encode("utf-8", errors="replace")).hexdigest()[:12]
|
|
path = cache_dir / f"stdout-{digest}.txt"
|
|
write_text_exclusive(path, stdout_text, private=False, overwrite=True)
|
|
return str(path)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.debug("Failed to spill execute_code stdout: %s", exc)
|
|
return None
|
|
|
|
|
|
def check_sandbox_requirements() -> bool:
|
|
"""check_fn: available unless the vercel_sandbox backend fails its own checks."""
|
|
if not SANDBOX_AVAILABLE:
|
|
return False
|
|
try:
|
|
from tools.terminal_tool import _get_env_config
|
|
from tools.terminal_tool_backends import _check_vercel_sandbox_requirements
|
|
config = _get_env_config()
|
|
except Exception:
|
|
logger.debug("Could not resolve terminal config for execute_code availability", exc_info=True)
|
|
return False
|
|
return config.get("env_type") != "vercel_sandbox" or _check_vercel_sandbox_requirements(config)
|
|
|
|
|
|
# ---- hermes_tools.py code generator ----
|
|
|
|
# Per-tool stub templates: (signature, docstring, args_dict_expr — the JSON payload sent over RPC).
|
|
_TOOL_STUBS = {
|
|
"web_search": ("query: str, limit: int = 5",
|
|
'"""Search the web. Returns dict with data.web list of {url, title, description}."""',
|
|
'{"query": query, "limit": limit}'),
|
|
"web_extract": ("urls: list, char_limit: int = None",
|
|
'"""Extract content from URLs (no LLM summarization). Returns dict with results list of {url, title, content, error}. Pages over char_limit (default 15000) are head+tail truncated with the full text stored on disk; the content footer gives the path. content is markdown."""',
|
|
'{"urls": urls, "char_limit": char_limit}'),
|
|
"read_file": ("path: str, offset: int = 1, limit: int = 2000",
|
|
'"""Read a file (1-indexed lines). Returns dict with "content" and "total_lines"."""',
|
|
'{"path": path, "offset": offset, "limit": limit}'),
|
|
"write_file": ("path: str, content: str, cross_profile: bool = False",
|
|
'"""Write content to a file (always overwrites). Returns dict with status."""',
|
|
'{"path": path, "content": content, "cross_profile": cross_profile}'),
|
|
"search_files": ('pattern: str, target: str = "content", path: str = ".", file_glob: str = None, limit: int = 50, offset: int = 0, output_mode: str = "content", context: int = 0, order: str = "discovery"',
|
|
'"""Search file contents (target="content") or find files by name (target="files"). Returns dict with "matches"."""',
|
|
'{"pattern": pattern, "target": target, "path": path, "file_glob": file_glob, "limit": limit, "offset": offset, "output_mode": output_mode, "context": context, "order": order}'),
|
|
"patch": ('path: str = None, old_string: str = None, new_string: str = None, replace_all: bool = False, mode: str = "replace", patch: str = None, cross_profile: bool = False',
|
|
'"""Targeted find-and-replace (mode="replace") or V4A multi-file patches (mode="patch"). Returns dict with status."""',
|
|
'{"path": path, "old_string": old_string, "new_string": new_string, "replace_all": replace_all, "mode": mode, "patch": patch, "cross_profile": cross_profile}'),
|
|
"terminal": ("command: str, timeout: int = None, workdir: str = None",
|
|
'"""Run a shell command (foreground only). Returns dict with "output" and "exit_code"."""',
|
|
'{"command": command, "timeout": timeout, "workdir": workdir}'),
|
|
}
|
|
|
|
|
|
def _missing_hermes_tools_import_hint(m, enabled_tools) -> str:
|
|
missing = m.group(1)
|
|
if missing in {"json_parse", "shell_quote", "retry"}:
|
|
return (f"Import helpers with `from hermes_tools import {missing}`. "
|
|
"If that import failed, the generated module may be stale or another "
|
|
"hermes_tools may be first on sys.path. Check hermes_tools.__file__ "
|
|
"and retry with reset=true.")
|
|
available = sorted(SANDBOX_ALLOWED_TOOLS & set(enabled_tools or SANDBOX_ALLOWED_TOOLS))
|
|
return (f"'{missing}' is not available inside the execute_code sandbox. "
|
|
f"Importable tools here: {', '.join(available)}. For anything "
|
|
"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, missing helper
|
|
# imports, 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",
|
|
lambda m, _: f"Import {m.group(1)} before calling it: "
|
|
f"from hermes_tools import {m.group(1)}"),
|
|
(r"ModuleNotFoundError: No module named '([\w.]+)'",
|
|
lambda m, _: f"'{m.group(1)}' is not installed in the sandbox interpreter. "
|
|
"Use Python stdlib inside execute_code, or run the code via "
|
|
"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']."),
|
|
)
|
|
|
|
|
|
def _sandbox_failure_hint(stderr_text: str, enabled_tools=None) -> Optional[str]:
|
|
"""Map well-known sandbox script failures to one actionable recovery hint
|
|
(bounded scan, first match wins, never raises)."""
|
|
if not stderr_text:
|
|
return None
|
|
window = stderr_text[:4000]
|
|
try:
|
|
for pattern, fmt in _FAILURE_HINT_RULES:
|
|
m = re.search(pattern, window)
|
|
if m:
|
|
return fmt(m, enabled_tools)
|
|
except Exception:
|
|
return None
|
|
return None
|
|
|
|
|
|
def generate_hermes_tools_module(enabled_tools: List[str],
|
|
transport: str = "uds") -> str:
|
|
"""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
|
|
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) ----------
|
|
|
|
_COMMON_HELPERS = '''\
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Convenience helpers (avoid common scripting pitfalls)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def json_parse(text: str):
|
|
"""Parse JSON tolerant of control characters and UTF-8 BOM (strict=False).
|
|
Use this instead of json.loads() when parsing output from terminal()
|
|
or web_extract() that may contain raw tabs/newlines in strings,
|
|
or from tools/files that prepend a UTF-8 BOM (salvage #57870, credit @woxinwuhen713-bit)."""
|
|
if isinstance(text, str) and text.startswith("\ufeff"):
|
|
text = text[1:]
|
|
return json.loads(text, strict=False)
|
|
|
|
|
|
def shell_quote(s: str) -> str:
|
|
"""Shell-escape a string for safe interpolation into commands.
|
|
Use this when inserting dynamic content into terminal() commands:
|
|
terminal(f"echo {shell_quote(user_input)}")
|
|
"""
|
|
return shlex.quote(s)
|
|
|
|
|
|
def retry(fn, max_attempts=3, delay=2):
|
|
"""Retry a function up to max_attempts times with exponential backoff.
|
|
Use for transient failures (network errors, API rate limits):
|
|
result = retry(lambda: terminal("gh issue list ..."))
|
|
"""
|
|
last_err = None
|
|
for attempt in range(max_attempts):
|
|
try:
|
|
return fn()
|
|
except Exception as e:
|
|
last_err = e
|
|
if attempt < max_attempts - 1:
|
|
time.sleep(delay * (2 ** attempt))
|
|
raise last_err
|
|
|
|
'''
|
|
|
|
# ---- UDS transport (local backend) ---------------------------------------
|
|
|
|
_UDS_TRANSPORT_HEADER = '''\
|
|
"""Auto-generated Hermes tools RPC stubs."""
|
|
import json, os, socket, shlex, threading, time
|
|
|
|
_sock = None
|
|
# The RPC server handles a single client connection serially and has no
|
|
# request-id in the protocol, so concurrent _call() invocations from multiple
|
|
# threads (e.g. ThreadPoolExecutor) would race on the shared socket and get
|
|
# each other's responses. Serialize the entire send+recv round-trip.
|
|
_call_lock = threading.Lock()
|
|
''' + _COMMON_HELPERS + '''\
|
|
|
|
def _connect():
|
|
"""Connect to the parent's RPC server via the transport it picked.
|
|
|
|
HERMES_RPC_SOCKET can be either:
|
|
- a filesystem path (POSIX Unix domain socket — the default on
|
|
Linux and macOS)
|
|
- a string of the form ``tcp://127.0.0.1:<port>`` (Windows, where
|
|
AF_UNIX is unreliable — the parent falls back to loopback TCP)
|
|
"""
|
|
global _sock
|
|
if _sock is None:
|
|
endpoint = os.environ["HERMES_RPC_SOCKET"]
|
|
if endpoint.startswith("tcp://"):
|
|
# tcp://host:port (host is always 127.0.0.1 in practice — we
|
|
# only bind loopback server-side)
|
|
_host_port = endpoint[len("tcp://"):]
|
|
_host, _, _port = _host_port.rpartition(":")
|
|
_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
_sock.connect((_host or "127.0.0.1", int(_port)))
|
|
else:
|
|
_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
_sock.connect(endpoint)
|
|
_sock.settimeout(300)
|
|
return _sock
|
|
|
|
def _call(tool_name, args):
|
|
"""Send a tool call to the parent process and return the parsed result."""
|
|
request = json.dumps({
|
|
"tool": tool_name,
|
|
"args": args,
|
|
"token": os.environ.get("HERMES_RPC_TOKEN", ""),
|
|
}) + "\\n"
|
|
# Session kernels outlive the RPC server's 300s idle window, so their
|
|
# connection can be legitimately gone by the next cell. The server
|
|
# re-accepts (HERMES_RPC_PERSISTENT=1); retry once on a fresh socket.
|
|
_attempts = 2 if os.environ.get("HERMES_RPC_PERSISTENT") == "1" else 1
|
|
with _call_lock:
|
|
for _attempt in range(_attempts):
|
|
try:
|
|
conn = _connect()
|
|
conn.sendall(request.encode())
|
|
buf = b""
|
|
while True:
|
|
chunk = conn.recv(65536)
|
|
if not chunk:
|
|
raise RuntimeError("Agent process disconnected")
|
|
buf += chunk
|
|
if buf.endswith(b"\\n"):
|
|
break
|
|
break
|
|
except (OSError, RuntimeError):
|
|
global _sock
|
|
try:
|
|
if _sock is not None:
|
|
_sock.close()
|
|
except OSError:
|
|
pass
|
|
_sock = None
|
|
if _attempt + 1 >= _attempts:
|
|
raise
|
|
raw = buf.decode().strip()
|
|
result = json.loads(raw)
|
|
if isinstance(result, str):
|
|
try:
|
|
return json.loads(result)
|
|
except (json.JSONDecodeError, TypeError):
|
|
return result
|
|
return result
|
|
|
|
'''
|
|
|
|
# ---- File-based transport (remote backends) -------------------------------
|
|
|
|
_FILE_TRANSPORT_HEADER = '''\
|
|
"""Auto-generated Hermes tools RPC stubs (file-based transport)."""
|
|
import json, os, shlex, tempfile, threading, time
|
|
|
|
_RPC_DIR = os.environ.get("HERMES_RPC_DIR") or os.path.join(tempfile.gettempdir(), "hermes_rpc")
|
|
_seq = 0
|
|
# `_seq += 1` is not atomic (read-modify-write), so concurrent _call()
|
|
# invocations from multiple threads could allocate the same sequence number
|
|
# and clobber each other's request files. Guard seq allocation with a lock.
|
|
_seq_lock = threading.Lock()
|
|
''' + _COMMON_HELPERS + '''\
|
|
|
|
def _call(tool_name, args):
|
|
"""Send a tool call request via file-based RPC and wait for response."""
|
|
global _seq
|
|
with _seq_lock:
|
|
_seq += 1
|
|
seq = _seq
|
|
seq_str = f"{seq:06d}"
|
|
req_file = os.path.join(_RPC_DIR, f"req_{seq_str}")
|
|
res_file = os.path.join(_RPC_DIR, f"res_{seq_str}")
|
|
|
|
# Write request atomically (write to .tmp, then rename).
|
|
# encoding="utf-8" is critical: on Windows-hosted remote backends
|
|
# (or any non-UTF-8 locale) the default open() mode would mangle
|
|
# non-ASCII chars in tool args when encoding them as JSON.
|
|
tmp = req_file + ".tmp"
|
|
with open(tmp, "w", encoding="utf-8") as f:
|
|
json.dump({
|
|
"tool": tool_name,
|
|
"args": args,
|
|
"seq": seq,
|
|
"token": os.environ.get("HERMES_RPC_TOKEN", ""),
|
|
}, f)
|
|
os.rename(tmp, req_file)
|
|
|
|
# Wait for response with adaptive polling
|
|
deadline = time.monotonic() + 300 # 5-minute timeout per tool call
|
|
poll_interval = 0.05 # Start at 50ms
|
|
while not os.path.exists(res_file):
|
|
if time.monotonic() > deadline:
|
|
raise RuntimeError(f"RPC timeout: no response for {tool_name} after 300s")
|
|
time.sleep(poll_interval)
|
|
poll_interval = min(poll_interval * 1.2, 0.25) # Back off to 250ms
|
|
|
|
with open(res_file, encoding="utf-8") as f:
|
|
raw = f.read()
|
|
|
|
# Clean up response file
|
|
try:
|
|
os.unlink(res_file)
|
|
except OSError:
|
|
pass
|
|
|
|
result = json.loads(raw)
|
|
if isinstance(result, str):
|
|
try:
|
|
return json.loads(result)
|
|
except (json.JSONDecodeError, TypeError):
|
|
return result
|
|
return result
|
|
|
|
'''
|
|
|
|
|
|
# ---- Remote execution support (file-based RPC via terminal backend) ----
|
|
|
|
def _get_or_create_env(task_id: str):
|
|
"""``(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_backends import _container_config_from_config, _create_environment, _ssh_config_from_config
|
|
from tools.terminal_tool import (
|
|
_active_environments, _env_lock, _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,
|
|
)
|
|
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):
|
|
# Shared shaper: execute_code's own key subset dropped docker_extra_args / docker_forward_env /
|
|
# docker_env, so a sandbox created from this path lost the operator's configured settings.
|
|
container_config = _container_config_from_config(config)
|
|
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"],
|
|
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),
|
|
)
|
|
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])
|
|
return env, env_type
|
|
|
|
|
|
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; 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)
|
|
|
|
|
|
def _env_temp_dir(env: Any) -> str:
|
|
"""Return a writable temp dir for env-backed execute_code sandboxes."""
|
|
temp_dir = None
|
|
get_temp_dir = getattr(env, "get_temp_dir", None)
|
|
if callable(get_temp_dir):
|
|
try:
|
|
temp_dir = get_temp_dir()
|
|
except Exception as exc:
|
|
logger.debug("Could not resolve execute_code env temp dir: %s", exc)
|
|
for candidate in (temp_dir, tempfile.gettempdir()):
|
|
if isinstance(candidate, str) and candidate.startswith("/"):
|
|
return candidate.rstrip("/") or "/"
|
|
return "/tmp"
|
|
|
|
|
|
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:
|
|
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:
|
|
"""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)
|
|
|
|
|
|
def _remote_failure(exc: BaseException, exec_start: float, tool_calls_made: int) -> str:
|
|
duration = round(time.monotonic() - exec_start, 2)
|
|
logger.error("execute_code remote failed after %ss with %d tool calls: %s: %s",
|
|
duration, tool_calls_made, type(exc).__name__, exc, exc_info=True)
|
|
return _error_result(str(exc), tool_calls_made=tool_calls_made, duration=duration)
|
|
|
|
|
|
_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)."""
|
|
stdout_text = kernel_result.get("stdout", "") or ""
|
|
stderr_text = kernel_result.get("stderr", "") or ""
|
|
traceback_text = kernel_result.get("traceback", "") or ""
|
|
if stderr_text or traceback_text:
|
|
# 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
|
|
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":
|
|
_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:
|
|
"""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."""
|
|
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_counter, stop_event, rpc_thread = [0], threading.Event(), None
|
|
try:
|
|
env.execute(f"mkdir -p {quoted_rpc_dir}", cwd="/", timeout=10)
|
|
rpc_token = secrets.token_urlsafe(32)
|
|
_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.
|
|
# See #30882.
|
|
rpc_thread = threading.Thread(
|
|
target=propagate_context_to_thread(_rpc_poll_loop), daemon=True,
|
|
args=(env, f"{sandbox_dir}/rpc", effective_task_id, [], tool_call_counter,
|
|
max_tool_calls, sandbox_tools, stop_event, rpc_token))
|
|
rpc_thread.start()
|
|
env_prefix = (f"HERMES_RPC_DIR={quoted_rpc_dir} HERMES_RPC_TOKEN={shlex.quote(rpc_token)} "
|
|
"PYTHONDONTWRITEBYTECODE=1")
|
|
tz = get_timezone_name() # routed profile's timezone, not the bridged default's
|
|
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)
|
|
stdout_text = script_result.get("output", "") or ""
|
|
exit_code = script_result.get("returncode", -1)
|
|
# Backend exit codes: 124 = timeout wrapper, 130 = SIGINT.
|
|
status = _REMOTE_EXIT_STATUS.get(exit_code, "success")
|
|
except Exception as exc:
|
|
return _remote_failure(exc, exec_start, tool_call_counter[0])
|
|
finally:
|
|
stop_event.set()
|
|
if rpc_thread is not None:
|
|
rpc_thread.join(timeout=5)
|
|
try:
|
|
env.execute(f"rm -rf {quoted_sandbox_dir}", cwd="/", timeout=15)
|
|
except Exception:
|
|
logger.debug("Failed to clean up remote sandbox %s", sandbox_dir)
|
|
result = _remote_result(status, stdout_text, exec_start,
|
|
{"exit_code": exit_code, "tool_calls_made": tool_call_counter[0]})
|
|
if status == "timeout":
|
|
_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",
|
|
result["duration_seconds"], timeout, tool_call_counter[0])
|
|
elif status == "interrupted":
|
|
result["output"] = _format_interrupted_output(result["output"])
|
|
elif exit_code != 0:
|
|
result["status"] = "error"
|
|
result["error"] = f"Script exited with code {exit_code}"
|
|
return json.dumps(result, ensure_ascii=False)
|
|
|
|
|
|
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: 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, max_tool_calls = _cfg.get("timeout", DEFAULT_TIMEOUT), _cfg.get("max_tool_calls", DEFAULT_MAX_TOOL_CALLS)
|
|
sandbox_tools, effective_task_id = _sandbox_tools_for(enabled_tools), 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 _error_result(f"Python 3 is not available in the {env_type} terminal "
|
|
"environment. Install Python to use execute_code with remote backends.")
|
|
# 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:
|
|
# --- Session-kernel path (hermes-agent#96873) ------------------- Same always-on model as
|
|
# local: one persistent kernel per owner, rebuilt on the run-to-completion transport (detached
|
|
# runner + file cell protocol).
|
|
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,
|
|
max_tool_calls=max_tool_calls, reset=bool(reset),
|
|
idle_exit=int(_cfg.get("kernel_idle_timeout", 1800)),
|
|
)
|
|
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)
|
|
|
|
|
|
# ---- Main entry point ----
|
|
|
|
|
|
def execute_code(
|
|
code: str,
|
|
task_id: Optional[str] = None,
|
|
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. "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."""
|
|
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.
|
|
try:
|
|
# See #68559.
|
|
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)")
|
|
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.")
|
|
# 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, mirroring the terminal_tool guard (#68289): without this,
|
|
# execute_code is a straight bypass — the terminal() path refuses `launchctl bootout ai.hermes.gateway`,
|
|
# but the identical command inside `os.system(...)` / `subprocess.run([...])` here sailed through and
|
|
# SIGTERM'd the gateway mid-task.
|
|
from tools.process_registry import _is_supervised_gateway_process
|
|
if _is_supervised_gateway_process():
|
|
from cron.lifecycle_guard import contains_gateway_lifecycle_command
|
|
if contains_gateway_lifecycle_command(code):
|
|
return tool_error(
|
|
"Blocked: cannot restart or stop the gateway from inside the "
|
|
"gateway process. The gateway would kill this script before "
|
|
"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 gets no container fast-path.
|
|
# See #30882.
|
|
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 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 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),
|
|
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,
|
|
)
|
|
|
|
|
|
def _kill_process_group(proc, escalate: bool = False):
|
|
"""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
|
|
kill_process_tree(proc.pid, sig=sig)
|
|
except Exception as e:
|
|
logger.debug("Could not terminate process tree: %s", e, exc_info=True)
|
|
try:
|
|
proc.kill()
|
|
except Exception as e2:
|
|
logger.debug("Could not kill process: %s", e2, exc_info=True)
|
|
_tree_signal(getattr(_signal, "SIGTERM", None))
|
|
if escalate:
|
|
try:
|
|
proc.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
_tree_signal(getattr(_signal, "SIGKILL", None))
|
|
|
|
|
|
def _load_config() -> dict:
|
|
"""Effective ``code_execution`` section (defaults + user file + managed overlay) — runs while the
|
|
module-level schema is built at tool discovery, so it must not import ``cli``."""
|
|
try:
|
|
from hermes_cli.config import load_config_readonly
|
|
cfg = load_config_readonly().get("code_execution", {})
|
|
return cfg if isinstance(cfg, dict) else {}
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
# ---- 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 kernel_mode config key is ignored.
|
|
EXECUTION_MODES = ("project", "strict")
|
|
DEFAULT_EXECUTION_MODE = "project"
|
|
|
|
|
|
def _get_execution_mode() -> str:
|
|
"""``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
|
|
logger.warning(
|
|
"Ignoring code_execution.mode=%r (expected one of %s), falling back to %r",
|
|
cfg_value, EXECUTION_MODES, DEFAULT_EXECUTION_MODE,
|
|
)
|
|
return DEFAULT_EXECUTION_MODE
|
|
|
|
|
|
# ---- OpenAI Function-Calling Schema ----
|
|
|
|
# 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"
|
|
" Returns {\"data\": {\"web\": [{\"url\", \"title\", \"description\"}, ...]}}"),
|
|
("web_extract", " web_extract(urls: list[str], char_limit: int = None) -> dict\n"
|
|
" Returns {\"results\": [{\"url\", \"title\", \"content\", \"error\"}, ...]} where content is markdown.\n"
|
|
" No LLM summarization. Pages over char_limit (default 15000) are head+tail truncated; full text stored on disk (path in the content footer)."),
|
|
("read_file", " 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."),
|
|
("search_files", " search_files(pattern: str, target=\"content\", path=\".\", file_glob=None, limit=50, order=\"discovery\") -> dict\n"
|
|
" target: \"content\" (search inside files) or \"files\" (find files by name). Returns {\"matches\": [...]}"),
|
|
("patch", " patch(path: str, old_string: str, new_string: str, replace_all: bool = False) -> dict\n"
|
|
" Replaces old_string with new_string in the file."),
|
|
("terminal", " terminal(command: str, timeout=None, workdir=None) -> dict\n"
|
|
" Foreground only (no background/pty). Returns {\"output\": \"...\", \"exit_code\": N}"),
|
|
]
|
|
|
|
|
|
def build_execute_code_schema(enabled_sandbox_tools: set = None,
|
|
mode: str = None) -> dict:
|
|
"""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]
|
|
import_examples = import_examples or 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 — pass "
|
|
"absolute paths for any file that lives outside the session's "
|
|
"working directory, or use terminal()/read_file() to reach it."
|
|
)
|
|
else:
|
|
cwd_note = (
|
|
"Scripts run in the session's working directory. Interpreter: "
|
|
"the project's activated venv/conda python when one is active "
|
|
"(VIRTUAL_ENV/CONDA_PREFIX — matches terminal()); otherwise "
|
|
"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.
|
|
# Session kernels are always on (kernel_mode retired in #96787): persistence is part of the tool's one
|
|
# description, not a bolt-on paragraph behind a dead conditional.
|
|
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"
|
|
"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"
|
|
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"
|
|
f"{cwd_note}\n\n"
|
|
"Helpers require imports: `from hermes_tools import json_parse, shell_quote, retry`. "
|
|
"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."
|
|
)
|
|
return {
|
|
"name": "execute_code",
|
|
"description": description,
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"code": {"type": "string", "description": (
|
|
"Python code to execute. Import tools with "
|
|
f"`from hermes_tools import {import_str}` "
|
|
"and print your final result to stdout.")},
|
|
"reset": {"type": "boolean", "description": (
|
|
"Discard the kernel's persistent state and start fresh before running this code.")},
|
|
},
|
|
"required": ["code"],
|
|
},
|
|
}
|
|
|
|
|
|
# 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``."""
|
|
if "code" not in args and "command" in args:
|
|
logger.warning("execute_code received 'command' instead of the required 'code' argument")
|
|
return tool_error("execute_code received a 'command' parameter, but it requires "
|
|
"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=\"...\").")
|
|
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="🐍",
|
|
max_result_size_chars=100_000,
|
|
)
|