From ae8c9760326746d64f5e10f5ab0e45b7a5ce80b3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 28 Aug 2026 03:05:03 -0700 Subject: [PATCH] =?UTF-8?q?feat(execute=5Fcode):=20stdout=20spillover=20?= =?UTF-8?q?=E2=80=94=20truncated=20output's=20full=20text=20saved=20to=20c?= =?UTF-8?q?ache/exec=20(host)=20or=20kernel=20tmpdir=20(cells),=20path=20+?= =?UTF-8?q?=20read=5Ffile=20recipe=20in=20the=20result=20(#97043)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/tools/test_code_execution.py | 8 +++++ tools/code_execution_tool.py | 58 ++++++++++++++++++++++++++++-- tools/code_kernel.py | 51 ++++++++++++++++++++++---- 3 files changed, 109 insertions(+), 8 deletions(-) diff --git a/tests/tools/test_code_execution.py b/tests/tools/test_code_execution.py index 26180325d4..66ed46a686 100644 --- a/tests/tools/test_code_execution.py +++ b/tests/tools/test_code_execution.py @@ -810,7 +810,15 @@ class TestHeadTailTruncation(unittest.TestCase): self.assertIn("TAIL", result["output"]) self.assertGreater(result["stdout_bytes_total"], result["stdout_bytes_captured"]) self.assertGreater(result["stdout_bytes_omitted"], 0) + # Spillover (#96997-adjacent): the warning now points at the saved + # full-output file instead of advising a narrower re-run. self.assertIn("execute_code stdout was truncated", result["warning"]) + self.assertIn("read_file", result["warning"]) + self.assertIn("stdout_spill_path", result) + with open(result["stdout_spill_path"], encoding="utf-8") as f: + body = f.read() + self.assertIn("HEAD", body) + self.assertIn("TAIL", body) class TestRpcTokenAuthorization(unittest.TestCase): diff --git a/tools/code_execution_tool.py b/tools/code_execution_tool.py index 2e7347f393..dc7ac07648 100644 --- a/tools/code_execution_tool.py +++ b/tools/code_execution_tool.py @@ -120,18 +120,72 @@ def _assemble_stdout_result( def _truncate_stdout_text(stdout_text: str) -> Tuple[str, Dict[str, Any]]: - """Cap a complete stdout string by bytes using the same head/tail policy.""" + """Cap a complete stdout string by bytes using the same head/tail policy. + + When the full text is in hand (this function's callers, unlike the + streaming per-call reader), the omitted middle is not discarded: the + complete output is spilled to cache/exec and the result carries the + path — the same recover-don't-rerun pattern as web_extract's + cache/web full-text store. + """ stdout_bytes = stdout_text.encode("utf-8", errors="replace") if len(stdout_bytes) <= MAX_STDOUT_BYTES: return _assemble_stdout_result(stdout_bytes) head_bytes = int(MAX_STDOUT_BYTES * 0.4) tail_bytes = MAX_STDOUT_BYTES - head_bytes - return _assemble_stdout_result( + text, metadata = _assemble_stdout_result( stdout_bytes[:head_bytes], stdout_bytes[-tail_bytes:], total_bytes=len(stdout_bytes), ) + 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 + + +# Hard ceiling on the spilled file, mirroring web_tools' MAX_STORED_TEXT_CHARS +# rationale: a runaway print loop must not write unbounded bytes to disk. +MAX_SPILLED_STDOUT_BYTES = 5_000_000 + + +def _spill_full_stdout(stdout_text: str) -> Optional[str]: + """Write full stdout to cache/exec; return its path (None on failure). + + Best-effort by design — truncated inline output is still returned when + storage fails. Files are keyed by content digest so identical reruns + coalesce; the directory rides the same remote bind-mount list as + cache/web (credential_files._CACHE_DIRS) if present there. + """ + try: + import hashlib + from hermes_constants import get_hermes_dir + + 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" + from tools.spill_safety import write_text_exclusive + + 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 # Environment variable scrubbing rules (shared between the local + remote # backends). Secret-substring block is applied first; anything left must diff --git a/tools/code_kernel.py b/tools/code_kernel.py index 6101bdbf92..7805f636d1 100644 --- a/tools/code_kernel.py +++ b/tools/code_kernel.py @@ -82,6 +82,8 @@ import traceback _SENTINEL = os.environ["HERMES_KERNEL_SENTINEL"] _CAPTURE_LIMIT = {capture_limit} +_SPILL_DIR = os.environ.get("HERMES_KERNEL_SPILL_DIR", "") +_SPILL_CAP = {spill_cap} # The persistent cell namespace. `__name__` is `__main__` so scripts behave # like the per-call path; builtins resolve normally through exec. @@ -90,10 +92,25 @@ GLOBALS = {{"__name__": "__main__", "__builtins__": __builtins__}} _real_stdout = sys.stdout -def _bounded(text): +def _bounded(text, spill_name=None): + """Clip to the inline cap; spill the FULL text to disk when clipping. + + Returns (clipped_text, clipped?, spill_path_or_empty). Spill is + best-effort — a failed write degrades to plain clipping. + """ if len(text) <= _CAPTURE_LIMIT: - return text, False - return text[: _CAPTURE_LIMIT], True + return text, False, "" + spill_path = "" + if _SPILL_DIR and spill_name: + try: + spill_path = os.path.join(_SPILL_DIR, spill_name) + with open(spill_path, "w", encoding="utf-8", errors="replace") as f: + f.write(text[:_SPILL_CAP]) + if len(text) > _SPILL_CAP: + f.write("\\n\\n[... spill capped ...]") + except Exception: + spill_path = "" + return text[: _CAPTURE_LIMIT], True, spill_path def _reply(payload): @@ -128,8 +145,10 @@ def main(): except BaseException: status = "error" trace = traceback.format_exc() - stdout_text, stdout_clipped = _bounded(out.getvalue()) - stderr_text, stderr_clipped = _bounded(err.getvalue()) + stdout_text, stdout_clipped, stdout_spill = _bounded( + out.getvalue(), "cell_%06d_stdout.txt" % execution_count + ) + stderr_text, stderr_clipped, _ = _bounded(err.getvalue()) _reply( {{ "id": request.get("id", ""), @@ -138,6 +157,7 @@ def main(): "stderr": stderr_text, "stdout_clipped": stdout_clipped, "stderr_clipped": stderr_clipped, + "stdout_spill_path": stdout_spill, "traceback": trace, "execution_count": execution_count, }} @@ -148,7 +168,8 @@ def main(): if __name__ == "__main__": main() -'''.format(capture_limit=_RUNNER_CAPTURE_BYTES) +'''.format(capture_limit=_RUNNER_CAPTURE_BYTES, + spill_cap=5_000_000) class CellAuthority: @@ -564,6 +585,10 @@ def _spawn(kernel: SessionKernel, *, task_id: str, child_python: str, child_python=child_python, ) child_env["HERMES_KERNEL_SENTINEL"] = kernel.sentinel + # Cells clip stdout to the inline cap; the full text spills to the + # kernel's own tmpdir so the agent can read_file the middle instead of + # re-running (host surfaces the path in the result). + child_env["HERMES_KERNEL_SPILL_DIR"] = kernel.tmpdir # Tell the generated client to reconnect after the RPC server's idle # timeout — a kernel outlives the 300s window between cells. child_env["HERMES_RPC_PERSISTENT"] = "1" @@ -741,6 +766,20 @@ def execute_in_session_kernel( } result.update(stdout_metadata) + # Cell-side spill (runner clipped before replying): surface the + # full-output path with the same read_file recipe as the + # host-side spill in _truncate_stdout_text. + cell_spill = str(payload.get("stdout_spill_path", "") or "") + if cell_spill and payload.get("stdout_clipped"): + result["stdout_spill_path"] = cell_spill + result["warning"] = ( + "Cell stdout exceeded the inline cap; head shown. FULL " + f"output saved to {cell_spill} — page it with " + f'read_file(path="{cell_spill}", offset=...) instead of ' + "re-running. (Kernel state persists: printing a narrower " + "slice next call is often cheaper.)" + ) + if status == "timeout": message = ( f"Cell timed out after {timeout}s; the session kernel was "