Files
hermes-agent/agent/session_activity.py
unsupportedpastels c6293dcd57 fix(cron): preserve suspend credit across idle sampling
Keep newly observed sleep credit until the following poll because the supplied idle duration can predate that sleep. Extend both contributor regression tests across the sampling boundary and qualify the platform guarantee.
2026-10-02 13:50:23 -04:00

145 lines
6.8 KiB
Python

"""Shared session activity observation contract: timestamp + bounded description/provenance, observation
only (notification, timeout, kill and retry policy live elsewhere). Provenance is a small closed enum of
*noun* sources; the default agent clock stamps ``unknown`` unless a writer passes ``provenance=``."""
from __future__ import annotations
import sys
import time
from contextlib import suppress
from enum import Enum
from typing import Any, Mapping, Optional
from agent.i18n import t
ACTIVITY_DESCRIPTION_MAX = 120
# Durable SessionDB heartbeat cadence. Contract: MUST stay >= 30s — the SessionDB write path is contended and
# this observation-only projection never justifies extra write pressure. A code constant on purpose (no config
# can make it a high-frequency writer); matches the kanban auto-heartbeat. force_persist and terminal
# compression stamps (see TERMINAL_COMPRESSION_PROVENANCES) are the only bypasses.
SESSION_ACTIVITY_HEARTBEAT_MIN_INTERVAL_SECONDS = 60.0
class ActivityProvenance(str, Enum):
"""Where a durable/in-memory activity stamp came from."""
UNKNOWN = "unknown"
# Compression writers: heartbeat, host timeout, cooldown, turn hold.
# See #72424.
AGENT_COMPRESSION = "agent.compression"
AGENT_COMPRESSION_TIMEOUT = "agent.compression_timeout"
AGENT_COMPRESSION_COOLDOWN = "agent.compression_cooldown"
AGENT_COMPRESSION_TURNHOLD = "agent.compression_turnhold"
# Provenances that END the user-visible "compressing" phase: the host's progress timeout, a cooldown/
# backoff block, and the gateway's hygiene turn-hold. Each is written once, at a terminal edge, and must
# reach the durable projection IMMEDIATELY — the heartbeat above wrote "context compression in progress"
# moments earlier, so a rate-limited terminal stamp leaves `sessions.last_activity_description` advertising
# a compression that has already stopped, with no later writer to correct it (the agent may be idle, the
# process gone). That is the permanently "stuck / compressing" chat users see.
#
# Distinct from ``conversation_compression._TERMINAL_COMPRESSION_PROVENANCES``, which answers a different
# question — "may a detached heartbeat still overwrite this stamp?" — and deliberately excludes TURNHOLD,
# because after a turn-hold the worker may still be alive and adoptable.
TERMINAL_COMPRESSION_PROVENANCES = frozenset(
{
ActivityProvenance.AGENT_COMPRESSION_TIMEOUT,
ActivityProvenance.AGENT_COMPRESSION_COOLDOWN,
ActivityProvenance.AGENT_COMPRESSION_TURNHOLD,
}
)
def is_terminal_compression_provenance(provenance: Optional[ActivityProvenance | str]) -> bool:
"""True when this stamp ends a compression phase and must bypass the persist rate limit."""
return normalize_activity_provenance(provenance) in TERMINAL_COMPRESSION_PROVENANCES
def bound_activity_description(description: Optional[str]) -> str:
"""Clamp free-form activity text to the shared description budget."""
text = (description or "").strip()
return text if len(text) <= ACTIVITY_DESCRIPTION_MAX else text[: ACTIVITY_DESCRIPTION_MAX - 1] + "…"
def normalize_activity_provenance(provenance: Optional[ActivityProvenance | str]) -> ActivityProvenance:
"""Return a known provenance, or ``UNKNOWN`` when unset/unrecognized."""
if isinstance(provenance, ActivityProvenance):
return provenance
try:
return ActivityProvenance((provenance or "").strip())
except ValueError:
return ActivityProvenance.UNKNOWN
def format_iteration_progress(api_call_count: Any, max_iterations: Any) -> str:
"""``iteration N/M`` for user-facing status lines, or ``iteration N`` when the cap is unbounded.
``AIAgent.max_iterations`` defaults to ``sys.maxsize`` (unlimited), so printing the pair verbatim
shows ``iteration 3/9223372036854775807`` in busy acks, heartbeats and timeout diagnostics (#102806).
"""
try:
cap = int(max_iterations)
except (TypeError, ValueError):
cap = sys.maxsize
if cap >= sys.maxsize:
return t("display.iteration_progress.unbounded", n=api_call_count)
return t("display.iteration_progress.bounded", n=api_call_count, max=cap)
def reset_session_activity_persist_window(agent: Any) -> None:
"""Clear the persist rate-limit so the next stamp writes through (terminal compression labels must not stick on mid-compress text)."""
with suppress(Exception):
agent._session_activity_last_persist_mono = 0.0
def build_activity_snapshot(
*,
last_activity_at: Optional[float],
last_activity_description: Optional[str],
last_activity_provenance: Optional[ActivityProvenance | str] = None,
now: Optional[float] = None,
extra: Optional[Mapping[str, Any]] = None,
) -> dict[str, Any]:
"""Build the shared activity snapshot (plus optional caller extras)."""
when = float(last_activity_at) if last_activity_at is not None else None
clock = float(now if now is not None else time.time())
desc = bound_activity_description(last_activity_description)
prov = normalize_activity_provenance(last_activity_provenance).value
return {
"last_activity_at": when,
"last_activity_description": desc,
"last_activity_provenance": prov,
"seconds_since_activity": round(clock - when, 1) if when is not None else None,
# Short aliases used by existing gateway/delegate readers.
"last_activity_ts": when, "last_activity_desc": desc, "description": desc, "provenance": prov,
**(extra or {}),
}
class AwakeIdleMeter:
"""Removes host sleep from a polling watchdog's idle readings.
Idle time is measured on the wall clock, which keeps running while the host sleeps, so a laptop
that sleeps 15 minutes mid-turn wakes up "idle" for 15 minutes and the first poll after resume
kills a turn that never had a chance to run. ``time.monotonic()`` pauses during sleep on macOS
(``mach_absolute_time``) and Linux (``CLOCK_MONOTONIC``), so the two clocks drift apart by the
time spent asleep. Call :meth:`measure` once per poll with the wall-clock idle seconds.
"""
def __init__(self) -> None:
self._wall, self._mono = time.time(), time.monotonic()
self._asleep_s = 0.0
def measure(self, idle_s: float) -> float:
"""Return *idle_s* minus the sleep observed since the agent's last activity."""
wall, mono = time.time(), time.monotonic()
slept_s = max(0.0, (wall - self._wall) - (mono - self._mono))
self._asleep_s += slept_s
self._wall, self._mono = wall, mono
# The idle sample may predate sleep observed by these clock reads. Keep that new
# credit until the next poll; only older credit can be trimmed by renewed activity.
self._asleep_s = max(0.0, min(self._asleep_s, idle_s + slept_s))
return max(0.0, idle_s - self._asleep_s)