feat(execute_code): stdout spillover — truncated output's full text saved to cache/exec (host) or kernel tmpdir (cells), path + read_file recipe in the result (#97043)

This commit is contained in:
Teknium
2026-08-28 03:05:03 -07:00
committed by GitHub
parent 2f57cd95b2
commit ae8c976032
3 changed files with 109 additions and 8 deletions
+8
View File
@@ -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):
+56 -2
View File
@@ -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
+45 -6
View File
@@ -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 "