Hermes now routes scratch space through HERMES_HOME/cache/scratch (exported as TMPDIR), so every production path that still spelled out /tmp bypassed that and kept teaching the agent the habit. Fallbacks in tool_result_storage, code_execution_tool, process_registry, the ACP child HOME, mini_swe_runner's local cwd, and the CI/profiling scripts now use tempfile.gettempdir(); shell installers fall back to $TMPDIR (then HERMES_HOME) when mktemp is missing, and repro/eval shells use `mktemp -d -t`. User-facing help text and sample payloads (hermes send, approvals test, hooks test, voice-mode WSL hints, meet_bot debug line) no longer suggest /tmp. Container-side paths (mini_swe_runner docker cwd, sandbox base env, remote sync tarballs) keep the literal because they name the sandbox filesystem, not the host.
323 lines
14 KiB
Python
323 lines
14 KiB
Python
"""Tool result persistence -- preserves large outputs instead of truncating. Layers against
|
|
context overflow: (1) per-tool caps inside each tool; (2) ``maybe_persist_tool_result`` —
|
|
output over the tool's threshold is persisted and replaced by a preview + path; canonical home
|
|
is ALWAYS host-side ``$HERMES_HOME/cache/spillover/{id}.txt`` (works for sessions that never
|
|
ran a terminal), remote backends get the translated in-sandbox path (probed for readability)
|
|
else a copy in the sandbox temp dir; (3) ``enforce_turn_budget``."""
|
|
|
|
import hashlib
|
|
import logging
|
|
import os
|
|
import re
|
|
import shlex
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
|
|
from tools.budget_config import DEFAULT_PREVIEW_SIZE_CHARS, BudgetConfig, DEFAULT_BUDGET
|
|
|
|
logger = logging.getLogger(__name__)
|
|
PERSISTED_OUTPUT_TAG = "<persisted-output>"
|
|
PERSISTED_OUTPUT_CLOSING_TAG = "</persisted-output>"
|
|
STORAGE_DIR = os.path.join(tempfile.gettempdir(), "hermes-results")
|
|
SPILLOVER_SUBDIR = "cache/spillover"
|
|
SPILLOVER_MAX_AGE_HOURS = 24
|
|
_BUDGET_TOOL_NAME = "__budget_enforcement__"
|
|
_UNSAFE_RESULT_FILENAME_CHARS = re.compile(r"[^A-Za-z0-9_.-]+")
|
|
_MAX_RESULT_FILENAME_STEM = 120
|
|
|
|
_spillover_prune_lock = threading.Lock()
|
|
_spillover_pruned_homes: set = set() # profile home keys already swept this process
|
|
|
|
|
|
def get_spillover_dir():
|
|
"""Return $HERMES_HOME/cache/spillover as a Path (not created)."""
|
|
from hermes_constants import get_hermes_home
|
|
return get_hermes_home() / SPILLOVER_SUBDIR
|
|
|
|
|
|
def cleanup_spillover_cache(max_age_hours: int = SPILLOVER_MAX_AGE_HOURS) -> int:
|
|
"""Delete spillover files older than *max_age_hours*; returns count removed (same
|
|
contract as the ``cleanup_*_cache`` helpers the gateway housekeeping loop runs hourly)."""
|
|
cutoff = time.time() - (max_age_hours * 3600)
|
|
removed = 0
|
|
try:
|
|
entries = list(get_spillover_dir().iterdir())
|
|
except OSError:
|
|
return 0
|
|
for f in entries:
|
|
try:
|
|
if f.is_file() and f.stat().st_mtime < cutoff:
|
|
f.unlink()
|
|
removed += 1
|
|
except OSError:
|
|
pass
|
|
return removed
|
|
|
|
|
|
def _prune_spillover_once() -> None:
|
|
"""Best-effort prune, at most once per process PER PROFILE HOME (CLI-only installs never run
|
|
housekeeping; a multiplexed gateway must sweep every profile's ``cache/spillover``, not just the
|
|
first one that spilled)."""
|
|
from hermes_constants import hermes_home_key
|
|
home_key = hermes_home_key()
|
|
with _spillover_prune_lock:
|
|
if home_key in _spillover_pruned_homes:
|
|
return
|
|
_spillover_pruned_homes.add(home_key)
|
|
try:
|
|
if removed := cleanup_spillover_cache():
|
|
logger.debug("Pruned %d expired spillover file(s)", removed)
|
|
except Exception as exc:
|
|
logger.debug("Spillover prune failed: %s", exc)
|
|
|
|
|
|
def _is_host_side_env(env) -> bool:
|
|
"""True when this process should write the spill file directly: ``env=None`` (no sandbox
|
|
yet) or the local backend. Remote backends resolve ``read_file`` inside the sandbox."""
|
|
if env is None:
|
|
return True
|
|
try:
|
|
from tools.environments.local import LocalEnvironment
|
|
return isinstance(env, LocalEnvironment)
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _write_to_spillover(content: str, filename: str):
|
|
"""Write host-side to $HERMES_HOME/cache/spillover; returns path str or None.
|
|
|
|
The write is size-verified before the caller tells the model "Full output saved":
|
|
a partially-flushed file (quota, ENOSPC race) fails closed to the bounded inline
|
|
truncation instead of referencing an archive that silently lost bytes.
|
|
"""
|
|
data = content.encode("utf-8", errors="replace")
|
|
try:
|
|
spill_dir = get_spillover_dir()
|
|
spill_dir.mkdir(parents=True, exist_ok=True)
|
|
path = spill_dir / filename
|
|
path.write_bytes(data)
|
|
persisted_size = os.stat(path).st_size
|
|
except OSError as exc:
|
|
logger.warning("Spillover write failed for %s: %s", filename, exc)
|
|
return None
|
|
if persisted_size != len(data):
|
|
logger.warning(
|
|
"Spillover write for %s is not lossless (%d bytes on disk, "
|
|
"expected %d) — discarding archive",
|
|
filename, persisted_size, len(data),
|
|
)
|
|
try:
|
|
path.unlink()
|
|
except OSError:
|
|
pass
|
|
return None
|
|
_prune_spillover_once()
|
|
return str(path)
|
|
|
|
|
|
def _sandbox_visible_spillover_path(host_path: str, env) -> str | None:
|
|
"""Path where a remote backend can read *host_path*, or None. Translates via the image
|
|
tools' helper, forces a sync for synced backends, then PROBES readability — a persistent
|
|
container created before spillover joined the mount list lacks the bind mount and must
|
|
fall back to the in-sandbox write."""
|
|
try:
|
|
from tools.credential_files import to_agent_visible_cache_path
|
|
visible = to_agent_visible_cache_path(host_path)
|
|
except Exception as exc:
|
|
logger.debug("Spillover path translation failed: %s", exc)
|
|
return None
|
|
try:
|
|
if (sync_manager := getattr(env, "_sync_manager", None)) is not None:
|
|
sync_manager.sync(force=True)
|
|
except Exception as exc:
|
|
logger.debug("Spillover sync failed: %s", exc)
|
|
try:
|
|
if env.execute(f"test -r {shlex.quote(visible)}", timeout=15).get("returncode", 1) == 0:
|
|
return visible
|
|
except Exception as exc:
|
|
logger.debug("Spillover readability probe failed: %s", exc)
|
|
return None
|
|
|
|
|
|
def _resolve_storage_dir(env) -> str:
|
|
"""Return the best temp-backed storage dir for this environment."""
|
|
get_temp_dir = getattr(env, "get_temp_dir", None)
|
|
temp_dir = None
|
|
if callable(get_temp_dir):
|
|
try:
|
|
temp_dir = get_temp_dir()
|
|
except Exception as exc:
|
|
logger.debug("Could not resolve env temp dir: %s", exc)
|
|
return f"{temp_dir.rstrip('/') or '/'}/hermes-results" if temp_dir else STORAGE_DIR
|
|
|
|
|
|
def _safe_result_filename(tool_use_id: str) -> str:
|
|
"""Return a single safe filename for a tool result id."""
|
|
raw_id = str(tool_use_id or "tool_result")
|
|
safe_stem = _UNSAFE_RESULT_FILENAME_CHARS.sub("_", raw_id).strip("._-")
|
|
changed = safe_stem != raw_id
|
|
safe_stem = safe_stem or "tool_result"
|
|
if changed or len(safe_stem) > _MAX_RESULT_FILENAME_STEM:
|
|
digest = hashlib.sha256(raw_id.encode("utf-8")).hexdigest()[:12]
|
|
safe_stem = safe_stem[:_MAX_RESULT_FILENAME_STEM].rstrip("._-") or "tool_result"
|
|
safe_stem = f"{safe_stem}_{digest}"
|
|
return f"{safe_stem}.txt"
|
|
|
|
|
|
def generate_preview(content: str, max_chars: int = DEFAULT_PREVIEW_SIZE_CHARS) -> tuple[str, bool]:
|
|
"""Truncate at last newline within max_chars. Returns (preview, has_more)."""
|
|
if len(content) <= max_chars:
|
|
return content, False
|
|
last_nl = content.rfind("\n", 0, max_chars)
|
|
return content[:last_nl + 1 if last_nl > max_chars // 2 else max_chars], True
|
|
|
|
|
|
def _write_to_sandbox(content: str, remote_path: str, env) -> bool:
|
|
"""Write content into the sandbox via env.execute(); True on success. Content goes through
|
|
stdin, not the command string: Linux ``MAX_ARG_STRLEN`` caps one argv element at 128 KB,
|
|
so a heredoc-in-command silently failed for exactly the oversized results this handles.
|
|
|
|
The write is round-trip verified with ``wc -c`` (one extra exec RTT per oversized result):
|
|
a zero exit from ``cat`` does not prove the bytes landed (quota/ENOSPC races, API-body
|
|
truncation on payload backends). A measured mismatch removes the archive and fails closed;
|
|
an unprobeable backend (no ``wc``, exec error, unparseable output) stays best-effort success.
|
|
Heredoc-mode backends append exactly one trailing newline by construction
|
|
(``BaseEnvironment._embed_stdin_heredoc``), so one extra byte is accepted there."""
|
|
storage_dir = os.path.dirname(remote_path)
|
|
cmd = f"mkdir -p {shlex.quote(storage_dir)} && cat > {shlex.quote(remote_path)}"
|
|
if env.execute(cmd, timeout=30, stdin_data=content).get("returncode", 1) != 0:
|
|
return False
|
|
|
|
expected = len(content.encode("utf-8", errors="replace"))
|
|
try:
|
|
probe = env.execute(f"wc -c < {shlex.quote(remote_path)}", timeout=15)
|
|
except Exception as exc:
|
|
logger.debug("Sandbox size probe failed for %s: %s", remote_path, exc)
|
|
return True
|
|
if probe.get("returncode", 1) != 0:
|
|
return True
|
|
raw = str(probe.get("output", "") or "").strip().split()
|
|
if not raw or not raw[-1].isdigit():
|
|
return True
|
|
persisted_size = int(raw[-1])
|
|
if persisted_size == expected:
|
|
return True
|
|
# Only heredoc mode may be +1; the payload backend (managed_modal) delivers stdin verbatim, so
|
|
# it is expected byte-exact and any drift there is a real loss.
|
|
if persisted_size == expected + 1 and getattr(env, "_stdin_mode", None) == "heredoc":
|
|
return True
|
|
logger.warning("Sandbox spill for %s is not lossless (%d bytes in sandbox, expected %d) — discarding archive",
|
|
remote_path, persisted_size, expected)
|
|
try:
|
|
env.execute(f"rm -f {shlex.quote(remote_path)}", timeout=15)
|
|
except Exception:
|
|
pass
|
|
return False
|
|
|
|
|
|
def _build_persisted_message(preview: str, has_more: bool, original_size: int,
|
|
file_path: str) -> str:
|
|
"""Build the <persisted-output> replacement block."""
|
|
size_kb = original_size / 1024
|
|
size_str = f"{size_kb / 1024:.1f} MB" if size_kb >= 1024 else f"{size_kb:.1f} KB"
|
|
return (
|
|
f"{PERSISTED_OUTPUT_TAG}\n"
|
|
f"This tool result was too large ({original_size:,} characters, {size_str}).\n"
|
|
f"Full output saved to: {file_path}\n"
|
|
"Use the read_file tool with offset and limit to access specific sections of this output.\n"
|
|
"Recovery: page through the saved file with read_file (offset/limit) or "
|
|
"process it with execute_code — do NOT re-request the same data from the "
|
|
"remote API; the full result is already on disk.\n\n"
|
|
f"Preview (first {len(preview)} chars):\n"
|
|
+ preview + ("\n..." if has_more else "")
|
|
+ f"\n{PERSISTED_OUTPUT_CLOSING_TAG}")
|
|
|
|
|
|
_PERSISTED_PATH_RE = re.compile(r"^Full output saved to: (.+)$", re.MULTILINE)
|
|
|
|
|
|
def extract_persisted_path(content: str) -> str | None:
|
|
"""File path from a <persisted-output> block, or None (lets the result-reference stubbing
|
|
guard in agent/tool_guardrails.py carry the spillover path instead of leaving it dangling)."""
|
|
match = (_PERSISTED_PATH_RE.search(content)
|
|
if isinstance(content, str) and PERSISTED_OUTPUT_TAG in content else None)
|
|
return match.group(1).strip() if match else None
|
|
|
|
|
|
def maybe_persist_tool_result(content: str, tool_name: str, tool_use_id: str, env=None,
|
|
config: BudgetConfig = DEFAULT_BUDGET,
|
|
threshold: int | float | None = None) -> str:
|
|
"""Layer 2: persist an oversized result, return preview + path. ``threshold`` overrides
|
|
``config.resolve_threshold(tool_name)``; falls back to inline truncation when no write
|
|
location succeeds."""
|
|
if threshold is None:
|
|
threshold = config.resolve_threshold(tool_name)
|
|
if threshold == float("inf") or len(content) <= threshold:
|
|
return content
|
|
filename = _safe_result_filename(tool_use_id)
|
|
preview, has_more = generate_preview(content, max_chars=config.preview_size)
|
|
|
|
def _persisted(path: str, host_suffix: str = "") -> str:
|
|
logger.info("Persisted large tool result: %s (%s, %d chars -> %s%s)",
|
|
tool_name, tool_use_id, len(content), path, host_suffix)
|
|
return _build_persisted_message(preview, has_more, len(content), path)
|
|
|
|
# Always persist host-side first: cache/spillover is the single canonical home.
|
|
host_path = _write_to_spillover(content, filename)
|
|
host_side = _is_host_side_env(env)
|
|
if host_side and host_path is not None:
|
|
return _persisted(host_path)
|
|
if not host_side:
|
|
# Remote backend: reference the mounted/synced path when the sandbox can actually read
|
|
# it, else write into the sandbox temp dir (containers without the spillover mount).
|
|
visible = _sandbox_visible_spillover_path(host_path, env) if host_path else None
|
|
if visible is not None:
|
|
return _persisted(visible, f" [host: {host_path}]")
|
|
remote_path = f"{_resolve_storage_dir(env)}/{filename}"
|
|
try:
|
|
if _write_to_sandbox(content, remote_path, env):
|
|
return _persisted(remote_path)
|
|
except Exception as exc:
|
|
logger.warning("Sandbox write failed for %s: %s", tool_use_id, exc)
|
|
logger.info("Inline-truncating large tool result: %s (%d chars, no sandbox write)",
|
|
tool_name, len(content))
|
|
return (f"{preview}\n\n[Truncated: tool response was {len(content):,} chars. "
|
|
"Full output could not be saved to sandbox.]")
|
|
|
|
|
|
def enforce_turn_budget(tool_messages: list[dict], env=None,
|
|
config: BudgetConfig = DEFAULT_BUDGET) -> list[dict]:
|
|
"""Layer 3: persist the largest non-persisted results first until the turn's aggregate is
|
|
under budget. Mutates the list in-place and returns it."""
|
|
sizes = [len(msg.get("content", "")) for msg in tool_messages]
|
|
total_size = sum(sizes)
|
|
candidates = [(i, size) for i, size in enumerate(sizes)
|
|
if PERSISTED_OUTPUT_TAG not in tool_messages[i].get("content", "")]
|
|
if total_size <= config.turn_budget:
|
|
return tool_messages
|
|
for idx, size in sorted(candidates, key=lambda x: x[1], reverse=True):
|
|
if total_size <= config.turn_budget:
|
|
break
|
|
content = tool_messages[idx]["content"]
|
|
tool_use_id = tool_messages[idx].get("tool_call_id", f"budget_{idx}")
|
|
replacement = maybe_persist_tool_result(
|
|
content=content, tool_name=_BUDGET_TOOL_NAME, tool_use_id=tool_use_id,
|
|
env=env, config=config, threshold=0)
|
|
if replacement != content:
|
|
total_size += len(replacement) - size
|
|
tool_messages[idx]["content"] = replacement
|
|
logger.info("Budget enforcement: persisted tool result %s (%d chars)",
|
|
tool_use_id, size)
|
|
return tool_messages
|
|
|
|
|
|
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
|
|
# Names external plugins imported from this module before the Sep 2026 decomposition.
|
|
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
|
|
# The whole block is removed by reverting the commit that added it.
|
|
import uuid # noqa: F401,E402
|
|
|
|
HEREDOC_MARKER = "HERMES_PERSIST_EOF"
|
|
# ---- END PLUGIN-COMPAT ----
|