Files
hermes-agent/gateway/session_stall.py
teknium1 23036e20a6 fix(ux): plain-language, actionable user-facing messages (core)
Squashed integration of the user-facing message audit for this surface set.
Full per-finding receipts: /tmp/ux-audit/lanes/*-receipt.md (campaign artifacts).
2026-09-15 04:12:13 -07:00

77 lines
2.8 KiB
Python

"""Gateway session stall notification policy.
Consumes ``AIAgent.get_activity_summary()`` as the **single progress source** and owns
only the notify-once policy for "pending inbound + stale progress"; it never derives a
parallel progress clock from turn-start or inbound timestamps. Pending inbound is a
stall *policy gate*, not a delivery obligation and not process liveness.
"""
from __future__ import annotations
import math
import time
from typing import Any, Mapping, Optional
def should_emit_session_stall_notification(
*, timeout_seconds: float, idle_seconds: Optional[float], has_pending_inbound: bool,
already_notified: bool,
) -> bool:
"""Return True when a stall warning should be sent for this session."""
return (
timeout_seconds > 0 and has_pending_inbound and not already_notified
and idle_seconds is not None and idle_seconds >= timeout_seconds
)
def should_clear_session_stall_notification(
*, timeout_seconds: float, idle_seconds: Optional[float], has_pending_inbound: bool,
) -> bool:
"""Return True when a prior stall notice may be cleared (episode ended)."""
if not has_pending_inbound or timeout_seconds <= 0:
return True
# Unknown progress holds the latch: observation gaps are not recovery.
return idle_seconds is not None and idle_seconds < timeout_seconds
def format_session_stall_notification(idle_seconds: float) -> str:
"""User-facing stall warning (ASCII minutes).
See #72016.
"""
mins = max(1, int(idle_seconds // 60))
return (f"⚠️ I seem to be stuck (no activity for {mins} min). Send /stop to cancel the current "
"task, or /new to start a fresh conversation.")
def _finite_float(value: Any) -> Optional[float]:
"""``value`` as a finite float, or None (bools are rejected as non-numeric)."""
if value is None or isinstance(value, bool):
return None
try:
result = float(value)
except (TypeError, ValueError):
return None
return result if math.isfinite(result) else None
def resolve_session_idle_seconds_from_activity(
activity: Optional[Mapping[str, Any]], *, now: Optional[float] = None,
) -> Optional[float]:
"""Idle seconds from a shared activity snapshot: a finite ``seconds_since_activity``, else
derived from ``last_activity_at`` / ``last_activity_ts``. None when there is no usable
progress timestamp — callers must not fall back to turn-start or inbound clocks.
See #72039.
"""
if not activity:
return None
idle = _finite_float(activity.get("seconds_since_activity"))
if idle is not None:
return max(0.0, idle)
ts = activity.get("last_activity_at")
when = _finite_float(activity.get("last_activity_ts") if ts is None else ts)
if when is None:
return None
return max(0.0, float(time.time() if now is None else now) - when)