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:
@@ -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):
|
||||
|
||||
@@ -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
@@ -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 "
|
||||
|
||||
Reference in New Issue
Block a user