"""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