Files
hermes-agent/agent/replay_cleanup.py
T
Teknium be5c6a2fd8 refactor(agent/prompt): remove dead code, unify duplicated helpers, compact docstrings across prompt/skill/redaction modules
Dead (zero refs): coding_system_blocks, get_friendly_tool_labels, get_scan_ordered_skills_dirs,
_project_quarantine_cache_clear, clear_stable_prefixes, _redact_http_request_target_query_params,
_has_http_method_substring, PromptCachePlan.marker_count, display _diff_* colour thunks (-> _diff_ansi),
pass-through RedactingFormatter.__init__.
Unified: _slugify -> slugify_skill_name; reload diff -> diff_command_snapshots; _is_summary_item ->
is_compaction_summary_message alias; sanitizer walkers -> _sanitize_messages/_sanitize_structure;
assignment redaction passes -> _redact_assignments/_should_redact_assignment; quiet-mode tool lines -> _CUTE_LINES table.
2026-09-02 13:53:57 -07:00

266 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Replay-history sanitization shared across resume code paths.
A session whose last turn died mid-tool-loop (process killed by a restart
command, stale timeout, interrupt before the tool result was written) persists
a dangling ``assistant(tool_calls)`` or interrupted ``assistant→tool`` tail. On
resume the model re-issues the unanswered call → endless "thinking"/reboot loop.
These pure helpers strip those tails before replay, for EVERY resume surface
(messaging gateway and TUI/WebUI gateway alike).
"""
from __future__ import annotations
import logging
from typing import Any, Dict, List
from agent.tool_dispatch_helpers import make_tool_result_message
from agent.tool_result_classification import tool_may_have_side_effect
from agent.turn_context import drop_stale_api_content
logger = logging.getLogger(__name__)
def is_interrupted_tool_result(content: Any) -> bool:
"""Return True if a tool result indicates the tool was interrupted."""
if not isinstance(content, str):
return False
lowered = content.lower()
if "[command interrupted]" in lowered:
return True
if "exit_code" in lowered and ("130" in lowered or "-1" in lowered):
return "interrupt" in lowered
return False
def _call_name(call: Dict[str, Any]) -> str:
return str((call.get("function") or {}).get("name") or "")
def _call_id(call: Dict[str, Any]) -> str:
return str(call.get("id") or call.get("call_id") or "")
def _any_side_effecting(calls: List[Dict[str, Any]]) -> bool:
return any(tool_may_have_side_effect(_call_name(call)) for call in calls)
def _orphan_recovery(name: str, unknown_text: str, none_text: str) -> tuple:
"""(effect_disposition, content) for an interrupted/dangling call named ``name``."""
if tool_may_have_side_effect(name):
return "unknown", unknown_text
return "none", none_text
def strip_interrupted_tool_tails(
agent_history: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""Strip interrupted assistant→tool sequences from replay history.
The interrupted block is not necessarily the final tail (a queued real user
message may follow it), so every contiguous assistant(tool_calls)+tool-result
block containing an interrupted result is handled; successful sequences stay
intact. Read-only blocks are dropped; blocks with a side-effecting call are
KEPT with the interrupted results rewritten as orphan-recovery notices, since
the effect may already have happened and erasing it would hide that.
"""
if not agent_history:
return agent_history
cleaned: List[Dict[str, Any]] = []
i = 0
n = len(agent_history)
while i < n:
msg = agent_history[i]
if msg.get("role") == "assistant" and "tool_calls" in msg:
j = i + 1
tool_results: List[Dict[str, Any]] = []
while j < n and agent_history[j].get("role") == "tool":
tool_results.append(agent_history[j])
j += 1
if tool_results and any(
is_interrupted_tool_result(m.get("content", ""))
for m in tool_results
):
calls = msg.get("tool_calls") or []
if _any_side_effecting(calls):
call_names = {_call_id(call): _call_name(call) for call in calls}
cleaned.append(msg)
for tool_result in tool_results:
if not is_interrupted_tool_result(tool_result.get("content", "")):
cleaned.append(tool_result)
continue
recovered = dict(tool_result)
name = call_names.get(str(tool_result.get("tool_call_id") or ""), "")
recovered["effect_disposition"], recovered["content"] = _orphan_recovery(
name,
"[Orphan recovery: interrupted side-effecting tool may have "
"executed; its effect is UNKNOWN. Inspect state before retrying.]",
"[Orphan recovery: interrupted read-only tool did not complete.]",
)
cleaned.append(recovered)
i = j
continue
logger.debug(
"Stripping interrupted read-only assistant→tool replay block "
"(indices %d–%d, tool_results=%d)",
i, j - 1, len(tool_results),
)
i = j
continue
if msg.get("role") == "tool" and is_interrupted_tool_result(msg.get("content", "")):
logger.debug("Stripping orphan interrupted tool result from replay history")
i += 1
continue
cleaned.append(msg)
i += 1
return cleaned
def strip_dangling_tool_call_tail(
agent_history: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""Strip a trailing ``assistant(tool_calls)`` block left with NO answers.
A tool call that kills the gateway process itself (``docker restart``,
``hermes gateway restart``) is SIGKILLed mid-call, before any tool result or
the orderly shutdown rewind; the persisted tail is the assistant message with
zero matching ``tool`` rows, which ``strip_interrupted_tool_tails`` cannot
detect (no result to inspect). Only acts when the tail has NO tool answers —
a partially answered block still resumes. Read-only tails are dropped;
side-effecting ones get synthetic UNKNOWN-effect results instead of erasure.
"""
if not agent_history:
return agent_history
last = agent_history[-1]
if not (
isinstance(last, dict)
and last.get("role") == "assistant"
and last.get("tool_calls")
):
return agent_history
tool_calls = last.get("tool_calls") or []
if _any_side_effecting(tool_calls):
recovered = list(agent_history)
for call in tool_calls:
name = str((call.get("function") or {}).get("name") or "unknown")
disposition, content = _orphan_recovery(
name,
"[Orphan recovery: this tool may have executed before Hermes stopped; "
"its effect is UNKNOWN. Inspect current state before retrying.]",
"[Orphan recovery: this read-only tool did not complete and had no effect.]",
)
recovered.append(make_tool_result_message(
name, content, _call_id(call), effect_disposition=disposition,
))
logger.warning(
"Recovered dangling side-effecting tool call(s) as UNKNOWN instead of erasing them"
)
return recovered
logger.debug(
"Stripping dangling unanswered read-only assistant(tool_calls) tail (%d call(s))",
len(tool_calls),
)
return agent_history[:-1]
def sanitize_replay_history(
agent_history: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""Both replay-tail strippers in canonical order (interrupted blocks, then
dangling tail). Returns the same list object when nothing is stripped."""
if not agent_history:
return agent_history
return strip_dangling_tool_call_tail(strip_interrupted_tool_tails(agent_history))
# ──────────────────────────────────────────────────────────────────────
# Stale dangerous-confirmation text expiry
# ──────────────────────────────────────────────────────────────────────
# Short on purpose: a dangerous confirmation must not survive any restart or
# resume gap. The user can always re-confirm.
_DANGEROUS_CONFIRMATION_EXPIRY_SECONDS = 60.0
# Confirmation phrases that unlock destructive host actions; case-insensitive
# substring match so trailing punctuation / extra context still matches.
_DANGEROUS_CONFIRMATION_PATTERNS: tuple = (
"confirm forced restart",
"confirm forced reboot",
"confirm shutdown",
"confirm reboot",
"confirm power off",
"yes, delete everything",
"confirm wipe",
"confirm factory reset",
# i18n variants observed in the original incident
"確認強制重開機",
"確認強制重開",
"確認重啟",
)
# Redacting in place (rather than deleting the message) preserves strict
# user/assistant role alternation in the replayed history.
_EXPIRED_CONFIRMATION_SENTINEL = (
"[A high-risk confirmation previously given here has EXPIRED and must "
"not be acted on. Ask the user to re-confirm explicitly before "
"performing any destructive action.]"
)
def is_dangerous_confirmation(content: Any) -> bool:
"""True if user-message text contains a known dangerous confirmation phrase."""
if not isinstance(content, str):
return False
text = content.strip().lower()
return any(pattern in text for pattern in _DANGEROUS_CONFIRMATION_PATTERNS)
def strip_stale_dangerous_confirmations(
agent_history: List[Dict[str, Any]],
*,
now: float,
expiry_seconds: float = _DANGEROUS_CONFIRMATION_EXPIRY_SECONDS,
) -> List[Dict[str, Any]]:
"""Expire stale dangerous-confirmation text in user messages.
If a host restart killed the gateway before the tool result was written, the
user's confirmation phrase survives in the transcript; a casual "are you
there?" minutes later can read to the model as a fresh re-confirmation and
re-execute the destructive action. Expired confirmations are REDACTED IN
PLACE (deleting the message would leave two consecutive assistant turns).
Messages without a timestamp (legacy transcripts, test scaffolding) and
confirmations still inside the expiry window are left untouched.
"""
if not agent_history:
return agent_history
cleaned: List[Dict[str, Any]] = []
for msg in agent_history:
if (
isinstance(msg, dict)
and msg.get("role") == "user"
and is_dangerous_confirmation(msg.get("content", ""))
):
ts = msg.get("timestamp")
if ts is not None and (now - float(ts)) > expiry_seconds:
logger.debug(
"Redacting stale dangerous-confirmation text in user "
"message (age=%.1fs, expiry=%.1fs): %r",
now - float(ts),
expiry_seconds,
(msg.get("content") or "")[:80],
)
redacted = dict(msg)
redacted["content"] = _EXPIRED_CONFIRMATION_SENTINEL
# The api_content sidecar carries the exact bytes previously sent
# — the confirmation itself; replaying it would undo the redaction.
drop_stale_api_content(redacted)
cleaned.append(redacted)
continue
cleaned.append(msg)
return cleaned