be5c6a2fd8
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.
266 lines
11 KiB
Python
266 lines
11 KiB
Python
"""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
|