{"repo":"NousResearch/hermes-agent","public_id":"13142","display_label":"execute_code RPC directory handling on Docker/remote backends","summary":"The members center on running execute_code in remote terminal backends, especially Docker, where HERMES_RPC_DIR is reportedly not passed into the container, leading to tool_calls_made showing 0. Several items propose forwarding that variable to sandbox containers or through exec env; one is a broader feature for remote execution.","subsystem":"environments","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":5088,"merged_at":"2026-04-04T19:57:50+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-04-04T19:57:50+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":13142,"pr_number":5088,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-04T19:57:50+00:00"}],"members":[{"kind":"issue","number":13142,"title":"[Bug]: execute_code silently returns tool_calls_made: 0 on Docker backend — HERMES_RPC_DIR not set in docker run","url":null,"state":"closed"},{"kind":"pr","number":5088,"title":"feat: execute_code runs on remote terminal backends","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tools/code_execution_tool.py","tools/environments/base.py","tools/environments/persistent_shell.py"],"fix_hunks":[{"file":"tools/code_execution_tool.py","hunks":["@@ -5,18 +5,30 @@\n Lets the LLM write a Python script that calls Hermes tools via RPC,\n collapsing multi-step tool chains into a single inference turn.\n \n-Architecture:\n-  1. Parent generates a `hermes_tools.py` stub module with RPC functions\n+Architecture (two transports):\n+\n+  **Local backend (UDS):**\n+  1. Parent generates a `hermes_tools.py` stub module with UDS RPC functions\n   2. Parent opens a Unix domain socket and starts an RPC listener thread\n   3. Parent spawns a child process that runs the LLM's script\n-  4. When the script calls a tool function, the call travels over the UDS\n-     back to the parent, which dispatches through handle_function_call\n-  5. Only the script's stdout is returned to the LLM; intermediate tool\n-     results never enter the context window\n+  4. Tool calls travel over the UDS back to the parent for dispatch\n+\n+  **Remote backends (file-based RPC):**\n+  1. Parent generates `hermes_tools.py` with file-based RPC stubs\n+  2. Parent ships both files to the remote environment\n+  3. Script runs inside the terminal backend (Docker/SSH/Modal/Daytona/etc.)\n+  4. Tool calls are written as request files; a polling thread on the parent\n+     reads them via execute_oneshot(), dispatches, and writes response files\n+  5. The script polls for response files and continues\n+\n+In both cases, only the script's stdout is returned to the LLM; intermediate\n+tool results never enter the context window.\n \n-Platform: Linux / macOS only (Unix domain sockets). Disabled on Windows.\n+Platform: Linux / macOS only (Unix domain sockets for local). Disabled on Windows.\n+Remote execution additionally requires Python 3 in the terminal backend.\n \"\"\"\n \n+import base64\n import json\n import logging\n import os","@@ -114,11 +126,17 @@ def check_sandbox_requirements() -> bool:\n }\n \n \n-def generate_hermes_tools_module(enabled_tools: List[str]) -> str:\n+def generate_hermes_tools_module(enabled_tools: List[str],\n+                                 transport: str = \"uds\") -> str:\n     \"\"\"\n     Build the source code for the hermes_tools.py stub module.\n \n     Only tools in both SANDBOX_ALLOWED_TOOLS and enabled_tools get stubs.\n+\n+    Args:\n+        enabled_tools: Tool names enabled in the current session.\n+        transport: ``\"uds\"`` for Unix domain socket (local backend) or\n+                   ``\"file\"`` for file-based RPC (remote backends).\n     \"\"\"\n     tools_to_generate = sorted(SANDBOX_ALLOWED_TOOLS & set(enabled_tools))\n ","@@ -135,12 +153,17 @@ def generate_hermes_tools_module(enabled_tools: List[str]) -> str:\n         )\n         export_names.append(func_name)\n \n-    header = '''\\\n-\"\"\"Auto-generated Hermes tools RPC stubs.\"\"\"\n-import json, os, socket, shlex, time\n+    if transport == \"file\":\n+        header = _FILE_TRANSPORT_HEADER\n+    else:\n+        header = _UDS_TRANSPORT_HEADER\n+\n+    return header + \"\\n\".join(stub_functions)\n \n-_sock = None\n \n+# ---- Shared helpers section (embedded in both transport headers) ----------\n+\n+_COMMON_HELPERS = '''\\\n \n # ---------------------------------------------------------------------------\n # Convenience helpers (avoid common scripting pitfalls)","@@ -176,6 +199,17 @@ def retry(fn, max_attempts=3, delay=2):\n                 time.sleep(delay * (2 ** attempt))\n     raise last_err\n \n+'''\n+\n+# ---- UDS transport (local backend) ---------------------------------------\n+\n+_UDS_TRANSPORT_HEADER = '''\\\n+\"\"\"Auto-generated Hermes tools RPC stubs.\"\"\"\n+import json, os, socket, shlex, time\n+\n+_sock = None\n+''' + _COMMON_HELPERS + '''\\\n+\n def _connect():\n     global _sock\n     if _sock is None:","@@ -208,7 +242,57 @@ def _call(tool_name, args):\n \n '''\n \n-    return header + \"\\n\".join(stub_functions)\n+# ---- File-based transport (remote backends) -------------------------------\n+\n+_FILE_TRANSPORT_HEADER = '''\\\n+\"\"\"Auto-generated Hermes tools RPC stubs (file-based transport).\"\"\"\n+import json, os, shlex, time\n+\n+_RPC_DIR = os.environ.get(\"HERMES_RPC_DIR\", \"/tmp/hermes_rpc\")\n+_seq = 0\n+''' + _COMMON_HELPERS + '''\\\n+\n+def _call(tool_name, args):\n+    \"\"\"Send a tool call request via file-based RPC and wait for response.\"\"\"\n+    global _seq\n+    _seq += 1\n+    seq_str = f\"{_seq:06d}\"\n+    req_file = os.path.join(_RPC_DIR, f\"req_{seq_str}\")\n+    res_file = os.path.join(_RPC_DIR, f\"res_{seq_str}\")\n+\n+    # Write request atomically (write to .tmp, then rename)\n+    tmp = req_file + \".tmp\"\n+    with open(tmp, \"w\") as f:\n+        json.dump({\"tool\": tool_name, \"args\": args, \"seq\": _seq}, f)\n+    os.rename(tmp, req_file)\n+\n+    # Wait for response with adaptive polling\n+    deadline = time.monotonic() + 300  # 5-minute timeout per tool call\n+    poll_interval = 0.05  # Start at 50ms\n+    while not os.path.exists(res_file):\n+        if time.monotonic() > deadline:\n+            raise RuntimeError(f\"RPC timeout: no response for {tool_name} after 300s\")\n+        time.sleep(poll_interval)\n+        poll_interval = min(poll_interval * 1.2, 0.25)  # Back off to 250ms\n+\n+    with open(res_file) as f:\n+        raw = f.read()\n+\n+    # Clean up response file\n+    try:\n+        os.unlink(res_file)\n+    except OSError:\n+        pass\n+\n+    result = json.loads(raw)\n+    if isinstance(result, str):\n+        try:\n+            return json.loads(result)\n+        except (json.JSONDecodeError, TypeError):\n+            return result\n+    return result\n+\n+'''\n \n \n # ---------------------------------------------------------------------------","@@ -339,6 +423,443 @@ def _rpc_server_loop(\n                 logger.debug(\"RPC conn close error: %s\", e)\n \n \n+# ---------------------------------------------------------------------------\n+# Remote execution support (file-based RPC via terminal backend)\n+# ---------------------------------------------------------------------------\n+\n+def _get_or_create_env(task_id: str):\n+    \"\"\"Get or create the terminal environment for *task_id*.\n+\n+    Reuses the same environment (container/sandbox/SSH session) that the\n+    terminal and file tools use, creating one if it doesn't exist yet.\n+    Returns ``(env, env_type)`` tuple.\n+    \"\"\"\n+    from tools.terminal_tool import (\n+        _active_environments, _env_lock, _create_environment,\n+        _get_env_config, _last_activity, _start_cleanup_thread,\n+        _creation_locks, _creation_locks_lock, _task_env_overrides,\n+    )\n+\n+    effective_task_id = task_id or \"default\"\n+\n+    # Fast path: environment already exists\n+    with _env_lock:\n+        if effective_task_id in _active_environments:\n+            _last_activity[effective_task_id] = time.time()\n+            return _active_environments[effective_task_id], _get_env_config()[\"env_type\"]\n+\n+    # Slow path: create environment (same pattern as file_tools._get_file_ops)\n+    with _creation_locks_lock:\n+        if effective_task_id not in _creation_locks:\n+            _creation_locks[effective_task_id] = threading.Lock()\n+        task_lock = _creation_locks[effective_task_id]\n+\n+    with task_lock:\n+        with _env_lock:\n+            if effective_task_id in _active_environments:\n+                _last_activity[effective_task_id] = time.time()\n+                return _active_environments[effective_task_id], _get_env_config()[\"env_type\"]\n+\n+        config = _get_env_config()\n+        env_type = config[\"env_type\"]\n+        overrides = _task_env_overrides.get(effective_task_id, {})\n+\n+        if env_type == \"docker\":\n+            image = overrides.get(\"docker_image\") or config[\"docker_image\"]\n+        elif env_type == \"singularity\":\n+            image = overrides.get(\"singularity_image\") or config[\"singularity_image\"]\n+        elif env_type == \"modal\":\n+            image = overrides.get(\"modal_image\") or config[\"modal_image\"]\n+        elif env_type == \"daytona\":\n+            image = overrides.get(\"daytona_image\") or config[\"daytona_image\"]\n+        else:\n+            image = \"\"\n+\n+        cwd = overrides.get(\"cwd\") or config[\"cwd\"]\n+\n+        container_config = None\n+        if env_type in (\"docker\", \"singularity\", \"modal\", \"daytona\"):\n+            container_config = {\n+                \"container_cpu\": config.get(\"container_cpu\", 1),\n+                \"container_memory\": config.get(\"container_memory\", 5120),\n+                \"container_disk\": config.get(\"container_disk\", 51200),\n+                \"container_persistent\": config.get(\"container_persistent\", True),\n+                \"docker_volumes\": config.get(\"docker_volumes\", []),\n+            }\n+\n+        ssh_config = None\n+        if env_type == \"ssh\":\n+            ssh_config = {\n+                \"host\": config.get(\"ssh_host\", \"\"),\n+                \"user\": config.get(\"ssh_user\", \"\"),\n+                \"port\": config.get(\"ssh_port\", 22),\n+                \"key\": config.get(\"ssh_key\", \"\"),\n+                \"persistent\": config.get(\"ssh_persistent\", False),\n+            }\n+\n+        local_config = None\n+        if env_type == \"local\":\n+            local_config = {\n+                \"persistent\": config.get(\"local_persistent\", False),\n+            }\n+\n+        logger.info(\"Creating new %s environment for execute_code task %s...\",\n+                     env_type, effective_task_id[:8])\n+        env = _create_environment(\n+            env_type=env_type,\n+            image=image,\n+            cwd=cwd,\n+            timeout=config[\"timeout\"],\n+            ssh_config=ssh_config,\n+            container_config=container_config,\n+            local_config=local_config,\n+            task_id=effective_task_id,\n+            host_cwd=config.get(\"host_cwd\"),\n+        )\n+\n+        with _env_lock:\n+            _active_environments[effective_task_id] = env\n+            _last_activity[effective_task_id] = time.time()\n+\n+        _start_cleanup_thread()\n+        logger.info(\"%s environment ready for execute_code task %s\",\n+                     env_type, effective_task_id[:8])\n+        return env, env_type\n+\n+\n+def _ship_file_to_remote(env, remote_path: str, content: str) -> None:\n+    \"\"\"Write *content* to *remote_path* on the remote environment.\n+\n+    Uses ``echo … | base64 -d`` rather than stdin piping because some\n+    backends (Modal) don't reliably deliver stdin_data to chained\n+    commands.  Base64 output is shell-safe ([A-Za-z0-9+/=]) so single\n+    quotes are fine.\n+    \"\"\"\n+    encoded = base64.b64encode(content.encode(\"utf-8\")).decode(\"ascii\")\n+    env.execute_oneshot(\n+        f\"echo '{encoded}' | base64 -d > {remote_path}\",\n+        cwd=\"/\",\n+        timeout=30,\n+    )\n+\n+\n+def _rpc_poll_loop(\n+    env,\n+    rpc_dir: str,\n+    task_id: str,\n+    tool_call_log: list,\n+    tool_call_counter: list,\n+    max_tool_calls: int,\n+    allowed_tools: frozenset,\n+    stop_event: threading.Event,\n+):\n+    \"\"\"Poll the remote filesystem for tool call requests and dispatch them.\n+\n+    Runs in a background thread.  Uses ``env.execute_oneshot()`` so it can\n+    operate concurrently with the script-execution thread that holds\n+    ``env.execute()`` (important for persistent-shell backends like SSH).\n+    \"\"\"\n+    from model_tools import handle_function_call\n+\n+    poll_interval = 0.1  # 100 ms\n+\n+    while not stop_event.is_set():\n+        try:\n+            # List pending request files (skip .tmp partials)\n+            ls_result = env.execute_oneshot(\n+                f\"ls -1 {rpc_dir}/req_* 2>/dev/null || true\",\n+                cwd=\"/\",\n+                timeout=10,\n+            )\n+            output = ls_result.get(\"output\", \"\").strip()\n+            if not output:\n+                stop_event.wait(poll_interval)\n+                continue\n+\n+            req_files = sorted([\n+                f.strip() for f in output.split(\"\\n\")\n+                if f.strip()\n+                and not f.strip().endswith(\".tmp\")\n+                and \"/req_\" in f.strip()\n+            ])\n+\n+            for req_file in req_files:\n+                if stop_event.is_set():\n+                    break\n+\n+                call_start = time.monotonic()\n+\n+                # Read request\n+                read_result = env.execute_oneshot(\n+                    f\"cat {req_file}\",\n+                    cwd=\"/\",\n+                    timeout=10,\n+                )\n+                try:\n+                    request = json.loads(read_result.get(\"output\", \"\"))\n+                except (json.JSONDecodeError, ValueError):\n+                    logger.debug(\"Malformed RPC request in %s\", req_file)\n+                    # Remove bad request to avoid infinite retry\n+                    env.execute_oneshot(f\"rm -f {req_file}\", cwd=\"/\", timeout=5)\n+                    continue\n+\n+                tool_name = request.get(\"tool\", \"\")\n+                tool_args = request.get(\"args\", {})\n+                seq = request.get(\"seq\", 0)\n+                seq_str = f\"{seq:06d}\"\n+                res_file = f\"{rpc_dir}/res_{seq_str}\"\n+\n+                # Enforce allow-list\n+                if tool_name not in allowed_tools:\n+                    available = \", \".join(sorted(allowed_tools))\n+                    tool_result = json.dumps({\n+                        \"error\": (\n+                            f\"Tool '{tool_name}' is not available in execute_code. \"\n+                            f\"Available: {available}\"\n+                        )\n+                    })\n+                # Enforce tool call limit\n+                elif tool_call_counter[0] >= max_tool_calls:\n+                    tool_result = json.dumps({\n+                        \"error\": (\n+                            f\"Tool call limit reached ({max_tool_calls}). \"\n+                            \"No more tool calls allowed in this execution.\"\n+                        )\n+                    })\n+                else:\n+                    # Strip forbidden terminal parameters\n+                    if tool_name == \"terminal\" and isinstance(tool_args, dict):\n+                        for param in _TERMINAL_BLOCKED_PARAMS:\n+                            tool_args.pop(param, None)\n+\n+                    # Dispatch through the standard tool handler\n+                    try:\n+                        _real_stdout, _real_stderr = sys.stdout, sys.stderr\n+                        devnull = open(os.devnull, \"w\")\n+                        try:\n+                            sys.stdout = devnull\n+                            sys.stderr = devnull\n+                            tool_result = handle_function_call(\n+                                tool_name, tool_args, task_id=task_id\n+                            )\n+                        finally:\n+                            sys.stdout, sys.stderr = _real_stdout, _real_stderr\n+                            devnull.close()\n+                    except Exception as exc:\n+                        logger.error(\"Tool call failed in remote sandbox: %s\",\n+                                     exc, exc_info=True)\n+                        tool_result = json.dumps({\"error\": str(exc)})\n+\n+                    tool_call_counter[0] += 1\n+                    call_duration = time.monotonic() - call_start\n+                    tool_call_log.append({\n+                        \"tool\": tool_name,\n+                        \"args_preview\": str(tool_args)[:80],\n+                        \"duration\": round(call_duration, 2),\n+                    })\n+\n+                # Write response atomically (tmp + rename).\n+                # Use echo piping (not stdin_data) because Modal doesn't\n+                # reliably deliver stdin to chained commands.\n+                encoded_result = base64.b64encode(\n+                    tool_result.encode(\"utf-8\")\n+                ).decode(\"ascii\")\n+                env.execute_oneshot(\n+                    f\"echo '{encoded_result}' | base64 -d > {res_file}.tmp\"\n+                    f\" && mv {res_file}.tmp {res_file}\",\n+                    cwd=\"/\",\n+                    timeout=60,\n+                )\n+\n+                # Remove the request file\n+                env.execute_oneshot(f\"rm -f {req_file}\", cwd=\"/\", timeout=5)\n+\n+        except Exception as e:\n+            if not stop_event.is_set():\n+                logger.debug(\"RPC poll error: %s\", e, exc_info=True)\n+\n+        if not stop_event.is_set():\n+            stop_event.wait(poll_interval)\n+\n+\n+def _execute_remote(\n+    code: str,\n+    task_id: Optional[str],\n+    enabled_tools: Optional[List[str]],\n+) -> str:\n+    \"\"\"Run a script on the remote terminal backend via file-based RPC.\n+\n+    The script and the generated hermes_tools.py module are shipped to\n+    the remote environment, and tool calls are proxied through a polling\n+    thread that communicates via request/response files.\n+    \"\"\"\n+    from tools.terminal_tool import _interrupt_event\n+\n+    _cfg = _load_config()\n+    timeout = _cfg.get(\"timeout\", DEFAULT_TIMEOUT)\n+    max_tool_calls = _cfg.get(\"max_tool_calls\", DEFAULT_MAX_TOOL_CALLS)\n+\n+    session_tools = set(enabled_tools) if enabled_tools else set()\n+    sandbox_tools = frozenset(SANDBOX_ALLOWED_TOOLS & session_tools)\n+    if not sandbox_tools:\n+        sandbox_tools = SANDBOX_ALLOWED_TOOLS\n+\n+    effective_task_id = task_id or \"default\"\n+    env, env_type = _get_or_create_env(effective_task_id)\n+\n+    sandbox_id = uuid.uuid4().hex[:12]\n+    sandbox_dir = f\"/tmp/hermes_exec_{sandbox_id}\"\n+\n+    tool_call_log: list = []\n+    tool_call_counter = [0]\n+    exec_start = time.monotonic()\n+    stop_event = threading.Event()\n+    rpc_thread = None\n+\n+    try:\n+        # Verify Python is available on the remote\n+        py_check = env.execute_oneshot(\n+            \"command -v python3 >/dev/null 2>&1 && echo OK\",\n+            cwd=\"/\", timeout=15,\n+        )\n+        if \"OK\" not in py_check.get(\"output\", \"\"):\n+            return json.dumps({\n+                \"status\": \"error\",\n+                \"error\": (\n+                    f\"Python 3 is not available in the {env_type} terminal \"\n+                    \"environment. Install Python to use execute_code with \"\n+                    \"remote backends.\"\n+                ),\n+                \"tool_calls_made\": 0,\n+                \"duration_seconds\": 0,\n+            })\n+\n+        # Create sandbox directory on remote\n+        env.execute_oneshot(\n+            f\"mkdir -p {sandbox_dir}/rpc\", cwd=\"/\", timeout=10,\n+        )\n+\n+        # Generate and ship files\n+        tools_src = generate_hermes_tools_module(\n+            list(sandbox_tools), transport=\"file\",\n+        )\n+        _ship_file_to_remote(env, f\"{sandbox_dir}/hermes_tools.py\", tools_src)\n+        _ship_file_to_remote(env, f\"{sandbox_dir}/script.py\", code)\n+\n+        # Start RPC polling thread\n+        rpc_thread = threading.Thread(\n+            target=_rpc_poll_loop,\n+            args=(\n+                env, f\"{sandbox_dir}/rpc\", effective_task_id,\n+                tool_call_log, tool_call_counter, max_tool_calls,\n+                sandbox_tools, stop_event,\n+            ),\n+            daemon=True,\n+        )\n+        rpc_thread.start()\n+\n+        # Build environment variable prefix for the script\n+        env_prefix = (\n+            f\"HERMES_RPC_DIR={sandbox_dir}/rpc \"\n+            f\"PYTHONDONTWRITEBYTECODE=1\"\n+        )\n+        tz = os.getenv(\"HERMES_TIMEZONE\", \"\").strip()\n+        if tz:\n+            env_prefix += f\" TZ={tz}\"\n+\n+        # Execute the script on the remote backend\n+        logger.info(\"Executing code on %s backend (task %s)...\",\n+                     env_type, effective_task_id[:8])\n+        script_result = env.execute(\n+            f\"cd {sandbox_dir} && {env_prefix} python3 script.py\",\n+            timeout=timeout,\n+        )\n+\n+        stdout_text = script_result.get(\"output\", \"\")\n+        exit_code = script_result.get(\"returncode\", -1)\n+        status = \"success\"\n+\n+        # Check for timeout/interrupt from the backend\n+        if exit_code == 124:\n+            status = \"timeout\"\n+        elif exit_code == 130:\n+            status = \"interrupted\"\n+\n+    except Exception as exc:\n+        duration = round(time.monotonic() - exec_start, 2)\n+        logger.error(\n+            \"execute_code remote failed after %ss with %d tool calls: %s: %s\",\n+            duration, tool_call_counter[0], type(exc).__name__, exc,\n+            exc_info=True,\n+        )\n+        return json.dumps({\n+            \"status\": \"error\",\n+            \"error\": str(exc),\n+            \"tool_calls_made\": tool_call_counter[0],\n+            \"duration_seconds\": duration,\n+        }, ensure_ascii=False)\n+\n+    finally:\n+        # Stop the polling thread\n+        stop_event.set()\n+        if rpc_thread is not None:\n+            rpc_thread.join(timeout=5)\n+\n+        # Clean up remote sandbox dir\n+        try:\n+            env.execute_oneshot(\n+                f\"rm -rf {sandbox_dir}\", cwd=\"/\", timeout=15,\n+            )\n+        except Exception:\n+            logger.debug(\"Failed to clean up remote sandbox %s\", sandbox_dir)\n+\n+    duration = round(time.monotonic() - exec_start, 2)\n+\n+    # --- Post-process output (same as local path) ---\n+\n+    # Truncate stdout to cap\n+    if len(stdout_text) > MAX_STDOUT_BYTES:\n+        head_bytes = int(MAX_STDOUT_BYTES * 0.4)\n+        tail_bytes = MAX_STDOUT_BYTES - head_bytes\n+        head = stdout_text[:head_bytes]\n+        tail = stdout_text[-tail_bytes:]\n+        omitted = len(stdout_text) - len(head) - len(tail)\n+        stdout_text = (\n+            head\n+            + f\"\\n\\n... [OUTPUT TRUNCATED - {omitted:,} chars omitted \"\n+            f\"out of {len(stdout_text):,} total] ...\\n\\n\"\n+            + tail\n+        )\n+\n+    # Strip ANSI escape sequences\n+    from tools.ansi_strip import strip_ansi\n+    stdout_text = strip_ansi(stdout_text)\n+\n+    # Redact secrets\n+    from agent.redact import redact_sensitive_text\n+    stdout_text = redact_sensitive_text(stdout_text)\n+\n+    # Build response\n+    result: Dict[str, Any] = {\n+        \"status\": status,\n+        \"output\": stdout_text,\n+        \"tool_calls_made\": tool_call_counter[0],\n+        \"duration_seconds\": duration,\n+    }\n+\n+    if status == \"timeout\":\n+        result[\"error\"] = f\"Script timed out after {timeout}s and was killed.\"\n+    elif status == \"interrupted\":\n+        result[\"output\"] = (\n+            stdout_text + \"\\n[execution interrupted — user sent a new message]\"\n+        )\n+    elif exit_code != 0:\n+        result[\"status\"] = \"error\"\n+        result[\"error\"] = f\"Script exited with code {exit_code}\"\n+\n+    return json.dumps(result, ensure_ascii=False)\n+\n+\n # ---------------------------------------------------------------------------\n # Main entry point\n # ---------------------------------------------------------------------------","@@ -352,6 +873,9 @@ def execute_code(\n     Run a Python script in a sandboxed child process with RPC access\n     to a subset of Hermes tools.\n \n+    Dispatches to the local (UDS) or remote (file-based RPC) path\n+    depending on the configured terminal backend.\n+\n     Args:\n         code:          Python source code to execute.\n         task_id:       Session task ID for tool isolation (terminal env, etc.).","@@ -369,6 +893,14 @@ def execute_code(\n     if not code or not code.strip():\n         return json.dumps({\"error\": \"No code provided.\"})\n \n+    # Dispatch: remote backends use file-based RPC, local uses UDS\n+    from tools.terminal_tool import _get_env_config\n+    env_type = _get_env_config()[\"env_type\"]\n+    if env_type != \"local\":\n+        return _execute_remote(code, task_id, enabled_tools)\n+\n+    # --- Local execution path (UDS) --- below this line is unchanged ---\n+\n     # Import interrupt event from terminal_tool (cooperative cancellation)\n     from tools.terminal_tool import _interrupt_event\n "]},{"file":"tools/environments/base.py","hunks":["@@ -91,6 +91,19 @@ def _build_run_kwargs(self, timeout: int | None,\n             kw[\"stdin\"] = subprocess.DEVNULL\n         return kw\n \n+    def execute_oneshot(self, command: str, cwd: str = \"\", *,\n+                        timeout: int | None = None,\n+                        stdin_data: str | None = None) -> dict:\n+        \"\"\"Execute a command bypassing any persistent shell.\n+\n+        Safe for concurrent use alongside a long-running execute() call.\n+        Backends that maintain a persistent shell (SSH, Local) override this\n+        to route through their oneshot path, avoiding the shell lock.\n+        Non-persistent backends delegate to execute().\n+        \"\"\"\n+        return self.execute(command, cwd=cwd, timeout=timeout,\n+                            stdin_data=stdin_data)\n+\n     def _timeout_result(self, timeout: int | None) -> dict:\n         \"\"\"Standard return dict when a command times out.\"\"\"\n         return {"]},{"file":"tools/environments/persistent_shell.py","hunks":["@@ -141,6 +141,19 @@ def execute(self, command: str, cwd: str = \"\", *,\n             command, cwd, timeout=timeout, stdin_data=stdin_data,\n         )\n \n+    def execute_oneshot(self, command: str, cwd: str = \"\", *,\n+                        timeout: int | None = None,\n+                        stdin_data: str | None = None) -> dict:\n+        \"\"\"Always use the oneshot (non-persistent) execution path.\n+\n+        This bypasses _shell_lock so it can run concurrently with a\n+        long-running command in the persistent shell — used by\n+        execute_code's file-based RPC polling thread.\n+        \"\"\"\n+        return self._execute_oneshot(\n+            command, cwd, timeout=timeout, stdin_data=stdin_data,\n+        )\n+\n     def cleanup(self):\n         if self.persistent:\n             self._cleanup_persistent_shell()\n"]}]},{"kind":"pr","number":13342,"title":"fix(docker): pass RPC dir through exec env","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"569e9f96702dfc505501f95d2f0c29a349029267","number":0},{"kind":"pr","value":"#5088","number":5088},{"kind":"pr","value":"#13537","number":13537}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":13537,"title":"fix(docker): forward HERMES_RPC_DIR to sandbox containers for execute_code RPC","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"569e9f96702dfc505501f95d2f0c29a349029267","number":0},{"kind":"pr","value":"#5088","number":5088},{"kind":"pr","value":"#13342","number":13342}],"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}