Files
hermes-agent/gateway/bot_loop_guard.py
Erosika 55b3ea0b11 fix(gateway): count each bot message once in the loop guard and consume the author variable
The Telegram adapter asks the authorization check before dispatch, the ingress gate asks it
again, and the busy path asks a third time. Each call counted one loop-guard event, so a
Telegram bot tripped the budget after a third of the configured messages. The verdict now only
refuses a chat that is cooling down. The ingress gate counts an admitted bot message once.

`parse_turn_author` treats only booleans, integers and the strings true/1/yes as a bot flag,
and returns None for an author with neither id nor name. Names keep format characters and
non-breaking spaces so emoji sequences survive. The quiet one-shot pops HERMES_TURN_AUTHOR
before the turn so tool subprocesses do not inherit it. `max_events` must be a whole positive
number. Issue numbers move out of code comments.
2026-09-10 10:27:07 -07:00

142 lines
5.3 KiB
Python

"""Sliding-window budget for bot-authored inbound messages.
``{PLATFORM}_ALLOW_BOTS`` only decides admission, so two Hermes profiles replying to each other never stop.
The guard counts admitted bot messages per conversation and drops further ones for ``cooldown_seconds``
once ``max_events`` land inside ``window_seconds``. Settings: config.yaml ``gateway.bot_loop_guard``.
"""
from __future__ import annotations
import threading
import time
from collections import deque
from dataclasses import dataclass
from typing import Callable, Deque, Dict, Hashable, Tuple
__all__ = ["BotLoopGuard", "BotLoopGuardSettings", "load_settings", "settings_from_config"]
_TRUTHY = frozenset({"true", "1", "yes", "on"})
_FALSY = frozenset({"false", "0", "no", "off"})
@dataclass(frozen=True)
class BotLoopGuardSettings:
enabled: bool = True
max_events: int = 20
window_seconds: float = 300.0
cooldown_seconds: float = 600.0
def _as_bool(raw, default: bool) -> bool:
if isinstance(raw, bool):
return raw
text = str(raw).strip().lower() if raw is not None else ""
if text in _TRUTHY:
return True
if text in _FALSY:
return False
return default
def _as_positive(raw, default: float) -> float:
if isinstance(raw, bool):
return default
try:
value = float(raw)
except (TypeError, ValueError):
return default
return value if value > 0 else default
def _as_positive_int(raw, default: int) -> int:
value = _as_positive(raw, 0.0)
return int(value) if value >= 1 and value == int(value) else default
def settings_from_config(cfg) -> BotLoopGuardSettings:
"""Read ``gateway.bot_loop_guard`` from a loaded config dict. Unusable values keep the default."""
from hermes_cli.config import cfg_get
block = cfg_get(cfg, "gateway", "bot_loop_guard", default=None)
if not isinstance(block, dict):
return BotLoopGuardSettings()
defaults = BotLoopGuardSettings()
return BotLoopGuardSettings(
enabled=_as_bool(block.get("enabled"), defaults.enabled),
max_events=_as_positive_int(block.get("max_events"), defaults.max_events),
window_seconds=_as_positive(block.get("window_seconds"), defaults.window_seconds),
cooldown_seconds=_as_positive(block.get("cooldown_seconds"), defaults.cooldown_seconds),
)
def load_settings() -> BotLoopGuardSettings:
"""Settings from the live config.yaml. Defaults when the config cannot be read."""
try:
from hermes_cli.config import load_config_readonly
return settings_from_config(load_config_readonly())
except Exception:
return BotLoopGuardSettings()
class BotLoopGuard:
"""Per-conversation sliding window with a cooldown once the budget trips. Thread-safe.
Settings are re-read on every call so a config.yaml edit takes effect without a restart."""
def __init__(
self,
settings: Callable[[], BotLoopGuardSettings] = load_settings,
clock: Callable[[], float] = time.monotonic,
) -> None:
self._settings = settings
self._clock = clock
self._lock = threading.Lock()
self._events: Dict[Hashable, Deque[float]] = {}
self._cooldown_until: Dict[Hashable, float] = {}
self._last_sweep = 0.0
@property
def tracked_conversations(self) -> int:
with self._lock:
return len(self._events)
def blocked(self, conversation: Hashable) -> bool:
"""True while ``conversation`` is cooling down. Reads only, so callers may ask as often as they like."""
if not self._settings().enabled:
return False
with self._lock:
return self._cooldown_until.get(conversation, 0.0) > self._clock()
def admit(self, conversation: Hashable) -> Tuple[bool, str]:
"""Count one admitted bot-authored message for ``conversation``.
Returns ``(allowed, state)``; state is ``disabled``, ``ok``, ``tripped`` (this message started the cooldown) or ``cooldown``."""
settings = self._settings()
if not settings.enabled:
return True, "disabled"
now = self._clock()
with self._lock:
self._sweep(now, settings)
if self._cooldown_until.get(conversation, 0.0) > now:
return False, "cooldown"
events = self._events.setdefault(conversation, deque())
cutoff = now - settings.window_seconds
while events and events[0] <= cutoff:
events.popleft()
if len(events) >= settings.max_events:
self._cooldown_until[conversation] = now + settings.cooldown_seconds
events.clear()
return False, "tripped"
events.append(now)
return True, "ok"
def _sweep(self, now: float, settings: BotLoopGuardSettings) -> None:
"""Drop idle conversations at most once per window so memory stays bounded."""
if now - self._last_sweep < settings.window_seconds:
return
self._last_sweep = now
idle_cutoff = now - settings.window_seconds
for key in [k for k, dq in self._events.items() if not dq or dq[-1] <= idle_cutoff]:
del self._events[key]
for key in [k for k, until in self._cooldown_until.items() if until <= now]:
del self._cooldown_until[key]