{"repo":"NousResearch/hermes-agent","public_id":"64435","display_label":"Bound terminal output to prevent OOM issues","summary":"These issues and fixes address unbounded foreground terminal output that could exhaust memory and freeze the gateway, with changes to cap output collection during streaming.","subsystem":null,"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":64581,"merged_at":"2026-07-15T05:00:29+00:00"},"resolution":{"state":"resolved","by":"pr-64581"},"freshness":{"state":"verified","last_verified_at":"2026-07-15T05:00:29+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":64435,"pr_number":64581,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-15T05:00:29+00:00"}],"members":[{"kind":"issue","number":64435,"title":"[Bug]: Unbounded foreground terminal output can OOM and freeze the gateway before truncation","url":null,"state":"closed"},{"kind":"pr","number":64448,"title":"fix: BoundedOutputCollector prevents OOM from unbounded terminal output (#64435)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#64581","number":64581}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":64524,"title":"fix(terminal): bound foreground output capture","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#64581","number":64581},{"kind":"pr","value":"#64448","number":64448}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":64581,"title":"fix(terminal): bound foreground output capture during streaming (#64435)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_base_environment.py","tests/tools/test_local_background_child_hang.py","tests/tools/test_terminal_output_transform_hook.py","tests/tools/test_terminal_task_cwd.py","tools/environments/base.py","tools/environments/modal_utils.py","tools/terminal_tool.py"],"fix_hunks":[{"file":"tests/tools/test_base_environment.py","hunks":["@@ -6,7 +6,7 @@\n \n from unittest.mock import MagicMock\n \n-from tools.environments.base import BaseEnvironment\n+from tools.environments.base import BaseEnvironment, _BoundedOutputCollector\n \n \n class _TestableEnv(BaseEnvironment):","@@ -22,6 +22,41 @@ def cleanup(self):\n         pass\n \n \n+class TestBoundedOutputCollector:\n+    def test_large_stream_retains_bounded_head_and_tail(self):\n+        collector = _BoundedOutputCollector(1_000)\n+        collector.append(\"HEAD-SENTINEL\\n\")\n+        for _ in range(2_000):\n+            collector.append(\"x\" * 4_096)\n+        collector.append(\"\\nTAIL-SENTINEL\")\n+\n+        rendered = collector.render()\n+\n+        assert collector.total_chars > 8_000_000\n+        assert collector.buffered_chars <= 1_000\n+        assert len(rendered) <= 1_000\n+        assert rendered.startswith(\"HEAD-SENTINEL\")\n+        assert rendered.endswith(\"TAIL-SENTINEL\")\n+        assert \"[OUTPUT TRUNCATED\" in rendered\n+\n+    def test_small_stream_is_unchanged(self):\n+        collector = _BoundedOutputCollector(100)\n+        collector.append(\"hello \")\n+        collector.append(\"world\")\n+\n+        assert collector.render() == \"hello world\"\n+\n+    def test_required_status_suffix_stays_inside_limit(self):\n+        collector = _BoundedOutputCollector(120)\n+        collector.append(\"A\" * 10_000)\n+\n+        rendered = collector.render(suffix=\"\\n[Command timed out after 1s]\")\n+\n+        assert len(rendered) <= 120\n+        assert rendered.endswith(\"[Command timed out after 1s]\")\n+        assert \"[OUTPUT TRUNCATED\" in rendered\n+\n+\n class TestWrapCommand:\n     def test_basic_shape(self):\n         env = _TestableEnv()"]},{"file":"tests/tools/test_local_background_child_hang.py","hunks":["@@ -91,6 +91,69 @@ def test_high_volume_output_complete(self, local_env):\n         assert lines[0] == \"1\"\n         assert lines[-1] == \"3000\"\n \n+    def test_foreground_capture_is_bounded_while_draining(\n+        self, local_env, monkeypatch\n+    ):\n+        monkeypatch.setattr(\"tools.tool_output_limits.get_max_bytes\", lambda: 10_000)\n+        command = (\n+            \"python3 -c \\\"import sys; \"\n+            \"sys.stdout.write('HEAD-SENTINEL\\\\n' + 'x' * 2000000 + \"\n+            \"'\\\\nTAIL-SENTINEL')\\\"\"\n+        )\n+\n+        result = local_env.execute(command, timeout=10, bounded_capture=True)\n+\n+        assert result[\"returncode\"] == 0\n+        assert len(result[\"output\"]) <= 10_000\n+        assert result[\"output\"].startswith(\"HEAD-SENTINEL\")\n+        assert result[\"output\"].endswith(\"TAIL-SENTINEL\")\n+        assert \"[OUTPUT TRUNCATED\" in result[\"output\"]\n+\n+    def test_default_capture_is_full_fidelity_for_internal_consumers(\n+        self, local_env\n+    ):\n+        \"\"\"Default execute() (no bounded_capture) must return complete output.\n+\n+        Internal consumers — file-operation ``cat`` reads that feed the patch\n+        engine, code-execution RPC reads, log reads — rely on full-fidelity\n+        capture. Bounding them at tool_output.max_bytes would CORRUPT files\n+        on read-modify-write (#64435 review finding), so only the foreground\n+        terminal tool opts in via bounded_capture=True.\n+        \"\"\"\n+        # ~200 KB — four times the default 50 KB cap.\n+        command = (\n+            \"python3 -c \\\"import sys; \"\n+            \"sys.stdout.write('START-MARK\\\\n' + ('y' * 200000) + '\\\\nEND-MARK')\\\"\"\n+        )\n+\n+        result = local_env.execute(command, timeout=10)\n+\n+        assert result[\"returncode\"] == 0\n+        assert \"[OUTPUT TRUNCATED\" not in result[\"output\"]\n+        assert result[\"output\"].startswith(\"START-MARK\")\n+        assert result[\"output\"].endswith(\"END-MARK\")\n+        assert len(result[\"output\"]) > 200000\n+\n+    def test_continuous_output_still_honors_foreground_timeout(\n+        self, local_env, monkeypatch\n+    ):\n+        monkeypatch.setattr(\"tools.tool_output_limits.get_max_bytes\", lambda: 5_000)\n+        command = (\n+            \"python3 -c \\\"import sys; \"\n+            \"chunk = 'x' * 4096; \"\n+            \"exec('while True: sys.stdout.write(chunk); sys.stdout.flush()')\\\"\"\n+        )\n+\n+        started = time.monotonic()\n+        result = local_env.execute(command, timeout=1, bounded_capture=True)\n+        elapsed = time.monotonic() - started\n+\n+        assert elapsed < 4.0\n+        assert result[\"returncode\"] == 124\n+        assert len(result[\"output\"]) <= 5_000\n+        assert \"[OUTPUT TRUNCATED\" in result[\"output\"]\n+        assert result[\"output\"].endswith(\"[Command timed out after 1s]\")\n+\n     def test_timeout_path_still_works(self, local_env):\n         \"\"\"Foreground command exceeding timeout must still be killed.\"\"\"\n         t0 = time.monotonic()"]},{"file":"tests/tools/test_terminal_output_transform_hook.py","hunks":["@@ -5,6 +5,7 @@\n \n import hermes_cli.plugins as plugins_mod\n import tools.terminal_tool as terminal_tool_module\n+from tools.environments.local import LocalEnvironment\n \n \n _UNSET = object()","@@ -138,6 +139,61 @@ def test_terminal_output_transform_still_runs_strip_and_redact(monkeypatch, tmp_\n     assert \"abc123def456\" not in result[\"output\"]  # secret body is gone\n \n \n+def test_large_process_output_is_bounded_before_sudo_and_plugin_hooks(\n+    monkeypatch, tmp_path\n+):\n+    limit = 10_000\n+    monkeypatch.setattr(\"tools.tool_output_limits.get_max_bytes\", lambda: limit)\n+    monkeypatch.setattr(\n+        terminal_tool_module, \"_get_env_config\", lambda: _make_env_config(tmp_path)\n+    )\n+    monkeypatch.setattr(terminal_tool_module, \"_start_cleanup_thread\", lambda: None)\n+    monkeypatch.setattr(\n+        terminal_tool_module,\n+        \"_check_all_guards\",\n+        lambda *_args, **_kwargs: {\"approved\": True},\n+    )\n+\n+    sudo_input_lengths = []\n+    hook_inputs = []\n+\n+    def _sudo_spy(output):\n+        sudo_input_lengths.append(len(output))\n+        return False\n+\n+    def _hook_spy(hook_name, **kwargs):\n+        if hook_name == \"transform_terminal_output\":\n+            hook_inputs.append(kwargs[\"output\"])\n+        return []\n+\n+    monkeypatch.setattr(\n+        terminal_tool_module, \"_sudo_wrong_password_failure\", _sudo_spy\n+    )\n+    monkeypatch.setattr(\"hermes_cli.plugins.invoke_hook\", _hook_spy)\n+\n+    env = LocalEnvironment(cwd=str(tmp_path), timeout=10)\n+    monkeypatch.setitem(terminal_tool_module._active_environments, \"default\", env)\n+    monkeypatch.setitem(terminal_tool_module._last_activity, \"default\", 0.0)\n+    try:\n+        command = (\n+            \"python3 -c \\\"import sys; \"\n+            \"sys.stdout.write('HEAD-SENTINEL\\\\n' + 'x' * 2000000 + \"\n+            \"'\\\\nTAIL-SENTINEL')\\\"\"\n+        )\n+        result = json.loads(terminal_tool_module.terminal_tool(command=command))\n+    finally:\n+        env.cleanup()\n+\n+    assert sudo_input_lengths\n+    assert max(sudo_input_lengths) <= limit\n+    assert len(hook_inputs) == 1\n+    assert len(hook_inputs[0]) <= limit\n+    assert hook_inputs[0].startswith(\"HEAD-SENTINEL\")\n+    assert hook_inputs[0].endswith(\"TAIL-SENTINEL\")\n+    assert \"[OUTPUT TRUNCATED\" in hook_inputs[0]\n+    assert len(result[\"output\"]) <= limit\n+\n+\n def test_terminal_output_transform_hook_exception_falls_back(monkeypatch, tmp_path):\n     def _raise(*_args, **_kwargs):\n         raise RuntimeError(\"boom\")"]},{"file":"tests/tools/test_terminal_task_cwd.py","hunks":["@@ -40,7 +40,7 @@ def execute(self, command, **kwargs):\n     result = json.loads(terminal_tool.terminal_tool(command=\"pwd\", task_id=task_id))\n \n     assert result[\"exit_code\"] == 0\n-    assert calls == [(\"pwd\", {\"timeout\": 60, \"cwd\": \"/workspace/acp\"})]\n+    assert calls == [(\"pwd\", {\"timeout\": 60, \"cwd\": \"/workspace/acp\", \"bounded_capture\": True})]\n \n \n def test_explicit_workdir_still_wins_over_registered_task_cwd(monkeypatch):","@@ -73,7 +73,7 @@ def execute(self, command, **kwargs):\n     )\n \n     assert result[\"exit_code\"] == 0\n-    assert calls == [{\"timeout\": 60, \"cwd\": \"/explicit/workdir\"}]\n+    assert calls == [{\"timeout\": 60, \"cwd\": \"/explicit/workdir\", \"bounded_capture\": True}]\n \n \n def test_foreground_command_prefers_live_env_cwd_over_init_time_cwd(monkeypatch):","@@ -104,7 +104,7 @@ def execute(self, command, **kwargs):\n     result = json.loads(terminal_tool.terminal_tool(command=\"pwd\", task_id=task_id))\n \n     assert result[\"exit_code\"] == 0\n-    assert calls == [(\"pwd\", {\"timeout\": 60, \"cwd\": \"/workspace/live\"})]\n+    assert calls == [(\"pwd\", {\"timeout\": 60, \"cwd\": \"/workspace/live\", \"bounded_capture\": True})]\n \n \n def test_background_command_prefers_live_env_cwd_over_init_time_cwd(monkeypatch):","@@ -261,7 +261,7 @@ def execute(self, command, **kwargs):\n     assert result[\"exit_code\"] == 0\n     # The command must run in the config cwd (hermes-agent), NOT the stale\n     # env.cwd left by session A (hermes-desktop-tipc).\n-    assert calls == [(\"pwd\", {\"timeout\": 60, \"cwd\": \"/home/user/src/hermes-agent\"})]\n+    assert calls == [(\"pwd\", {\"timeout\": 60, \"cwd\": \"/home/user/src/hermes-agent\", \"bounded_capture\": True})]\n \n \n def test_same_session_env_cwd_is_trusted_after_first_claim(monkeypatch):","@@ -303,7 +303,7 @@ def execute(self, command, **kwargs):\n     result = json.loads(terminal_tool.terminal_tool(command=\"pwd\", task_id=task_id))\n \n     assert result[\"exit_code\"] == 0\n-    assert calls == [(\"pwd\", {\"timeout\": 60, \"cwd\": \"/workspace/deep\"})]\n+    assert calls == [(\"pwd\", {\"timeout\": 60, \"cwd\": \"/workspace/deep\", \"bounded_capture\": True})]\n \n \n def test_safe_getcwd_returns_real_cwd(monkeypatch):"]},{"file":"tools/environments/base.py","hunks":["@@ -17,6 +17,7 @@\n import time\n import uuid\n from abc import ABC, abstractmethod\n+from collections import deque\n from pathlib import Path\n from typing import IO, Callable, Protocol\n ","@@ -44,6 +45,105 @@\n _activity_callback_local = threading.local()\n \n \n+# Sentinel capacity for full-fidelity capture (internal consumers). Large\n+# enough that the collector never evicts in practice, keeping a single code\n+# path for both bounded and unbounded modes.\n+_UNBOUNDED_CAPTURE_CHARS = 2**63 - 1\n+\n+\n+class _BoundedOutputCollector:\n+    \"\"\"Retain a bounded 40/60 head-tail window of streamed text.\"\"\"\n+    def __init__(self, max_chars: int):\n+        self.max_chars = max(1, int(max_chars))\n+        self._head_limit = int(self.max_chars * 0.4)\n+        self._tail_limit = self.max_chars - self._head_limit\n+        self._head: list[str] = []\n+        self._tail: deque[str] = deque()\n+        self._head_chars = 0\n+        self._tail_chars = 0\n+        self._total_chars = 0\n+        self._lock = threading.Lock()\n+\n+    @property\n+    def buffered_chars(self) -> int:\n+        with self._lock:\n+            return self._head_chars + self._tail_chars\n+\n+    @property\n+    def total_chars(self) -> int:\n+        with self._lock:\n+            return self._total_chars\n+\n+    def append(self, text: str) -> None:\n+        if not text:\n+            return\n+        with self._lock:\n+            text_len = len(text)\n+            self._total_chars += text_len\n+            start = 0\n+\n+            if self._head_chars < self._head_limit:\n+                take = min(self._head_limit - self._head_chars, text_len)\n+                if take:\n+                    self._head.append(text[:take])\n+                    self._head_chars += take\n+                    start = take\n+\n+            remaining = text_len - start\n+            if remaining <= 0 or self._tail_limit <= 0:\n+                return\n+            if remaining >= self._tail_limit:\n+                self._tail.clear()\n+                self._tail.append(text[-self._tail_limit :])\n+                self._tail_chars = self._tail_limit\n+                return\n+\n+            chunk = text[start:]\n+            self._tail.append(chunk)\n+            self._tail_chars += len(chunk)\n+            while self._tail_chars > self._tail_limit:\n+                excess = self._tail_chars - self._tail_limit\n+                first = self._tail[0]\n+                if len(first) <= excess:\n+                    self._tail.popleft()\n+                    self._tail_chars -= len(first)\n+                else:\n+                    self._tail[0] = first[excess:]\n+                    self._tail_chars -= excess\n+\n+    def render(self, *, suffix: str = \"\") -> str:\n+        \"\"\"Render within ``max_chars``, preserving a required status suffix.\"\"\"\n+        with self._lock:\n+            if len(suffix) >= self.max_chars:\n+                return suffix[-self.max_chars :]\n+\n+            head = \"\".join(self._head)\n+            tail = \"\".join(self._tail)\n+            available = self.max_chars - len(suffix)\n+            if self._total_chars <= available:\n+                return head + tail + suffix\n+\n+            notice = \"\"\n+            for _ in range(4):\n+                content_budget = max(0, available - len(notice))\n+                head_chars = int(content_budget * 0.4)\n+                tail_chars = content_budget - head_chars\n+                omitted = max(0, self._total_chars - head_chars - tail_chars)\n+                updated = (\n+                    f\"\\n\\n... [OUTPUT TRUNCATED - {omitted:,} chars omitted \"\n+                    f\"out of {self._total_chars:,} total] ...\\n\\n\"\n+                )\n+                if updated == notice:\n+                    break\n+                notice = updated\n+\n+            content_budget = max(0, available - len(notice))\n+            head_chars = int(content_budget * 0.4)\n+            tail_chars = content_budget - head_chars\n+            rendered_tail = tail[-tail_chars:] if tail_chars else \"\"\n+            return head[:head_chars] + notice[:available] + rendered_tail + suffix\n+\n+\n def set_activity_callback(cb: Callable[[str], None] | None) -> None:\n     \"\"\"Register a callback that _wait_for_process fires periodically.\"\"\"\n     _activity_callback_local.callback = cb","@@ -578,11 +678,21 @@ def _embed_stdin_heredoc(command: str, stdin_data: str) -> str:\n     # Process lifecycle\n     # ------------------------------------------------------------------\n \n-    def _wait_for_process(self, proc: ProcessHandle, timeout: int = 120) -> dict:\n+    def _wait_for_process(\n+        self, proc: ProcessHandle, timeout: int = 120, *, bounded_capture: bool = False\n+    ) -> dict:\n         \"\"\"Poll-based wait with interrupt checking and stdout draining.\n \n         Shared across all backends — not overridden.\n \n+        ``bounded_capture=True`` (foreground terminal-tool path only) retains\n+        at most ``tool_output.max_bytes`` of output in a head/tail window\n+        while draining, so a verbose subprocess cannot OOM the process\n+        (#64435). The default (False) preserves full-fidelity capture for\n+        internal consumers — file-operation ``cat`` reads feeding the patch\n+        engine, code-execution RPC reads, log reads — where truncation would\n+        corrupt data.\n+\n         Fires the ``activity_callback`` (if set on this instance) every 10s\n         while the process is running so the gateway's inactivity timeout\n         doesn't kill long-running commands.","@@ -594,7 +704,19 @@ def _wait_for_process(self, proc: ProcessHandle, timeout: int = 120) -> dict:\n         an orphan with ``PPID=1`` when python is shut down mid-tool — the\n         ``sleep 300``-survives-30-min bug Physikal and I both hit.\n         \"\"\"\n-        output_chunks: list[str] = []\n+        if bounded_capture:\n+            try:\n+                from tools.tool_output_limits import get_max_bytes\n+\n+                capture_limit = get_max_bytes()\n+            except Exception:\n+                capture_limit = 50_000\n+        else:\n+            # Full fidelity: effectively unbounded collector (single head\n+            # segment, no eviction) so behavior matches the historical\n+            # accumulate-everything semantics.\n+            capture_limit = _UNBOUNDED_CAPTURE_CHARS\n+        output = _BoundedOutputCollector(capture_limit)\n \n         # Non-blocking drain via select().\n         #","@@ -635,16 +757,16 @@ def _drain_iterable(stream):\n                     if piece is None:\n                         continue\n                     if isinstance(piece, bytes):\n-                        output_chunks.append(decoder.decode(piece))\n+                        output.append(decoder.decode(piece))\n                     else:\n-                        output_chunks.append(str(piece))\n+                        output.append(str(piece))\n             except Exception:\n                 pass\n             finally:\n                 try:\n                     tail = decoder.decode(b\"\", final=True)\n                     if tail:\n-                        output_chunks.append(tail)\n+                        output.append(tail)\n                 except Exception:\n                     pass\n ","@@ -675,14 +797,14 @@ def _drain():\n                         chunk = os.read(fd, 4096)\n                         if not chunk:\n                             break\n-                        output_chunks.append(decoder.decode(chunk))\n+                        output.append(decoder.decode(chunk))\n                 except (ValueError, OSError):\n                     pass\n                 finally:\n                     try:\n                         tail = decoder.decode(b\"\", final=True)\n                         if tail:\n-                            output_chunks.append(tail)\n+                            output.append(tail)\n                     except Exception:\n                         pass\n                 return","@@ -700,7 +822,7 @@ def _drain():\n                             break\n                         if not chunk:\n                             break  # true EOF — all writers closed\n-                        output_chunks.append(decoder.decode(chunk))\n+                        output.append(decoder.decode(chunk))\n                         idle_after_exit = 0\n                     elif proc.poll() is not None:\n                         # bash is gone and the pipe was idle for ~100ms.  Give","@@ -716,7 +838,7 @@ def _drain():\n                 try:\n                     tail = decoder.decode(b\"\", final=True)\n                     if tail:\n-                        output_chunks.append(tail)\n+                        output.append(tail)\n                 except Exception:\n                     pass\n ","@@ -762,7 +884,7 @@ def _drain():\n                     self._kill_process(proc)\n                     drain_thread.join(timeout=2)\n                     return {\n-                        \"output\": \"\".join(output_chunks) + \"\\n[Command interrupted]\",\n+                        \"output\": output.render(suffix=\"\\n[Command interrupted]\"),\n                         \"returncode\": 130,\n                     }\n                 if time.monotonic() > deadline:","@@ -774,12 +896,11 @@ def _drain():\n                         )\n                     self._kill_process(proc)\n                     drain_thread.join(timeout=2)\n-                    partial = \"\".join(output_chunks)\n                     timeout_msg = f\"\\n[Command timed out after {timeout}s]\"\n                     return {\n-                        \"output\": partial + timeout_msg\n-                        if partial\n-                        else timeout_msg.lstrip(),\n+                        \"output\": output.render(suffix=timeout_msg).lstrip()\n+                        if output.total_chars == 0\n+                        else output.render(suffix=timeout_msg),\n                         \"returncode\": 124,\n                     }\n                 # Periodic activity touch so the gateway knows we're alive","@@ -855,7 +976,7 @@ def _drain():\n                 proc.returncode,\n             )\n \n-        return {\"output\": \"\".join(output_chunks), \"returncode\": proc.returncode}\n+        return {\"output\": output.render(), \"returncode\": proc.returncode}\n \n     def _kill_process(self, proc: ProcessHandle):\n         \"\"\"Terminate a process. Subclasses may override for process-group kill.\"\"\"","@@ -932,8 +1053,19 @@ def execute(\n         timeout: int | None = None,\n         stdin_data: str | None = None,\n         rewrite_compound_background: bool = True,\n+        bounded_capture: bool = False,\n     ) -> dict:\n-        \"\"\"Execute a command, return {\"output\": str, \"returncode\": int}.\"\"\"\n+        \"\"\"Execute a command, return {\"output\": str, \"returncode\": int}.\n+\n+        ``bounded_capture=True`` caps stdout/stderr retention at\n+        ``tool_output.max_bytes`` WHILE the stream is drained (head/tail\n+        window) instead of holding the full output in memory (#64435).\n+        It must only be set by callers whose output is destined for the\n+        model/tool payload (the foreground terminal tool). Internal\n+        full-fidelity consumers — file operations ``cat`` reads that feed\n+        the patch engine, code-execution RPC reads, log reads — MUST leave\n+        it False: truncating those corrupts data, not just display.\n+        \"\"\"\n         self._before_execute()\n \n         exec_command, sudo_stdin = self._prepare_command(command)","@@ -968,7 +1100,9 @@ def execute(\n         proc = self._run_bash(\n             wrapped, login=login, timeout=effective_timeout, stdin_data=effective_stdin\n         )\n-        result = self._wait_for_process(proc, timeout=effective_timeout)\n+        result = self._wait_for_process(\n+            proc, timeout=effective_timeout, bounded_capture=bounded_capture\n+        )\n         self._update_cwd(result)\n \n         return result"]},{"file":"tools/environments/modal_utils.py","hunks":["@@ -80,11 +80,17 @@ def execute(\n         timeout: int | None = None,\n         stdin_data: str | None = None,\n         rewrite_compound_background: bool = True,\n+        bounded_capture: bool = False,\n     ) -> dict:\n         # Managed/remote modal transports execute commands via explicit transport\n         # and do not rely on shell background rewriters. Keep parameter for\n         # compatibility with BaseEnvironment callers.\n         _ = rewrite_compound_background\n+        # bounded_capture: accepted for BaseEnvironment.execute() signature\n+        # parity (the terminal tool passes it). Modal transports return the\n+        # remote function's result in one payload, so streaming-time bounding\n+        # does not apply; the terminal tool's final truncation still caps it.\n+        _ = bounded_capture\n         self._before_execute()\n         prepared = self._prepare_modal_exec(\n             command,"]},{"file":"tools/terminal_tool.py","hunks":["@@ -2657,6 +2657,12 @@ def terminal_tool(\n                     execute_kwargs = {\n                         \"timeout\": effective_timeout,\n                         \"cwd\": command_cwd,\n+                        # Foreground model-facing output: cap retention while\n+                        # streaming (head/tail window) so a verbose command\n+                        # can't OOM the gateway before truncation (#64435).\n+                        # Internal env.execute() consumers (file ops cat\n+                        # reads, RPC reads) intentionally stay unbounded.\n+                        \"bounded_capture\": True,\n                     }\n                     result = env.execute(command, **execute_kwargs)\n                 except Exception as e:","@@ -2708,9 +2714,10 @@ def terminal_tool(\n                         \"command.\"\n                     )\n \n-            # Foreground terminal output canonicalization seam: plugins receive\n-            # the full output string before default truncation and may only\n-            # replace it by returning a string from transform_terminal_output.\n+            # Foreground terminal output canonicalization seam: process capture\n+            # is already bounded by BaseEnvironment before sudo checks and hooks\n+            # run. Plugins may replace that bounded string; replacements are\n+            # still subject to the final output limit below.\n             # The hook is fail-open, and the first valid string return wins.\n             try:\n                 from hermes_cli.plugins import invoke_hook\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}