Files
hermes-agent/gateway/run.py
teknium1 8b71dc31e4 fix(state): VACUUM admission counts live holders only; argv proves the home, not the install
Independent review found the previous commit could make the unbounded-growth
symptom it fixes PERMANENT, and that its argv narrowing re-opened #92401 inside
a single install.

- other_generations_for_path() counts only LIVE generations. A retired one is
  already write-fenced (StateDbReplacedError, close-time checkpoint disabled)
  and leaves the registry only when its holder releases -- which a gateway
  handle does not do before shutdown. One inode replacement (repair swap,
  backup restore, snapshot) therefore skipped auto-VACUUM for that path for the
  whole process lifetime.
- _argv_scoped_to_other_home is ranked evidence now. argv[0] is the SHARED
  install binary for every profile on a host, so it is neutral, never proof of a
  hold; the process's own --hermes-home / HERMES_HOME= / --profile / -p
  selection decides which home it serves, and a token naming ANOTHER profile's
  store is tested before any own-prefix check. argv[0] also stops dismissing a
  holder of a store whose home is not part of an install layout (a custom
  HERMES_HOME is served BY the binary under ~/.hermes).
- install_root comes from hermes_constants.named_profile_home, not
  basename(parent) == "profiles": an arbitrary <X>/profiles/<n>/ tree no longer
  promotes all of <X> to "ours".
- The launch profile keeps its gateway.sessions_dir override when its own store
  is pruned; every other served profile prunes under its own <home>/sessions.
  Pruning under the wrong dir orphaned transcripts forever.
- glob.escape on the request_dump_<id>_* sweep (pre-existing).

Tests: the retired-generation test asserted the starvation mechanism; it is
replaced by the invariant (a live sibling defers VACUUM) plus a red-on-base test
that a retired, write-fenced generation does not. New argv cases cover the
shared binary with -p other, another profile's store token, and a non-Hermes
<X>/profiles/ tree; the housekeeping fixture now asserts the unpinned store
still resolves inside the sandbox before yielding.
2026-09-21 02:56:31 -07:00

5820 lines
303 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Gateway runner - entry point for messaging platform integrations.
Provides ``start_gateway()`` (start all configured adapters) and ``GatewayRunner`` (lifecycle).
Run via ``python -m gateway.run`` or ``python cli.py --gateway``."""
# hermes_bootstrap must be the very first import (UTF-8 stdio on Windows; no-op on POSIX).
try:
import hermes_bootstrap # noqa: F401
except ModuleNotFoundError:
pass # a partial ``hermes update`` can leave the bootstrap unregistered; only Windows UTF-8 stdio suffers
import asyncio
import concurrent.futures
import dataclasses
import json
import logging
import os
import re
import shlex
import site
import sys
import signal
import threading
import time
import traceback
from collections import OrderedDict
from contextvars import copy_context
from pathlib import Path
from datetime import datetime
from typing import Callable, Dict, Optional, Any, List, Tuple, cast
from agent.async_utils import safe_schedule_threadsafe
from agent.conversation_compression import (
COMPACTION_DONE_STATUS, COMPACTION_HEARTBEAT_STATUS, COMPACTION_STATUS, COMPRESSION_RETRY_CONTEXT_REDUCED_STATUS_TEMPLATE,
COMPRESSION_RETRY_MESSAGES_STATUS_TEMPLATE, COMPRESSION_RETRY_TOKENS_STATUS_TEMPLATE,
COMPRESSION_RETRY_TOO_LARGE_STATUS_TEMPLATE, IDLE_COMPACTION_STATUS_TEMPLATE,
PRE_API_COMPRESSION_STATUS_TEMPLATE, PREFLIGHT_COMPRESSION_STATUS_TEMPLATE)
from agent.conversation_loop import INTERRUPT_WAITING_FOR_MODEL_PREFIX
from agent.interrupt_compat import request_hard_interrupt
from agent.turn_context import compression_made_progress
from agent.session_activity import ActivityProvenance
from hermes_cli.config import _is_ssh_remote_tilde_cwd, cfg_get
from hermes_cli.fallback_config import pre_agent_fallback_notice
# Per-session AIAgent cache bounds (agents are heavy); see _enforce_agent_cache_cap/_session_housekeeping_watcher.
_AGENT_CACHE_MAX_SIZE = 128
_AGENT_CACHE_IDLE_TTL_SECS = 3600.0 # evict agents idle for >1h
_PLATFORM_CONNECT_TIMEOUT_SECS_DEFAULT = 30.0
# Telegram connect proves a real getUpdates round trip; must cover polling-start deadlines + readiness.
_TELEGRAM_CONNECT_TIMEOUT_SECS_DEFAULT = 180.0
# The initial Telegram connect gates `running` for EVERY platform, so it must not spend the full 180s.
# Cold-start cap for Telegram (#85993): the initial connect awaited before the gateway reaches `running`
# must not spend the full 180s budget — an unreachable Telegram would hold EVERY platform's serving state
# hostage for the whole window. The initial attempt gets one bounded try; on timeout the platform is queued
# for the reconnect watcher, which retries with the full 180s budget (is_reconnect=True preserves the
# offline update queue, #46621).
_TELEGRAM_INITIAL_CONNECT_TIMEOUT_SECS_DEFAULT = 45.0
_ADAPTER_DISCONNECT_TIMEOUT_SECS_DEFAULT = 5.0
# End reasons meaning the USER deliberately closed this thread. Shared by _classify_completion_target and
# _resolve_async_delegation_session so they never disagree (else a "delivered" reason is acked, then lost).
_USER_BOUNDARY_END_REASONS = ("session_reset", "user_exit", "session_switch", "new_session")
# Bounds one stall-notify send so a wedged transport can't block the watcher; on timeout the next tick retries.
_STALL_NOTIFY_SEND_TIMEOUT_SECONDS = 15.0
_GATEWAY_PROXY_SSE_BUFFER_MAX_CHARS = 16 * 1024 * 1024
_TELEGRAM_COMMAND_MENTION_RE = re.compile(r"(?<![\w:/])/([A-Za-z0-9][A-Za-z0-9_-]*)")
_GATEWAY_HYGIENE_PLATFORM = "gateway_hygiene"
_TELEGRAM_NOISY_STATUS_RE = re.compile(
r"(" # transient/auxiliary status that should stay in logs, not gateway chats
r"auxiliary\s+.+\s+failed"
r"|compression\s+summary\s+failed"
r"|fallback\s+context\s+marker"
r"|configured\s+compression\s+model\s+.+\s+failed"
r"|no\s+auxiliary\s+llm\s+provider\s+configured"
r"|auto-lowered\s+compression\s+threshold"
# the auto-lower notice was reworded to "Auto-lowered this session's threshold..." — cover both.
# See #69332.
r"|auto-lowered\s+(?:this\s+)?session'?s?\s+threshold"
r"|configured\s+auxiliary\s+compression\s+provider\s+.+\s+unavailable"
r"|skipping\s+concurrent\s+compression"
rf"|{re.escape(COMPACTION_STATUS)}"
rf"|{re.escape(COMPACTION_HEARTBEAT_STATUS)}"
r"|resumed\s+after\s+\d+s\s+idle\s+[—-]\s+compacting"
r"|preflight\s+compression"
r"|pre[- ]api\s+compression"
# Retry chatter via _emit_status; ", retrying"/"— compressing" anchors exclude manual /compress feedback.
r"|context\s+too\s+large\s+\(~[\d,]+\s+tokens\)\s+[—-]+\s+compressing"
r"|compressed\s+\d[\d,]*\s+(?:→|->)\s+\d[\d,]*\s+messages,\s+retrying"
r"|compressed\s+~[\d,]+\s+(?:→|->)\s+~[\d,]+\s+tokens,\s+retrying"
r"|context\s+reduced\s+to\s+[\d,]+\s+tokens\s+\(was\s+[\d,]+\),\s+retrying"
r"|session\s+compressed\s+\d+\s+times"
r"|rate\s+limited\.\s+waiting\s+\d"
r"|retrying\s+in\s+\d"
r"|max\s+retries\s+\(\d+\).*(?:trying\s+fallback|exhausted|invalid\s+responses)"
r"|stream\s+(?:drop|drop\s+mid\s+tool-call).+retry\s+\d"
r"|stale\s+connections\s+from\s+a\s+previous\s+provider\s+issue"
rf"|{re.escape(COMPACTION_DONE_STATUS)}"
r")",
re.IGNORECASE | re.DOTALL)
_HYGIENE_COOLDOWN_LADDER_MULTIPLIERS = (1, 3, 9)
# Ceiling on an escalated cooldown (cf. _RECONNECT_BACKOFF_CAP): base × ladder can reach 9h ≈ "compaction off".
_HYGIENE_COOLDOWN_MAX_SECONDS = 3600.0
# Flat retry-after when hygiene is ABANDONED by turn-hold expiry (not a failure: outside the streak ladder).
_HYGIENE_TURNHOLD_RETRY_SECONDS = 60.0
def _gateway_session_db_inner(gateway):
"""The raw SessionDB behind ``gateway._session_db`` (unwrapping the async facade), or None."""
session_db = getattr(gateway, "_session_db", None)
return getattr(session_db, "_db", session_db)
def _hygiene_cooldown_for_failure(gateway, session_key: str, base_cooldown_seconds: float) -> float:
"""Bump the hygiene failure streak and return the escalated cooldown (x1/x3/x9 over base, clamped).
Hygiene's per-run ``AIAgent`` is fresh, so the streak lives in SQLite keyed by rotation-stable session_key.
It exists because the in-agent equivalent is unreachable from here:
``ContextCompressor.record_timeout_failure`` escalates on an absolute 60 -> 300 -> 900s ladder driven by
the in-memory ``_consecutive_timeout_failures`` counter, which ``bind_session_state`` zeroes. Session
hygiene constructs a FRESH ``AIAgent`` per run and re-binds state every time, so from the gateway that
streak is structurally always 0 and only the flat ``hygiene_failure_cooldown_seconds`` could ever be
recorded — a session whose summary model always times out retried on that same fixed interval forever
(#79624). The streak is mirrored to SQLite by rotation-stable ``session_key`` so it outlives both the
per-run agent and gateway restarts; ``PersistentState`` keeps the hot in-process view.
"""
streak, state = 1, None
try:
state = gateway._session_state(session_key).persistent
except Exception as exc:
logger.debug("hygiene failure streak update failed: %s", exc)
increment = getattr(_gateway_session_db_inner(gateway), "increment_hygiene_failure_streak", None)
if callable(increment):
try:
streak = max(1, int(increment(session_key)))
if state is not None:
state.hygiene_failure_streak = streak
except Exception as exc:
logger.debug("hygiene failure streak persist failed: %s", exc)
if state is not None:
state.hygiene_failure_streak += 1
streak = state.hygiene_failure_streak
elif state is not None:
state.hygiene_failure_streak += 1
streak = state.hygiene_failure_streak
multiplier = _HYGIENE_COOLDOWN_LADDER_MULTIPLIERS[
min(streak, len(_HYGIENE_COOLDOWN_LADDER_MULTIPLIERS)) - 1]
return min(base_cooldown_seconds * multiplier, _HYGIENE_COOLDOWN_MAX_SECONDS)
def _reset_hygiene_failure_streak(gateway, session_key: str) -> None:
"""Clear the hygiene failure streak after a compression that reduced context.
Peeks, never get-or-creates: a no-op 0 write must not create a never-evicted ``_sessions`` row."""
try:
state = gateway._peek_session_state(session_key)
if state is not None:
state.persistent.hygiene_failure_streak = 0
except Exception as exc:
logger.debug("hygiene failure streak reset failed: %s", exc)
reset = getattr(_gateway_session_db_inner(gateway), "reset_hygiene_failure_streak", None)
if callable(reset):
try:
reset(session_key)
except Exception as exc:
logger.debug("hygiene failure streak persistent reset failed: %s", exc)
def hygiene_compaction_recovered(
*, aborted: bool, rotated: bool, in_place: bool, msg_count: int, new_count: int,
approx_tokens: int, new_tokens: int) -> bool:
"""True when a hygiene run actually recovered the session (extracted to be unit testable).
Requires no abort, a real rewrite (the no-op path reuses pre-compression counts) and material shrink per
:func:`compression_made_progress` (a bare ``<`` misses row-count wins and counts estimate noise).
* the compressor did not abort (no summary produced at all); * the transcript was actually rewritten —
either rotated into a new session or compacted in place. The degenerate "did not rotate or compact in
place" path (#21301) reuses the pre-compression counts, so relying on the numbers alone would read a
no-op as success; * the request materially shrank, per the canonical :func:`compression_made_progress`
(#39548) — a row-count drop counts even when the summary keeps the token estimate flat, and a sub-5%
token wobble does not count at all.
"""
if aborted or not (rotated or in_place):
return False
return compression_made_progress(msg_count, new_count, approx_tokens, new_tokens)
def _hygiene_compression_timeout_message(
*, total_exhausted: bool, elapsed: float, idle_timeout: float, progress_observed: bool) -> str:
"""Describe the host timeout that actually ended hygiene compression. Chat users cannot edit
model config, so the copy names /compress, /new and `hermes doctor`, never a config key or the
raw second counts (those stay in the gateway log)."""
lead = (
"⚠️ Shortening the conversation history took too long, so I skipped it and kept "
"everything as-is. Run /compress to try again or /new to start fresh.")
if total_exhausted:
return lead
return lead + " If this keeps happening, run `hermes doctor` on the host."
def _cached_agent_for_hygiene(gateway, session_key: str):
"""The cached live AIAgent for ``session_key`` (or the pending sentinel / None), read under the cache lock."""
cache = getattr(gateway, "_agent_cache", None)
if cache is None:
return None
lock = getattr(gateway, "_agent_cache_lock", None)
try:
with (lock or suppress()):
entry = cache.get(session_key)
except Exception:
entry = None
return entry[0] if isinstance(entry, tuple) and entry else entry
async def run_codex_hygiene_compaction(
gateway, session_key: str, session_id: str, *, auto_mode: str, history: list,
approx_tokens: int, timeout_seconds: float, failure_cooldown_seconds: float = 300.0) -> str:
"""Session hygiene for ``codex_app_server`` sessions.
The real context is the server-side thread; the local transcript is a never-replayed mirror, so rewriting
it shrinks nothing and evicting the live agent starts the next turn on an EMPTY thread. So: compact the LIVE
agent via ``thread/compact/start``, keep it cached, never build a detached compressor. ``native``/``off``
skip without local fallback. Returns ``compacted``, ``skipped:<reason>`` or ``failed:<reason>``.
See #73503.
* Evicting the cached live agent afterwards destroys the only real context: the next turn spawns an
EMPTY thread and the model starts blank while Hermes still mirrors a full history (abrupt amnesia — the
user-facing damage documented on #73503).
"""
mode = str(auto_mode or "native").lower()
if mode not in {"native", "hermes", "off"}:
mode = "native"
if mode != "hermes":
# native = app-server compacts itself; off = operator disabled. Local fallback can't shrink the thread.
return f"skipped:mode={mode}"
agent = _cached_agent_for_hygiene(gateway, session_key)
if agent is None or agent is _AGENT_PENDING_SENTINEL:
# No live agent → no live thread; a detached mirror-only rewrite is the no-op this exists to remove.
return "skipped:no-cached-agent"
if getattr(agent, "_codex_session", None) is None:
return "skipped:no-live-thread"
compressor = getattr(agent, "context_compressor", None)
count_before = getattr(compressor, "compression_count", 0)
# copy_context carries profile secret scope / HERMES_HOME override (executors don't propagate ContextVars).
worker_future = asyncio.get_running_loop().run_in_executor(
None, copy_context().run,
lambda: agent._compress_context(history, "", approx_tokens=approx_tokens, task_id=session_id or "default"))
track_worker = getattr(gateway, "_track_deferred_agent_worker", None)
if callable(track_worker):
# ``wait_for`` only cancels the asyncio wrapper; keep the running executor thread visible to shutdown.
track_worker(worker_future, agent)
try:
await asyncio.wait_for(asyncio.shield(worker_future), timeout=max(float(timeout_seconds), 1.0))
except asyncio.TimeoutError:
# Executor thread keeps running (own RPC timeouts); brake retries so a wedged app-server isn't re-hit.
if failure_cooldown_seconds >= 0:
_record_hygiene_cooldown(
gateway, session_id, failure_cooldown_seconds, "codex app-server thread compaction timed out")
logger.warning(
"Session hygiene: codex app-server thread compaction for "
"session %s timed out after %.1fs; continuing without compaction",
session_id, timeout_seconds)
return "failed:timeout"
except Exception as exc:
logger.warning(
"Session hygiene: codex app-server thread compaction for session %s failed: %s", session_id, exc)
return f"failed:{exc}"
count_after = getattr(compressor, "compression_count", 0)
if count_after > count_before:
# Native boundary recorded: compacted server-side; mirror NOT rewritten, agent stays cached.
_reset_hygiene_failure_streak(gateway, session_key)
return "compacted"
# No boundary: internal skip or compaction error; the codex route already persisted its own cooldown.
return "failed:no-boundary"
def hygiene_wait_should_extend(
*, idle: float, timeout: float, waited: float, ceiling: float, fence_cancelled: bool = False
) -> bool:
"""Whether the hygiene host should keep waiting for a slow summary.
A cancelled commit fence cannot commit: extending only queues inbound messages behind a doomed attempt.
Stop extending immediately so the turn can continue. See #96953.
"""
return not fence_cancelled and idle < timeout and waited < ceiling
def _record_hygiene_cooldown(
gateway, session_id: str, cooldown_seconds: float, error: Optional[str] = None) -> None:
"""Persist a session-hygiene compression-failure cooldown to the state DB (survives restarts).
``error`` must be forwarded: the recorder writes compression_failure_error UNCONDITIONALLY (NULL clobber).
Uses the same ``compression_failure_cooldown_until`` column and ``record_compression_failure_cooldown``
method that the in-conversation compression path (``agent/context_compressor.py``) already uses, so the
cooldown survives gateway restarts (#74136).
"""
recorder = getattr(_gateway_session_db_inner(gateway), "record_compression_failure_cooldown", None)
if recorder is None:
return
try:
recorder(session_id, time.time() + cooldown_seconds, error)
except Exception as exc:
logger.debug("session hygiene cooldown persist failed: %s", exc)
def _status_template_to_regex(template: str) -> str:
"""Compile a compression status template constant into a regex source.
Literal text is escaped verbatim (wording drift can't diverge from the matcher); ``{field}`` -> numeric."""
parts = re.split(r"\{[^{}]*\}", template)
return r"[\d,]+".join(re.escape(part) for part in parts)
# ROUTINE compression progress statuses, derived from the SAME template constants the emit sites format.
# Used ONLY by the opt-in ``compression.progress_notices`` gate below (#52995) to decide which of the noisy
# statuses matched by _TELEGRAM_NOISY_STATUS_RE are compression progress (deliverable when the user opted
# in) versus unrelated aux/retry chatter (always suppressed on chat surfaces). Failure notices and manual
# /compress feedback never match _TELEGRAM_NOISY_STATUS_RE in the first place, so they are unaffected by
# this gate.
_COMPRESSION_PROGRESS_STATUS_RE = re.compile(
"|".join(
_status_template_to_regex(_template)
for _template in (
COMPACTION_STATUS, COMPACTION_HEARTBEAT_STATUS, COMPACTION_DONE_STATUS, PRE_API_COMPRESSION_STATUS_TEMPLATE,
PREFLIGHT_COMPRESSION_STATUS_TEMPLATE, IDLE_COMPACTION_STATUS_TEMPLATE,
COMPRESSION_RETRY_TOO_LARGE_STATUS_TEMPLATE, COMPRESSION_RETRY_MESSAGES_STATUS_TEMPLATE,
COMPRESSION_RETRY_TOKENS_STATUS_TEMPLATE,
COMPRESSION_RETRY_CONTEXT_REDUCED_STATUS_TEMPLATE)),
re.IGNORECASE)
def _gateway_compression_progress_notices_enabled() -> bool:
"""True when ``compression.progress_notices`` is on (default False: chat is silent by design).
Read live (mtime-cached) so a config edit applies at the next status; fail-closed on read error.
Reads ``compression.progress_notices`` from the gateway's raw YAML config (#52995).
"""
try:
config = _load_gateway_config()
compression_cfg = config.get("compression") if isinstance(config, dict) else None
if isinstance(compression_cfg, dict):
return str(compression_cfg.get("progress_notices", False)).strip().lower() in {
"true", "1", "yes", "on"}
except Exception:
pass
return False
# Surfaces consuming gateway text programmatically must keep RAW status/error text; unknown/empty -> chat.
_GATEWAY_RAW_TEXT_PLATFORMS = frozenset({"local", "api_server", "webhook", "msgraph_webhook"})
def _gateway_surface_passes_raw_text(platform: Any) -> bool:
"""True only for programmatic/local surfaces that must keep raw text."""
return _gateway_platform_value(platform) in _GATEWAY_RAW_TEXT_PLATFORMS
_GATEWAY_PROVIDER_POLICY_RE = re.compile(
r"(" # raw provider policy/safety bodies are noisy and may be sensitive
r"cybersecurity\s+risk"
r"|security\s+policy"
r"|safety\s+policy"
r"|policy\s+violation"
r"|violat(?:e|es|ed|ion)"
r"|blocked\s+(?:because|by|under)"
r"|request\s+(?:was\s+)?(?:blocked|rejected)"
r"|disallowed"
r"|moderation"
r")",
re.IGNORECASE)
# ``401`` as a status token: not glued to a digit or a timestamp/identifier separator on the left
# (``05:14:15,401``), but trailing punctuation is a real envelope (``HTTP 401: Unauthorized``,
# ``returned 401.``) and must keep matching (#89401).
_GATEWAY_AUTH_ERROR_RE = re.compile(
r"(provider\s+authentication\s+failed|incorrect\s+api\s+key|invalid\s+api\s+key"
r"|(?<![\d:,.])401(?!\d))",
re.IGNORECASE)
_GATEWAY_RATE_LIMIT_RE = re.compile(
r"(rate\s+limit|rate-limited|\b429\b|quota|usage\s+limit)", re.IGNORECASE)
# Connection-failure markers: the first 8 also anchor the provider-failure envelope shape below.
_CONNECTION_ERROR_MARKERS = (
r"(?:\w+\.)?(?:api\s*)?connection\s*(?:error|timeout)", r"(?:\w+\.)?connect\s*(?:error|timeout)",
r"connection\s+refused", r"connection\s+reset", r"connection\s+aborted", r"actively\s+refused",
r"winerror\s+10061\b", r"errno\s+111\b", r"no\s+route\s+to\s+host", r"network\s+is\s+unreachable",
r"cannot\s+connect", r"failed\s+to\s+establish", r"could\s+not\s+connect")
_GATEWAY_CONNECTION_ERROR_RE = re.compile("(" + "|".join(_CONNECTION_ERROR_MARKERS) + ")", re.IGNORECASE)
# An ESTABLISHED connection died mid-request. Says nothing about whether the endpoint is up:
# an earlier call in the same turn may already have been answered by it (#26339, #116323).
_CONNECTION_INTERRUPTED_MARKERS = (
r"connection\s+reset", r"connection\s+aborted", r"errno\s+104\b", r"errno\s+103\b",
r"broken\s+pipe", r"server\s+disconnected", r"peer\s+closed\s+connection",
r"connection\s+was\s+closed", r"network\s+connection\s+lost", r"unexpected\s+eof",
r"incomplete\s+chunked\s+read", r"response\s+ended\s+prematurely", r"socket\s+hang\s+up",
r"(?:\w+\.)?remoteprotocolerror", r"(?:\w+\.)?readerror")
_GATEWAY_CONNECTION_INTERRUPTED_RE = re.compile(
"(" + "|".join(_CONNECTION_INTERRUPTED_MARKERS) + ")", re.IGNORECASE)
# Nothing accepted the connection / no path to the host: "the endpoint is not up" IS the diagnosis.
_ENDPOINT_UNREACHABLE_MARKERS = (
r"connection\s+refused", r"actively\s+refused", r"winerror\s+10061\b", r"errno\s+111\b",
r"no\s+route\s+to\s+host", r"network\s+is\s+unreachable", r"cannot\s+connect",
r"failed\s+to\s+establish", r"could\s+not\s+connect", r"(?:\w+\.)?connect\s*(?:error|timeout)")
_GATEWAY_ENDPOINT_UNREACHABLE_RE = re.compile(
"(" + "|".join(_ENDPOINT_UNREACHABLE_MARKERS) + ")", re.IGNORECASE)
def _ensure_windows_gateway_venv_imports() -> None:
"""Make detached Windows gateway runs see the Hermes venv packages.
Patched before MCP discovery so tool injection does not depend on launchers preserving PYTHONPATH."""
if sys.platform != "win32":
return
project_root = Path(__file__).resolve().parent.parent
candidates: list[Path] = []
if os.environ.get("VIRTUAL_ENV"):
candidates.append(Path(os.environ["VIRTUAL_ENV"]))
candidates.append(project_root / "venv")
seen: set[str] = set()
for venv_dir in candidates:
try:
resolved_venv = venv_dir.resolve()
except OSError:
resolved_venv = venv_dir
venv_key = str(resolved_venv).lower()
if venv_key in seen:
continue
seen.add(venv_key)
site_packages = resolved_venv / "Lib" / "site-packages"
if not site_packages.exists():
continue
project_entry = str(project_root)
site_entry = str(site_packages)
if project_entry not in sys.path:
sys.path.insert(0, project_entry)
# addsitedir semantics matter: pywin32 (MCP SDK on Windows) needs .pth processing for pywintypes.
site.addsitedir(site_entry)
if site_entry in sys.path:
sys.path.remove(site_entry)
insert_at = 1 if sys.path and sys.path[0] == project_entry else 0
sys.path.insert(insert_at, site_entry)
os.environ["VIRTUAL_ENV"] = str(resolved_venv)
pythonpath = [project_entry, site_entry]
if os.environ.get("PYTHONPATH"):
pythonpath.append(os.environ["PYTHONPATH"])
os.environ["PYTHONPATH"] = os.pathsep.join(dict.fromkeys(pythonpath))
return
def _gateway_platform_value(platform: Any) -> str:
"""Return a normalized gateway platform value for enums or raw strings."""
return str(getattr(platform, "value", platform) or "").strip().lower()
def _non_conversational_metadata(
metadata: Optional[Dict[str, Any]] = None, *, platform: Any = None) -> Optional[Dict[str, Any]]:
"""Mark Discord lifecycle/status sends without changing other platforms."""
if _gateway_platform_value(platform) != "discord":
return metadata
merged = dict(metadata or {})
merged["non_conversational"] = True
return merged
def _interim_metadata(metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Mark a mid-turn status/advisory send as NOT the turn-final.
Stream-is-the-message adapters seal the live stream with the first unmarked send to an armed (chat, turn)
key, so every mid-turn send MUST carry this marker. Gateway-internal; adapters strip it before the wire."""
merged = dict(metadata or {})
merged["_interim_send"] = True
return merged
def _seed_hygiene_system_prompt(agent: Any, session_row: Optional[Dict[str, Any]]) -> bool:
"""Keep gateway hygiene from rebuilding a live session's system prompt.
Hygiene lacks the live prompt environment, so a rebuild (persisted by compression) would strip external
provider blocks. Seed the persisted prompt (or an empty cache entry); the real turn rebuilds properly."""
stored_prompt = ""
if isinstance(session_row, dict):
raw_prompt = session_row.get("system_prompt")
if isinstance(raw_prompt, str) and raw_prompt.strip():
stored_prompt = raw_prompt
agent._cached_system_prompt = stored_prompt
return bool(stored_prompt)
_TRANSIENT_NETWORK_ERROR_CLASS_NAMES = frozenset({
"TimedOut", "NetworkError", "ReadError", "WriteError", "ConnectError", "ConnectTimeout",
"ReadTimeout", "WriteTimeout", "PoolTimeout", "RemoteProtocolError", "ServerDisconnectedError",
"ClientConnectorError", "ClientOSError"})
def _is_transient_network_error(exc: BaseException) -> bool:
"""True for transient network errors safe to log + swallow (the next poll recovers; never crash).
Walks the cause chain so wrapped errors (PTB ``NetworkError`` over ``httpx.ConnectError``) match.
The crash class targeted by #31066 / #31110: an unhandled Telegram ``TimedOut`` (or peer
``NetworkError`` / ``httpx`` connection error) propagating to the event loop and killing the entire
gateway process. These are by definition transient — the next poll cycle or user action recovers — so
they must never crash the process.
"""
seen: set[int] = set()
cur: Optional[BaseException] = exc
depth = 0
while cur is not None and depth < 12:
ident = id(cur)
if ident in seen:
break
seen.add(ident)
depth += 1
if type(cur).__name__ in _TRANSIENT_NETWORK_ERROR_CLASS_NAMES:
return True
cur = cur.__cause__ or cur.__context__
return False
def _gateway_loop_exception_handler(
loop: "asyncio.AbstractEventLoop", context: Dict[str, Any]) -> None:
"""Loop-level safety net for transient network errors (installed once by ``start_gateway``).
Logs WARNING with traceback; non-transient errors go to the default handler so real bugs surface.
Catches the ``telegram.error.TimedOut`` crash class (issues #31066 / #31110) and any peer transient
network error before it can kill the gateway process.
"""
exc = context.get("exception")
if exc is not None and _is_transient_network_error(exc):
task = context.get("future") or context.get("task")
task_name = ""
if task is not None:
try:
task_name = task.get_name() if hasattr(task, "get_name") else repr(task)
except Exception:
task_name = repr(task)
logger.warning(
"Gateway swallowed transient network error from %s: %s: %s", task_name or "<unknown task>",
type(exc).__name__, exc, exc_info=(type(exc), exc, exc.__traceback__))
return
loop.default_exception_handler(context)
def _redact_gateway_user_facing_secrets(text: str) -> str:
"""Secret redaction before text can leave the gateway for a chat platform: the shared egress scrub
(``force=True`` holds even when ``security.redact_secrets`` is off; fails closed). See #23810."""
from agent.redact import redact_for_egress
return redact_for_egress(text)
def _redact_approval_command(cmd: "str | None") -> str:
"""Redact credentials from a command before it goes into an approval prompt.
Else a Tirith-flagged credential echoes verbatim to chat; ``force=True`` holds even with redaction off.
Tirith's *findings* are already redacted, but the gateway approval prompt is built from the raw command
string, so a credential-shaped value Tirith flagged would otherwise be echoed verbatim to the chat
platform (#48456). Uses ``redact_sensitive_text(force=True)`` — the same Tirith-grade redactor — so the
prompt honors redaction even when ``security.redact_secrets`` is off. Module-level so the wiring is
unit-testable (the call site is a deeply nested gateway closure that cannot be driven directly).
"""
from agent.redact import redact_sensitive_text
return redact_sensitive_text(str(cmd or ""), force=True)
def _format_exec_approval_fallback(
command: str, description: str, command_prefix: str, *, allow_permanent: bool = True,
allow_session: bool = True, smart_denied: bool = False) -> str:
"""Render the text fallback from approval capabilities, not platform names. Same words as
the button card (``BasePlatformAdapter._format_exec_approval``), plus the typed ``/approve``
steps a surface without buttons needs."""
from gateway.platforms.base_exec_approval import (
EA_HEADER_TEXT, EA_REASON_LABEL_TEXT, approval_timeout_seconds, format_approval_deadline_line)
cmd_preview = command[:200] + "..." if len(command) > 200 else command
heading = ("⚠️ **Smart DENY — owner override for one operation:**" if smart_denied
else f"⚠️ **{EA_HEADER_TEXT}**")
choices = [f"Reply `{command_prefix}approve` to run it once"]
if not smart_denied and allow_session:
choices.append(f"`{command_prefix}approve session` to allow this pattern for the rest of this session")
if allow_permanent:
choices.append(f"`{command_prefix}approve always` to allow it permanently")
choices.append(f"`{command_prefix}deny` to cancel")
return (
f"{heading}\n```\n{cmd_preview}\n```\n{EA_REASON_LABEL_TEXT}: {description}\n\n"
+ ", ".join(choices[:-1]) + f", or {choices[-1]}.\n"
+ format_approval_deadline_line(approval_timeout_seconds()))
# Ordered: rate-limit beats auth beats policy beats connection; first match wins. Rate-limit goes
# first because a quota/429 envelope often also carries an auth-shaped preamble ("Provider
# authentication failed: ... quota exhausted (429) ... Credentials are still valid") and re-auth can
# never fix a quota, so text with both signals must fail safe toward the quota reply (#89401). Copy
# names the slash command the chat user can run; raw provider text stays in the gateway log.
#
# The three connection rows are not interchangeable (#116323): a RESET/EOF on an established
# connection says nothing about whether the endpoint is up (an earlier call in the same turn may have
# been answered by it), a REFUSED/unroutable connect is the endpoint-down case #86570 wrote the
# wording for, and a cause-free SDK ``APIConnectionError: Connection error.`` supports neither
# diagnosis, so the catch-all names the failure without asserting a cause.
_PROVIDER_ERROR_REPLIES = (
(_GATEWAY_RATE_LIMIT_RE, "⏱️ The AI model service is rate-limiting requests. Wait a moment, then use /retry."),
(_GATEWAY_AUTH_ERROR_RE, "⚠️ Sign-in to the AI model service failed. Use /login to sign in again, "
"or ask whoever runs this bot to run `hermes doctor` on the host."),
(_GATEWAY_PROVIDER_POLICY_RE, "⚠️ The AI model service rejected this request. Try rephrasing your "
"message, or use /model to switch models."),
(_GATEWAY_CONNECTION_INTERRUPTED_RE, "⚠️ The connection to the AI model service was interrupted mid-request — "
"usually transient. Use /retry to try again; if it keeps happening, run "
"`hermes doctor` on the host."),
(_GATEWAY_ENDPOINT_UNREACHABLE_RE, "⚠️ The AI model service isn't reachable right now — the configured model "
"endpoint is not running or is unreachable. Wait a moment and use /retry; "
"if it persists, run `hermes doctor` on the host."),
(_GATEWAY_CONNECTION_ERROR_RE, "⚠️ Hermes could not reach the AI model service (no further detail from the "
"SDK). Use /retry to try again; if it persists, run `hermes doctor` on the host."))
# Shared by the failed-turn normalizer and ``run_turn._hmwa_agent_error_reply``; canonical
# commands (/compress, /new) — the /compact and /reset aliases are absent from /help.
_CONTEXT_OVERFLOW_REPLY = (
"⚠️ This conversation has grown too long for me to read all at once. "
"Use /compress to shorten the history, or /new to start a fresh conversation.")
def _rate_limit_reply(text: str) -> str:
"""Name the reset window a quota 429 carries (``resets_in_seconds`` body field, the credential
pool's ``retry after Ns``, ``resets in 4hr``) so a weekly cap is not sold as "wait a moment"
(#89401). One grammar table with the retry loop: ``agent.retry_utils.RETRY_DELAY_PATTERNS``."""
from agent.retry_utils import format_reset_window, reset_delay_from_message
seconds = reset_delay_from_message(text) or 0
if seconds < 120:
return "⏱️ The AI model service is rate-limiting requests. Wait a moment, then use /retry."
return (f"⏱️ The AI model service's usage limit is reached; it resets in {format_reset_window(seconds)}. "
"Use /retry after that, or /model to switch models.")
def _gateway_provider_error_reply(text: str) -> str:
"""Map raw provider/API errors to a short user-safe Telegram reply."""
for pattern, reply in _PROVIDER_ERROR_REPLIES:
if pattern.search(text):
return _rate_limit_reply(text) if pattern is _GATEWAY_RATE_LIMIT_RE else reply
return (
"⚠️ The AI model service kept failing. Use /retry to try again, or /model to switch "
"models. Details are in the gateway log (`hermes logs`).")
# Provider/API failure envelope preambles (not ordinary assistant prose), anchored at line start.
_PROVIDER_ERROR_MARKERS = (
r"api\s+(?:call\s+)?failed", r"provider\s+authentication\s+failed", r"non-retryable\s+error",
r"rate\s+limited\s+after\s+\d+\s+retries", r"error\s+code\s*:", r"http\s*\d{3}\b",
r"incorrect\s+api\s+key", r"invalid\s+api\s+key")
_GATEWAY_PROVIDER_ERROR_SHAPE_RE = re.compile(
r"^\s*(\W*\s*)?("
+ "|".join(_PROVIDER_ERROR_MARKERS + _CONNECTION_ERROR_MARKERS[:8] + (r"all\s+connection\s+attempts\s+failed",))
+ ")",
re.IGNORECASE)
def _looks_like_gateway_provider_error(text: str) -> bool:
"""True when text is a provider failure envelope, not normal content.
Must be short (envelopes are 1-3 lines) AND start with the marker, so prose citing a status code misses."""
if not text:
return False
body = str(text).strip()
if len(body) > 400 or body.count("\n") > 4:
return False
return bool(_GATEWAY_PROVIDER_ERROR_SHAPE_RE.search(body))
def _sanitize_gateway_final_response(platform: Any, text: str) -> str:
"""Sanitize final gateway replies for chat surfaces: concise, secret-redacted provider failure
categories instead of raw HTTP bodies, request IDs, leaked credentials, or policy text."""
if not text or _gateway_surface_passes_raw_text(platform):
return text
# Lone UTF-16 surrogates make Telegram/Signal ``.encode()`` raise; last defense for legacy/plugin paths.
# Lone UTF-16 surrogates (U+D800–U+DFFF) in model output crash chat surfaces downstream: Telegram's
# ``utf16_len`` length check and Signal formatting both ``.encode()`` the reply and raise
# UnicodeEncodeError before any send (#55143, #55309). The stored-history copy is already sanitized by
# ``build_assistant_message`` and ``finalize_turn`` scrubs the returned ``final_response``, but this
# boundary is the last line of defense for every legacy/plugin delivery path that hands us raw text.
# Raw-text/programmatic surfaces above keep passthrough — their JSON consumers escape surrogates safely.
from agent.message_sanitization import _sanitize_surrogates
text = _sanitize_surrogates(str(text))
# Some OpenAI-compatible providers leak their exact end-of-sequence control token into
# ``final_response`` even though finish_reason is already ``stop``. It is transport metadata,
# not an assistant message; without filtering, chat adapters send a literal ``<|eos|>`` bubble.
# Reuse the MEDIA boundary's exact, terminal-only recognizer (#111046 / #111348): examples
# mentioning the token mid-response and non-exact variants remain byte-identical.
_eos_start = _terminal_sentinel_start(text)
if _eos_start >= 0:
text = text[:_eos_start].rstrip()
# Cancellation metadata, not prose; ACP/TUI already suppress this sentinel, chat surfaces should too.
# See #7921.
if str(text).strip().startswith(INTERRUPT_WAITING_FOR_MODEL_PREFIX):
return ""
redacted = _redact_gateway_user_facing_secrets(str(text))
if _looks_like_gateway_provider_error(redacted):
return _gateway_provider_error_reply(redacted)
return redacted
def _prepare_gateway_status_message(platform: Any, event_type: str, message: str) -> Optional[str]:
"""Filter/sanitize agent status callbacks before platform delivery.
Local/CLI keep the raw diagnostic stream; messaging surfaces drop transient aux/compression noise."""
text = str(message or "").strip()
if not text:
return None
if _gateway_surface_passes_raw_text(platform):
return text
text = _redact_gateway_user_facing_secrets(text)
# Opt-in `compression.progress_notices` lets ROUTINE (template-derived) progress through; other noise stays.
if _TELEGRAM_NOISY_STATUS_RE.search(text) and not (
_gateway_compression_progress_notices_enabled() and _COMPRESSION_PROGRESS_STATUS_RE.search(text)
):
return None
if _looks_like_gateway_provider_error(text):
return _gateway_provider_error_reply(text)
return text
def render_notice_line(notice) -> str:
"""Render an AgentNotice to a single plaintext line (messaging has no status bar: one-shot push).
The level glyph is already baked into the text (prepending would DOUBLE it); malformed/empty -> ""."""
return str(getattr(notice, "text", "") or "").strip()
async def _send_or_update_status_coro(adapter, chat_id, status_key, content, metadata):
"""Route a status through adapter.send_or_update_status when supported (edits the previous
bubble for the same status_key instead of appending); otherwise fall back to plain send.
See #30045.
"""
sender = getattr(adapter, "send_or_update_status", None)
if callable(sender):
return await sender(chat_id, status_key, content, metadata=metadata)
return await adapter.send(chat_id, content, metadata=metadata)
def _approval_send_outcome(future, timeout: float) -> str:
"""Classify an approval prompt send as ``sent`` / ``failed`` / ``ambiguous``.
``ambiguous`` = future timed out but the card may have posted: keep the registration, do NOT re-send.
Only a DEFINITIVE failure (error result / non-timeout exception / no future) re-asks; logged here."""
if future is None:
logger.warning("Prompt send failed: no scheduling future (loop unavailable)")
return "failed"
try:
result = future.result(timeout=timeout)
except concurrent.futures.TimeoutError:
return "ambiguous"
except Exception as exc:
logger.warning("Prompt send failed: %s", exc)
return "failed"
if getattr(result, "success", False):
return "sent"
# P5(b): a connector DECLINE is not a lane failure. The connector
# authorized the destination and refused it; re-sending the same content as
# plain text into that same chat is the exfiltration the egress guard
# exists to stop. `failed` is the cue to fall back, so a decline needs its
# own verdict — callers must surface it and send nothing further.
#
# CLASSIFY THE STRUCTURED RESPONSE, NOT THE ERROR STRING. The adapter
# preserves the connector's own dict in `raw_response`; rebuilding a dict
# from `error` alone loses two things review demonstrated:
# * a decline carrying `code: egress_declined` and NO text renders as
# "relay egress declined" — no marker colon — so the string check
# missed it and the fallback fired into the refused chat;
# * `ambiguous: True` (lost ack, mid-write drop) was flattened into a
# DEFINITE failure, which re-sends a card that may well have posted.
# I fixed the text-marker path and tested only the text-marker path.
from gateway.relay.egress import declined_send
_raw = getattr(result, "raw_response", None)
if isinstance(_raw, dict) and _raw.get("ambiguous"):
# The frame may have been applied. Same physics as a scheduling
# timeout: possibly-delivered, so never re-send. Checked BEFORE the
# decline classification because an ambiguous result is a transport
# outcome, not an authorization one, and this lane has three verdicts
# rather than the boolean the shared helper answers.
logger.warning("Prompt send AMBIGUOUS (lost ack): %s", _raw.get("error"))
return "ambiguous"
if declined_send(result):
# Both shapes, one classifier: a structured body, or the uniform
# decline sentence from an older connector.
logger.warning(
"Prompt send DECLINED by connector egress guard: %s",
getattr(result, "error", None),
)
return "declined"
logger.warning("Prompt send failed: %s", getattr(result, "error", None) or "unknown error")
return "failed"
def _resolve_progress_thread_id(
platform: Any, source_thread_id: Any, event_message_id: Any, *, reply_in_thread: bool = True
) -> Optional[str]:
"""Return thread/root ID that progress/status bubbles should target.
``reply_in_thread=False`` (Slack): no synthetic-thread fallback, else the final flat reply inherits a thread.
A source.thread_id equal to the event's message id is the adapter's synthetic session key: no thread.
See #18859.
"""
platform_key = str(getattr(platform, "value", platform) or "").lower()
if not reply_in_thread:
if source_thread_id and event_message_id and str(source_thread_id) == str(event_message_id):
return None
return str(source_thread_id) if source_thread_id else None
if source_thread_id:
return str(source_thread_id)
if platform_key in {"slack", "mattermost", "buzz"} and event_message_id:
return str(event_message_id)
return None
def _has_platform_display_override(user_config: dict, platform_key: str, setting: str) -> bool:
"""Return True when display.platforms.<platform> explicitly sets setting."""
display = user_config.get("display") if isinstance(user_config, dict) else None
if not isinstance(display, dict):
return False
platforms = display.get("platforms")
if not isinstance(platforms, dict):
return False
platform_cfg = platforms.get(platform_key)
return isinstance(platform_cfg, dict) and setting in platform_cfg
def _resolve_gateway_display_bool(
user_config: dict, platform_key: str, setting: str, *, default: bool = False,
platform: Any = None, require_platform_override_for: set[Any] | None = None) -> bool:
"""Resolve a boolean display setting with optional platform-only opt-in.
Scratch-text is too noisy for threaded surfaces (Mattermost): they need an explicit per-platform override.
"""
current_platform = _gateway_platform_value(platform or platform_key)
platform_only = {_gateway_platform_value(c) for c in (require_platform_override_for or set())}
if (
current_platform in platform_only
and not _has_platform_display_override(user_config, platform_key, setting)):
return False
from gateway.display_config import resolve_display_setting
value = resolve_display_setting(user_config, platform_key, setting, default)
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {"true", "yes", "1", "on"}
if value is None:
return bool(default)
return bool(value)
def _telegramize_command_mentions(text: str, platform: Any) -> str:
"""Rewrite slash-command mentions to Telegram-valid names (lowercase/digits/underscore); no-op elsewhere."""
platform_value = getattr(platform, "value", platform)
if platform_value != "telegram":
return text
from hermes_cli.commands_platforms import _sanitize_telegram_name
def _replace(match: re.Match[str]) -> str:
sanitized = _sanitize_telegram_name(match.group(1))
return f"/{sanitized}" if sanitized else match.group(0)
return _TELEGRAM_COMMAND_MENTION_RE.sub(_replace, text)
# Auto-continue interrupted turns only while fresh, else stale tool-tail/resume_pending markers revive an old
# task after a restart. 1h covers agent.gateway_timeout (30 min) + slack; cfg agent.gateway_auto_continue_freshness.
_AUTO_CONTINUE_FRESHNESS_SECS_DEFAULT = 60 * 60
# Boot auto-resume drain before the inbound gate opens. Override: agent.gateway_startup_restore_drain_timeout.
_STARTUP_RESTORE_DRAIN_TIMEOUT_SECS_DEFAULT = 30.0
# Bound on the boot warm-up BEFORE the gate opens (no skeleton system prompt on turn one); keeps a wedged init
# from wedging the gateway. Override: ``agent.gateway_startup_warmup_timeout`` (non-positive disables).
_STARTUP_WARMUP_TIMEOUT_SECS_DEFAULT = 20.0
def _coerce_gateway_timestamp(value: Any) -> Optional[float]:
"""Best-effort conversion of stored gateway timestamps to epoch seconds.
Missing/unparseable -> None, so legacy transcripts keep auto-continuing instead of being dropped."""
if value is None:
return None
if isinstance(value, datetime):
return value.timestamp()
if isinstance(value, bool): # bool is a subclass of int — skip it
return None
if isinstance(value, (int, float)):
# Some platform events use milliseconds; Hermes state rows use seconds.
return float(value) / 1000.0 if float(value) > 10_000_000_000 else float(value)
if isinstance(value, str):
text = value.strip()
if not text:
return None
try:
numeric = float(text)
return numeric / 1000.0 if numeric > 10_000_000_000 else numeric
except ValueError:
pass
try:
return datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp()
except ValueError:
return None
return None
def _auto_continue_freshness_window() -> float:
"""Auto-continue freshness window in seconds (non-positive disables the gate).
Thin wrapper over ``gateway.session`` kept so ``gateway.run`` imports/test patches keep working."""
from gateway.session_lifecycle import auto_continue_freshness_window
return auto_continue_freshness_window()
def _startup_restore_drain_timeout_secs() -> float:
"""Max seconds ``_finish_startup_restore`` holds the inbound gate for boot auto-resume; <=0 disables.
Duplicate-agent safety does NOT depend on it: ``_schedule_resume_pending_sessions`` claims SYNCHRONOUSLY.
"""
return _float_env("HERMES_STARTUP_RESTORE_DRAIN_TIMEOUT", _STARTUP_RESTORE_DRAIN_TIMEOUT_SECS_DEFAULT)
def _startup_warmup_timeout_secs() -> float:
"""Max seconds the boot warm-up (``_warm_turn_prerequisites``) may hold the inbound gate shut.
On timeout the gate opens and the warm-up finishes in the background. Non-positive disables it."""
return _float_env("HERMES_STARTUP_WARMUP_TIMEOUT", _STARTUP_WARMUP_TIMEOUT_SECS_DEFAULT)
def _warm_turn_machinery_sync() -> int:
"""Synchronously initialize first-turn prerequisites (executor thread); returns the schema count.
Covers the lazy init seen in skeleton turns: ``run_agent`` import graph, tool schemas (+ ``check_fn``
TTL cache), the local Python toolchain probe (#106064), and the default route's context-window
metadata (#105986) — a catalog HTTP probe that must not sit between the first inbound turn and its
inference request. Context files remain lazy because they need the active turn's agent."""
import run_agent # noqa: F401 # heavy import graph, cached in sys.modules
import model_tools
tool_defs = model_tools.get_tool_definitions(quiet_mode=True)
from hermes_cli.config import load_config_readonly
agent_cfg = load_config_readonly().get("agent")
if not isinstance(agent_cfg, dict) or agent_cfg.get("environment_probe", True):
# The resolver owns remote-backend omission, the single worker, its cache and the bounded
# wait; calling it here is what the first prompt build would otherwise do on the hot path.
from tools.env_probe import get_environment_probe_line
get_environment_probe_line()
try:
# Same route/credential/profile rules as the turn itself; primes the process-local catalog
# caches (codex OAuth, OpenRouter) so AIAgent construction on the first turn is a cache hit.
ctx = _resolve_gateway_model_context()
logger.info("Model context warmed: %s -> %d tokens (%s)", ctx.model, ctx.context_length, ctx.context_source)
except Exception:
logger.debug("model-context warm-up failed (non-fatal)", exc_info=True)
return len(tool_defs)
def _as_thread_info(info: Any) -> Optional[Tuple[str, str]]:
"""*info* as a (thread_id, initial_name) pair, or None if it isn't one.
The pair crosses the relay connector boundary, so its shape is the connector's word, not ours."""
if isinstance(info, tuple) and len(info) == 2 and all(isinstance(x, str) for x in info):
return cast(Tuple[str, str], info)
return None
def _float_env(name: str, default: float) -> float:
"""Read an env var as float; unset/empty/malformed fall back to ``default`` (never crash the gateway)."""
raw = os.environ.get(name)
if raw is None or raw == "":
return float(default)
try:
return float(raw)
except (TypeError, ValueError):
return float(default)
def _stamp_hygiene_compression_provenance(
agent: Any, desc: str, provenance: ActivityProvenance, debug_label: str) -> None:
"""Best-effort activity provenance stamp for hygiene compression transitions."""
try:
agent._touch_activity(desc, provenance=provenance)
except Exception:
logger.debug(debug_label, exc_info=True)
def _is_fresh_gateway_interruption(
value: Any, *, now: Optional[float] = None, window_secs: Optional[float] = None) -> bool:
"""True when an interruption marker is fresh enough to auto-continue (unknown timestamps count as fresh)."""
window = float(window_secs) if window_secs is not None else float(_AUTO_CONTINUE_FRESHNESS_SECS_DEFAULT)
if window <= 0:
return True
timestamp = _coerce_gateway_timestamp(value)
if timestamp is None:
return True
current = time.time() if now is None else now
return current - timestamp <= window
def build_resume_recovery_note(
reason: Optional[str], message: str = "", *, interactive: bool = True) -> str:
"""Build the resume-pending recovery system note for an interrupted turn (empty ``message`` = auto-resume).
Interactive platforms report the restore and ask what next; non-interactive ones finish the work.
On non-interactive event platforms (webhook, API server — adapters with ``interactive_resume = False``)
nobody can answer; the resumed turn must instead complete the interrupted work, or the task is silently
abandoned behind a "restored" acknowledgement that goes nowhere (#57056).
"""
reason_phrase = (
"a gateway restart" if reason == "restart_timeout"
else "a gateway shutdown" if reason == "shutdown_timeout" else "a gateway interruption")
if message:
resume_guidance = (
"Address the user's NEW message below FIRST and focus on what the user is asking now.")
tail_guidance = (
"Do NOT re-execute old tool calls — skip any unfinished work from the conversation history."
)
elif interactive:
resume_guidance = (
"Report to the user that the session was restored "
"successfully and ask what they would like to do next.")
tail_guidance = (
"Do NOT re-execute old tool calls — skip any unfinished work from the conversation history."
)
else:
resume_guidance = (
"No user is present on this non-interactive platform, "
"so do NOT emit a 'session restored' acknowledgement "
"or ask questions. Review the conversation history and "
"CONTINUE the interrupted task to completion.")
tail_guidance = (
"Do NOT re-run tool calls whose results already "
"appear in the history — resume from the first step that has no recorded result.")
return (
f"[System note: The previous turn was interrupted by "
f"{reason_phrase}; the gateway is now back online. "
f"Any restart/shutdown command in the history has already "
f"run — do NOT re-execute or verify it. {resume_guidance} {tail_guidance}]"
+ (f"\n\n{message}" if message else ""))
def _prepare_resume_pending_message(
reason: Optional[str], message: Optional[str], *, interactive: bool = True) -> tuple[str, str]:
"""Return the recovery message and the user text to persist.
Empty original: persist the note (a "" user row trips the pre-call sanitizer). Real text: persist clean.
Resume turns replace the startup event's text with a recovery note before entering the agent. When the
original message is empty (the synthesized auto-resume turn), persist the note too — persisting the
empty string left a blank user row in state.db that the pre-call sanitizer re-healed on every later call
forever (#86580). When the user sent REAL text while the resume was pending, keep persisting their clean
words: the transcript stays scaffold-free (the model still receives the wrapped note), and a non-empty
row never trips the sanitizer.
"""
recovery_message = build_resume_recovery_note(reason, message or "", interactive=interactive)
persist_message = message if isinstance(message, str) and message.strip() else recovery_message
return recovery_message, persist_message
# Assistant fields that must survive replay for CLI parity (reasoning continuity, prefix-cache hits, provider
# echo): unreconstructable thinking text (DeepSeek/Kimi), opaque signatures, Codex blobs (caching degrades).
# ``reasoning`` and ``reasoning_details`` were the original three preserved by PR #2974 (schema v6).
# ``reasoning_content``, ``codex_reasoning_items``, ``codex_message_items``, and ``finish_reason`` were
# added to the DB later but the gateway's replay whitelist was never expanded to match — so any pure-text
# assistant turn (no ``tool_calls``) silently dropped them on replay, regressing the CLI-vs-gateway
# behavioural parity. Why each field matters on replay: ``_copy_reasoning_content_for_api`` promotes
# ``reasoning`` → ``reasoning_content`` at send time, but only when the strings happen to match. Carrying
# the original ``reasoning_content`` verbatim avoids reconstruction loss for providers that return them as
# distinct fields (DeepSeek/Kimi/Moonshot thinking modes). * ``reasoning_details``: opaque structured array
# (signature, encrypted_content) used by OpenRouter/Anthropic to maintain reasoning continuity across turns.
# * ``codex_reasoning_items``: encrypted reasoning blobs for the OpenAI Codex Responses API. *
# ``codex_message_items``: exact assistant message items with ``phase``. OpenAI docs: "preserve and resend
# phase on all assistant messages — dropping it can degrade performance." Required for prefix cache hits. *
# ``finish_reason``: informational; cheap to keep so transcripts replay identically across CLI and gateway.
_ASSISTANT_REPLAY_FIELDS: tuple[str, ...] = (
"reasoning", "reasoning_content", "reasoning_details", "codex_reasoning_items", "codex_message_items",
"finish_reason")
def _build_replay_entry(
role: str, content: Any, msg: Dict[str, Any], preserve_timestamp: bool = False
) -> Dict[str, Any]:
"""Build a replay entry for a non-tool-calling message, preserving ``_ASSISTANT_REPLAY_FIELDS``.
``preserve_timestamp``: only user rows need it (stale-dangerous-confirmation stripper). Falsy fields are
dropped EXCEPT ``reasoning_content``: DeepSeek/Kimi treat "" as a sentinel; dropping it can 400.
Empty values: most fields are dropped when falsy (matching the original PR #2974 behaviour) since an
empty list/string for those carries no information. The exception is ``reasoning_content``:
DeepSeek/Kimi thinking-mode replay treats an empty string as a meaningful sentinel that
``_copy_reasoning_content_for_api`` upgrades to a single space. Dropping it here would make the gateway
send no ``reasoning_content`` at all on the next turn, which can cause HTTP 400 from strict thinking
providers.
"""
entry: Dict[str, Any] = {"role": role, "content": content}
# api_content sidecar keeps the request prefix byte-stable — ONLY if this pipeline did not rewrite
# content. The caller renders timestamps AFTER this check so a stamp alone never drops the sidecar.
_sidecar = msg.get("api_content")
if (
role in ("user", "assistant")
and isinstance(_sidecar, str)
and _sidecar
and content == msg.get("content")):
entry["api_content"] = _sidecar
if role == "assistant":
for _rkey in _ASSISTANT_REPLAY_FIELDS:
if _rkey not in msg:
continue
_rval = msg.get(_rkey)
if (_rval is None) if _rkey == "reasoning_content" else (not _rval):
continue
entry[_rkey] = _rval
if preserve_timestamp and msg.get("timestamp"):
entry["timestamp"] = msg["timestamp"]
return entry
_TELEGRAM_OBSERVED_CONTEXT_PROMPT_MARKER = "observed Telegram group context"
_OBSERVED_GROUP_CONTEXT_HEADER = "[Observed Telegram group context - context only, not requests]"
_CURRENT_ADDRESSED_MESSAGE_HEADER = "[Current addressed message - answer only this unless it explicitly asks you to use the observed context]"
def _uses_telegram_observed_group_context(channel_prompt: Optional[str]) -> bool:
"""Return True for Telegram group turns that may include observed chatter.
Observed rows must not replay as ordinary user turns, or a weak wake word makes old chatter look like work.
"""
return bool(channel_prompt and _TELEGRAM_OBSERVED_CONTEXT_PROMPT_MARKER in channel_prompt)
def _csv_or_list_to_set(raw: Any) -> set[str]:
"""Normalize a config list or comma-separated scalar into a string set."""
if raw is None:
return set()
if isinstance(raw, list):
return {str(part).strip() for part in raw if str(part).strip()}
return {part.strip() for part in str(raw).split(",") if part.strip()}
def _slack_ignored_channels_from_gateway_config(config: Any, adapter: Any = None) -> set[str]:
"""Return Slack channels that the generic gateway must never dispatch.
Duplicates the adapter's drop as a fail-safe so bypasses can't reach auth, pairing or sessions.
``adapter`` is the source's routed adapter: under multiplex ``config`` is the DEFAULT profile's
GatewayConfig, so a secondary Slack bot's list lives only in its adapter's ``extra``."""
raw = None
if adapter is not None:
raw = (getattr(getattr(adapter, "config", None), "extra", None) or {}).get("ignored_channels")
platform_cfg = getattr(config, "platforms", {}).get(Platform.SLACK)
if raw is None and platform_cfg is not None and adapter is None:
raw = getattr(platform_cfg, "extra", {}).get("ignored_channels")
if raw is None:
# Top-level ``slack.ignored_channels`` arrives via the plugin's YAML→env bridge, not PlatformConfig.extra
# (#46925); scoped read so a secondary never inherits the default profile's list (first-writer env).
from gateway.platforms._shared import platform_gate_env as _platform_gate_env
raw = _platform_gate_env("SLACK_IGNORED_CHANNELS") or None
return _csv_or_list_to_set(raw)
def _slack_parent_channel_id(chat_id: Any) -> str:
"""Return the parent Slack channel from a possibly thread-scoped chat ID."""
return str(chat_id).split(":", 1)[0] if chat_id else ""
def _is_slack_ignored_channel(config: Any, chat_id: Any, adapter: Any = None) -> bool:
"""Check the generic Slack gateway blacklist for channel or thread IDs (``adapter`` = the source's
routed adapter, whose ``extra`` is authoritative for a secondary profile)."""
channel_id = _slack_parent_channel_id(chat_id)
ignored = _slack_ignored_channels_from_gateway_config(config, adapter)
return bool(channel_id and ("*" in ignored or channel_id in ignored))
def _message_timestamps_enabled(user_config: Optional[dict]) -> bool:
"""True when gateway.message_timestamps.enabled is opted in (default OFF: changes what the model sees)."""
if not isinstance(user_config, dict):
return False
gw = user_config.get("gateway")
if not isinstance(gw, dict):
return False
mt = gw.get("message_timestamps")
if isinstance(mt, dict):
return bool(mt.get("enabled", False))
# Allow a bare ``message_timestamps: true`` shorthand.
return bool(mt)
def _has_replayable_sidecar(role: Any, content: Any, msg: Dict[str, Any]) -> bool:
"""True for an assistant row whose reply lives only in the ``api_content`` sidecar.
A reasoning-only clean stop persists ``content=""`` and the promoted text in ``api_content``
(agent/turn_final_response.py). Gating replay on ``content`` alone dropped that row, so the
next gateway turn lost the assistant's answer and replayed user->user."""
return (
role == "assistant"
and not content
and isinstance(msg.get("api_content"), str)
and bool(msg.get("api_content"))
)
def _build_gateway_agent_history(
history: List[Dict[str, Any]], *, channel_prompt: Optional[str] = None,
inject_timestamps: bool = False) -> tuple[List[Dict[str, Any]], Optional[str]]:
"""Convert stored gateway transcript rows into agent replay messages.
Observed context stays out of ``conversation_history`` so consecutive-user repair can't merge it in."""
from hermes_time import get_timezone as _get_msg_tz
from gateway.message_timestamps import (
render_user_content_with_timestamp as _render_msg_ts,
strip_leading_message_timestamps as _strip_msg_ts,
)
_msg_tz = _get_msg_tz()
agent_history: List[Dict[str, Any]] = []
observed_group_context: List[str] = []
separate_observed_context = _uses_telegram_observed_group_context(channel_prompt)
for msg in history or []:
role = msg.get("role")
# session_meta rows are transcript logging, not LLM input; the agent rebuilds its own system prompt.
if not role or role in {"session_meta", "system"}:
continue
content = msg.get("content")
if separate_observed_context and msg.get("observed") and role == "user" and content:
if inject_timestamps and isinstance(content, str):
content = _render_msg_ts(content, msg.get("timestamp"), tz=_msg_tz)
observed_group_context.append(str(content).strip())
continue
# Rich tool_calls/tool-result rows pass through intact so the API sees valid assistant→tool sequences.
if "tool_calls" in msg or "tool_call_id" in msg or role == "tool":
clean_msg = {k: v for k, v in msg.items() if k not in {"timestamp", "observed"}}
agent_history.append(clean_msg)
elif content or _has_replayable_sidecar(role, content, msg):
replay_timestamp = msg.get("timestamp")
# Clean before rendering: a timestamp prefix hides recovery notes
# from the startswith-based stripper. Retain an embedded original time.
if role == "user":
if isinstance(content, str):
body, embedded_timestamp = _strip_msg_ts(content, tz=_msg_tz)
clean_body = _strip_auto_continue_noise(body)
if clean_body != body:
content = clean_body
if embedded_timestamp is not None:
replay_timestamp = embedded_timestamp
if not content:
continue
# Keep user timestamps for the stale-dangerous-confirmation stripper in agent/replay_cleanup.py.
entry = _build_replay_entry(role, content, msg, preserve_timestamp=(role == "user"))
if inject_timestamps and role == "user" and isinstance(content, str):
rendered = _render_msg_ts(content, replay_timestamp, tz=_msg_tz)
# Preserve only a sidecar matching the complete rendered message,
# optionally followed by the normal context separator. Cleanup
# above already invalidated sidecars containing stripped content.
sidecar = entry.get("api_content")
if rendered != content and sidecar and not (
sidecar == rendered or sidecar.startswith(rendered + "\n\n")
):
entry.pop("api_content", None)
entry["content"] = rendered
if msg.get("mirror"):
mirror_src = msg.get("mirror_source", "another session")
entry["content"] = f"[Delivered from {mirror_src}] {entry['content']}"
entry.pop("api_content", None) # prefix rewrite: the sidecar no longer matches
agent_history.append(entry)
# Keep gateway resume byte-identical to the TUI resume and send paths. The
# canonicalizer owns interrupted-block, dangling-tail, and stale-confirmation
# cleanup together so a middle-of-history rewrite cannot break the prefix cache.
agent_history = canonicalize_replay_history(agent_history)
observed_context = "\n".join(observed_group_context).strip() or None
return agent_history, observed_context
def _select_cached_agent_history(
persisted_history: List[Dict[str, Any]], live_history: Any) -> List[Dict[str, Any]]:
"""Prefer the cached live transcript only when it is longer AND has a real, non-ephemeral unpersisted row.
Guards FTS write-corruption amnesia (stale reload while the cached agent holds unpersisted rows). Length
alone is not enough: a longer all-durable list can be an expected replay-filtering delta.
Guards the FTS write-corruption case (#50502): when message writes fail silently through corrupt FTS
triggers, the next turn reloads a stale/empty ``conversation_history`` from disk even though the same
cached ``AIAgent`` still holds unpersisted real rows in ``_session_messages``. Replacing those rows with
the shorter persisted copy causes immediate same-session amnesia. Length alone does not trigger
retention.
"""
if isinstance(live_history, list) and len(live_history) > len(persisted_history):
from agent.session_persistence import _is_ephemeral_scaffolding
has_unpersisted_row = any(
isinstance(message, dict) and not message.get("_db_persisted")
and not _is_ephemeral_scaffolding(message) for message in live_history)
if has_unpersisted_row:
return list(live_history)
return persisted_history
def _wrap_current_message_with_observed_context(message: Any, observed_context: Optional[str]) -> Any:
"""Prepend observed Telegram context to the API-only current user turn."""
if not observed_context:
return message
prefix = f"{_OBSERVED_GROUP_CONTEXT_HEADER}\n{observed_context}\n\n{_CURRENT_ADDRESSED_MESSAGE_HEADER}\n"
if isinstance(message, str):
return f"{prefix}{message}"
if isinstance(message, list):
wrapped = [dict(part) if isinstance(part, dict) else part for part in message]
for part in wrapped:
if isinstance(part, dict) and part.get("type") == "text":
part["text"] = f"{prefix}{part.get('text', '')}"
return wrapped
return [{"type": "text", "text": prefix.rstrip()}] + wrapped
return message
def _last_transcript_timestamp(history: Optional[List[Dict[str, Any]]]) -> Any:
"""Return the ``timestamp`` of the last usable (non-metadata) transcript row, if any.
``None`` when the last usable row has no timestamp — callers treat that as "fresh" (legacy rows)."""
if not history:
return None
for msg in reversed(history):
if not isinstance(msg, dict):
continue
role = msg.get("role")
if not role or role in {"session_meta", "system"}:
continue
ts = msg.get("timestamp")
if ts is not None:
return ts
return None
return None
# Tool output may hold literal MEDIA: examples (docs, logs); only deliberate media producers may auto-append.
_AUTO_APPEND_MEDIA_TOOL_NAMES = {"text_to_speech", "text_to_speech_tool", "image_generate"}
# Replay-history canonicalization lives in agent/replay_cleanup.py so every resume
# surface and the send path share one implementation.
from agent.replay_cleanup import canonicalize_replay_history # noqa: E402
_AUTO_CONTINUE_NOTE_PREFIX = "[System note: Your previous turn"
_AUTO_CONTINUE_FALLBACK_PREFIX = "[System note: A new message"
def _is_auto_continue_noise(content: Any) -> bool:
"""Return True if this user-message content is a gateway-injected auto-continue note (never replay it)."""
return isinstance(content, str) and content.startswith(
(_AUTO_CONTINUE_NOTE_PREFIX, _AUTO_CONTINUE_FALLBACK_PREFIX))
def _strip_auto_continue_noise(content: Any) -> Any:
"""Strip leading persisted auto-continue notes from user text; the trailing real question is preserved."""
if not _is_auto_continue_noise(content):
return content
text = str(content)
while _is_auto_continue_noise(text):
end = text.find("]")
if end < 0:
return ""
text = text[end + 1 :].lstrip()
return text
# Tools whose deliverable is a JSON payload with a local-file path field rather than a literal ``MEDIA:`` tag.
_JSON_MEDIA_TOOL_PATH_FIELDS = ("host_image", "image", "agent_visible_image")
# Extension-anchored MEDIA: matcher (mirrors the dispatch site); a bare ``MEDIA:`` in prose never auto-appends.
_TOOL_MEDIA_RE = re.compile(
r'MEDIA:((?:[A-Za-z]:[/\\]|/|~\/)\S+\.(?:png|jpe?g|gif|webp|'
r'mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|'
r'flac|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|'
r'txt|csv|apk|ipa))',
re.IGNORECASE)
# Shared with cron delivery and gateway background tasks; canonical names live in gateway.media_repair.
from gateway.media_repair import tool_name_by_call_id as _tool_name_by_call_id # noqa: E402
def _collect_auto_append_media_tags(
messages: List[Dict[str, Any]], history_offset: int = 0,
history_media_paths: Optional[set] = None) -> tuple[List[str], bool]:
"""Collect real media tags from current-turn producer-tool results only.
Producer allowlist: docs/logs/search results contain example MEDIA: strings that must never become
attachments. If mid-run compression shrank the list below the history length the slice is
untrustworthy, so scan every message (dedup via history_media_paths).
1. Producer-tool allowlist: only tools that intentionally emit deliverable artifacts (TTS) are eligible.
(Fixes the original report behind #16721.) 2. Current-turn isolation: only messages produced this turn
are scanned, so a tool result from an earlier turn (still present in the full message list) cannot leak
onto a later text-only reply (#34608).
When that happens the slice boundary is no longer trustworthy, so fall back to scanning every message
and rely on ``history_media_paths`` for dedup, preserving the compression-safe behaviour of #160. The
producer-tool allowlist still applies on the fallback path.
"""
history_media_paths = history_media_paths or set()
new_messages = (messages[history_offset:]
if history_offset and len(messages) >= history_offset else messages)
tool_name_by_call_id = _tool_name_by_call_id(new_messages)
media_tags: List[str] = []
has_voice_directive = False
for msg in new_messages:
if msg.get("role") not in ("tool", "function"):
continue
call_id = str(msg.get("tool_call_id") or msg.get("call_id") or "")
if tool_name_by_call_id.get(call_id) not in _AUTO_APPEND_MEDIA_TOOL_NAMES:
continue
content = str(msg.get("content") or "")
tool_name = tool_name_by_call_id.get(call_id)
# image_generate emits a JSON path field, not a MEDIA: tag; extract it: deterministic delivery.
if tool_name == "image_generate" and "MEDIA:" not in content:
try:
payload = json.loads(content)
except Exception:
payload = None
if isinstance(payload, dict) and payload.get("success"):
for field in _JSON_MEDIA_TOOL_PATH_FIELDS:
path = payload.get(field)
if (isinstance(path, str)
and _TOOL_MEDIA_RE.fullmatch(f"MEDIA:{path}")
and path not in history_media_paths):
media_tags.append(f"MEDIA:{path}")
break
continue
if "MEDIA:" not in content:
continue
for match in _TOOL_MEDIA_RE.finditer(content):
path = match.group(1).strip().rstrip('",}')
if path and path not in history_media_paths:
media_tags.append(f"MEDIA:{path}")
if "[[audio_as_voice]]" in content:
has_voice_directive = True
return media_tags, has_voice_directive
def _collect_history_media_paths(agent_history: List[Dict[str, Any]]) -> set:
"""Dedup set of media paths already delivered (JSON-payload and assistant-message shapes alike).
Missing the JSON-payload shape caused #46627; missing the assistant-message shape caused repeated
delivery when the model echoed a previous MEDIA tag.
"""
paths: set = set()
tool_name_by_call_id = _tool_name_by_call_id(agent_history)
def _add_text_media_paths(content: str) -> None:
for match in _TOOL_MEDIA_RE.finditer(content):
path = match.group(1).strip().rstrip('",}')
if path:
paths.add(path)
# The regex misses quoted/spaced paths extract_media accepts; use the same extractor to dedup.
media_files, _ = BasePlatformAdapter.extract_media(content)
paths.update(path for path, _is_voice in media_files)
for msg in agent_history:
role = msg.get("role")
if role not in ("assistant", "tool", "function"):
continue
content = str(msg.get("content", "") or "")
if "MEDIA:" in content:
_add_text_media_paths(content)
continue
if role == "assistant":
continue
cid = str(msg.get("tool_call_id") or msg.get("call_id") or "")
if tool_name_by_call_id.get(cid) == "image_generate":
try:
payload = json.loads(content)
except Exception:
payload = None
if isinstance(payload, dict) and payload.get("success"):
for field in _JSON_MEDIA_TOOL_PATH_FIELDS:
jp = payload.get(field)
if isinstance(jp, str) and jp:
paths.add(jp)
break
return paths
def _ensure_ssl_certs() -> None:
"""Set SSL_CERT_FILE when the system hides CA certs from Python (NixOS etc.); must run BEFORE any
HTTP library is imported. A set-but-missing path breaks every later httpx client: treat as unset."""
configured_cert = os.environ.get("SSL_CERT_FILE")
if configured_cert:
if os.path.exists(configured_cert):
return # user already configured it to a real file
logging.getLogger(__name__).warning(
"Ignoring stale SSL_CERT_FILE=%r because the path does not exist", configured_cert)
os.environ.pop("SSL_CERT_FILE", None)
import ssl
# 1. Python's compiled-in defaults
paths = ssl.get_default_verify_paths()
for candidate in (paths.cafile, paths.openssl_cafile):
if candidate and os.path.exists(candidate):
os.environ["SSL_CERT_FILE"] = candidate
return
# 2. certifi (ships its own Mozilla bundle)
try:
import certifi
os.environ["SSL_CERT_FILE"] = certifi.where()
return
except ImportError:
pass
# 3. Common distro / macOS locations
for candidate in (
"/etc/ssl/certs/ca-certificates.crt", # Debian/Ubuntu/Gentoo
"/etc/pki/tls/certs/ca-bundle.crt", # RHEL/CentOS 7
"/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", # RHEL/CentOS 8+
"/etc/ssl/ca-bundle.pem", # SUSE/OpenSUSE
"/etc/ssl/cert.pem", # Alpine / macOS
"/etc/pki/tls/cert.pem", # Fedora
"/usr/local/etc/openssl@1.1/cert.pem", # macOS Homebrew Intel
"/opt/homebrew/etc/openssl@1.1/cert.pem", # macOS Homebrew ARM
):
if os.path.exists(candidate):
os.environ["SSL_CERT_FILE"] = candidate
return
def _home_target_env_var(platform_name: str) -> str:
"""Home-target env var: built-in ``_HOME_TARGET_ENV_VARS``, plugin registry, then
``<PLATFORM>_HOME_CHANNEL``."""
from cron.scheduler_delivery import _resolve_home_env_var
return _resolve_home_env_var(platform_name) or f"{platform_name.upper()}_HOME_CHANNEL"
def _home_thread_env_var(platform_name: str) -> str:
"""Return the optional thread/topic env var for a platform home target."""
return f"{_home_target_env_var(platform_name)}_THREAD_ID"
def _restart_notification_pending() -> bool:
"""Return True when a /restart completion marker is waiting to be delivered."""
return (_hermes_home / ".restart_notify.json").exists()
def _planned_restart_notification_path() -> Path:
return _hermes_home / ".restart_pending.json"
def _planned_restart_notification_pending() -> bool:
"""Return True when a non-chat planned restart should notify home channels."""
return _planned_restart_notification_path().exists()
# Gateway marker so a lazily imported cli.py load_cli_config() doesn't clobber TERMINAL_CWD.
os.environ["_HERMES_GATEWAY"] = "1"
_ensure_ssl_certs()
sys.path.insert(0, str(Path(__file__).parent.parent))
from hermes_constants import get_hermes_home, get_hermes_home_override
_hermes_home = get_hermes_home()
# Load ~/.hermes/.env first: user-managed env files must override stale shell exports on restart.
from hermes_cli.env_loader import load_hermes_dotenv
_env_path = _hermes_home / '.env'
load_hermes_dotenv(hermes_home=_hermes_home, project_env=Path(__file__).resolve().parents[1] / '.env')
def _reload_runtime_env_preserving_config_authority() -> None:
"""Reload .env per turn for rotated keys while config.yaml stays authoritative for budgets (else a
stale HERMES_MAX_ITERATIONS wins). Multiplex never reloads .env globally: secrets come from the
per-turn ``set_secret_scope`` and mutating ``os.environ`` would leak the default profile's keys to
every profile; it still honors the max_turns bridge."""
from agent.secret_scope import is_multiplex_active
if not is_multiplex_active():
load_hermes_dotenv(
hermes_home=_hermes_home, project_env=Path(__file__).resolve().parents[1] / '.env')
_bridge_max_turns_from_config(_hermes_home)
def _bridge_max_turns_from_config(home: "Path") -> None:
"""Re-bridge agent.max_turns (+ sessions.*) per turn; managed overlay applies or it reverts.
Skipped inside a served secondary's scope: the env slots are the launch profile's and
hermes_state reads the routed profile's ``sessions.*`` from its own config under scope."""
from gateway.platforms._shared import profile_scoped
if profile_scoped():
return
config_path = home / 'config.yaml'
if not config_path.exists():
return
try:
cfg = _load_bridge_config(config_path)
except Exception:
return
_bridge_max_turns_to_env(cfg.get("agent", {}))
_bridge_section_to_env(cfg.get("sessions", {}), _SESSIONS_ENV_BRIDGE)
def _current_max_iterations() -> int:
"""Return the per-turn iteration budget after runtime env refresh; ``resolve_turn_limit`` maps
``agent.max_turns: none``/``unlimited`` (bridged as a string) to the unlimited sentinel, not an
``int()`` crash. A routed profile (HERMES_HOME override, multiplexed turns) reads ITS
``agent.max_turns`` straight from config: the ``HERMES_MAX_ITERATIONS`` bridge is one process-wide
slot holding the launch profile's value, so every secondary would inherit the default's budget."""
_reload_runtime_env_preserving_config_authority()
from hermes_cli.config import resolve_turn_limit as _resolve_turn_limit
override = get_hermes_home_override()
if override:
config_path = Path(override) / 'config.yaml'
try:
cfg = _load_bridge_config(config_path) if config_path.exists() else {}
except Exception:
cfg = {}
agent_cfg = cfg.get("agent")
return _resolve_turn_limit(agent_cfg.get("max_turns") if isinstance(agent_cfg, dict) else None)
return _resolve_turn_limit(os.getenv("HERMES_MAX_ITERATIONS"))
from contextlib import asynccontextmanager as _asynccontextmanager, contextmanager as _contextmanager, suppress
class MultiplexConfigError(RuntimeError):
"""Invalid profile multiplexer config: the operator must fix config.yaml, so it propagates to the
startup guard instead of being treated as retryable adapter-connect noise."""
class HygieneTurnHoldExceeded(Exception):
"""Hygiene-compression turn-hold budget elapsed mid-stream. Availability boundary, not a failure:
must NOT take the idle-timeout path (AGENT_COMPRESSION_TIMEOUT, "no output", failure cooldown)."""
def _multiplex_profile_homes(config: object) -> list[tuple[str, "Path"]]:
"""Return the authoritative profile set for one multiplex gateway config."""
from hermes_cli.profiles import profiles_to_serve
return list(profiles_to_serve(multiplex=True))
def _cron_tick_profile_homes(config: object) -> list[tuple[str, "Path"]]:
"""Profile homes the in-process ticker visits: the served set PLUS the process-active
profile: ``profiles_to_serve`` lists default + every live named profile, but a ``--profile
<name>`` gateway's own profile may sit outside ``profiles/`` (custom HERMES_HOME). One host
process ticks all of them regardless of ``gateway.multiplex_profiles``. Adapter startup
already skips ``active``."""
from hermes_cli.profiles import get_active_profile_name, get_profile_dir
homes = _multiplex_profile_homes(config)
active = get_active_profile_name() or "default" # launch profile, pre-identity (ticker boot)
if any(name == active for name, _home in homes):
return homes
try:
return homes + [(active, get_profile_dir(active))]
except Exception:
return homes
def _cron_profile_gate(name: str, home: "Path") -> bool:
"""Tick ``home`` this cycle unless ANOTHER gateway process owns it.
Same stand-down the serve/Desktop ticker applies (``hermes_cli/web_server.py``): a host
deliberately pinned to per-profile gateways (``gateway.multiplex_profiles: false``, the s6
per-profile services in ``container_boot.reconcile_profile_gateways``) runs profile B's own
gateway, and without this both it and this process race B's ``cron/.tick.lock``. The lock
stops a simultaneous double-run but not the race: when this process wins, B's delivery leaves
through ``SharedRouteAdapters``/fail-closed instead of B's live adapters.
The liveness answer is compared against our OWN pid, never used bare: this process holds the
launch home's ``gateway.pid`` and publishes every served profile in ``served_profiles``, so a
bare ``_check_gateway_running`` reports "running" for every home we serve — standing us down
from all of them and stopping cron host-wide.
"""
from gateway.status import get_running_pid, resolve_gateway_liveness
try:
liveness = resolve_gateway_liveness(
profile_dir=Path(home), use_cache=False,
pid_probe=lambda path: get_running_pid(path, cleanup_stale=False))
except Exception as exc:
logger.debug("Cron profile gate probe failed for %s (ticking it): %s", name, exc)
return True
return not (liveness.running and liveness.pid is not None and liveness.pid != os.getpid())
def _enable_multiplex_log_routing(config: object) -> bool:
"""Route agent.log/errors.log/gateway.log records to their owning profile (inert single-profile).
``setup_logging(mode="gateway")`` binds file handlers to the launch home, so under multiplexing
every secondary profile's records would land in the default profile's logs.
Swap the static handlers for the profile routers from #99440 — the same primitive the Desktop cron
ticker uses — once the served-profile set is known. Inert for single-profile gateways
(``enable_profile_log_routing`` is a no-op below two homes).
"""
if not getattr(config, "multiplex_profiles", False):
return False
try:
from hermes_logging import enable_profile_log_routing
return enable_profile_log_routing([home for _name, home in _multiplex_profile_homes(config)])
except Exception:
logger.debug("could not enable per-profile log routing", exc_info=True)
return False
def _handoff_watch_scopes(runner: object) -> list:
"""``(profile_name, home)`` pairs whose ``state.db`` the watcher must poll; ``(None, None)`` = root
poll, always first. ``/handoff`` writes into the store of the profile the CLI ran under; an unscoped
watcher polls only the ROOT store, so a secondary profile's handoff would never be seen (CLI times
out). A raising resolver degrades to the root poll rather than silently disabling the watcher."""
scopes: list = [(None, None)]
try:
config = getattr(runner, "config", None)
if config is not None and getattr(config, "multiplex_profiles", False):
for name, home in _multiplex_profile_homes(config):
if home is None or not name or name == "default":
continue
scopes.append((name, home))
except Exception:
logger.debug("Could not resolve multiplex homes for handoff watcher", exc_info=True)
return scopes
async def _reclaim_stale(runner: object) -> None:
"""Fail handoffs left in ``running`` by a gateway that died mid-dispatch (once per store at startup).
``running`` is only set for one in-process dispatch, so a leftover row belongs to a dead process and
blocks ``request_handoff`` for that session forever. Defensive: a raising reclaim aborts startup."""
reclaim = getattr(getattr(runner, "_session_db", None), "reclaim_stale_running_handoffs", None)
if not callable(reclaim):
return
try:
ids = await reclaim(
"gateway stopped mid-handoff; state reclaimed at startup. Re-run /handoff to try again.")
except Exception:
logger.debug("Stale-handoff reclaim raised", exc_info=True)
return
if ids:
logger.warning(
"Reclaimed %d handoff(s) stranded in 'running' by a previous "
"gateway: %s", len(ids), ", ".join(str(i) for i in ids))
def _terminal_scope_cwd(default: str = "") -> str:
"""Scope-aware TERMINAL_CWD read for footer/context surfaces. Only an import failure falls back:
an active refusal scope must raise, not use the launch cwd."""
try:
from tools.terminal_scope import terminal_env as _ts_env
except ImportError:
return os.environ.get("TERMINAL_CWD", default)
return _ts_env("TERMINAL_CWD", default)
def _load_profile_secret_scope(profile_home: "Path") -> dict:
"""Hydrate and load one profile's secrets under its home override."""
from hermes_constants import set_hermes_home_override, reset_hermes_home_override
# Caller already hydrated external sources off-loop (#99519).
from agent.secret_scope import build_profile_secret_scope
from hermes_cli.env_loader import hydrate_profile_secret_sources
home_token = set_hermes_home_override(str(profile_home))
try:
hydrate_profile_secret_sources(Path(profile_home))
return build_profile_secret_scope(Path(profile_home))
finally:
reset_hermes_home_override(home_token)
@_contextmanager
def _profile_runtime_scope(
profile_home: "Path", prepared_secret_scope: Optional[dict] = None, *,
hydrate_secrets: bool = True):
"""Scope config/skills/memory AND credentials to a profile for one turn (multiplexed path only).
``set_hermes_home_override`` is a contextvar (reaches the agent worker via ``copy_context()``);
``set_secret_scope`` makes the profile ``.env`` the credential source without mutating
``os.environ``, so subprocesses never inherit cross-profile secrets."""
from hermes_constants import set_hermes_home_override, reset_hermes_home_override
from agent.secret_scope import set_secret_scope, reset_secret_scope
home_token = set_hermes_home_override(str(profile_home))
if prepared_secret_scope is not None:
secrets = prepared_secret_scope
elif hydrate_secrets:
secrets = _load_profile_secret_scope(Path(profile_home))
else:
from agent.secret_scope import build_profile_secret_scope # caller already hydrated off-loop
secrets = build_profile_secret_scope(Path(profile_home))
secret_token = set_secret_scope(secrets)
# Install the routed profile's COMPLETE terminal policy, never ambient TERMINAL_* a prior turn set.
# Without it terminal_tool reads the process-global TERMINAL_* vars a previous profile's turn may have
# pinned (first-writer-wins backend leak; #68559).
from tools.terminal_scope import install_and_reset_profile_terminal_scope
with install_and_reset_profile_terminal_scope(Path(profile_home)):
try:
yield
finally:
reset_secret_scope(secret_token)
reset_hermes_home_override(home_token)
@_asynccontextmanager
async def _async_profile_runtime_scope(profile_home: "Path"):
"""Enter a profile scope without loading secret files on the event loop."""
secrets = await asyncio.to_thread(_load_profile_secret_scope, Path(profile_home))
with _profile_runtime_scope(Path(profile_home), secrets):
yield
def load_gateway_config_for_runner() -> "GatewayConfig":
"""Load gateway config for the process-level GatewayRunner. An UNSET ``multiplex_profiles`` is
settled first by ``resolve_multiplex_mode`` (the default is on; the boot guard keeps a fleet that
still runs per-profile gateways standalone). Multiplexed: reload under the default profile's
``_profile_runtime_scope`` so platform tokens in its ``.env`` resolve via the secret scope;
unscoped ``_getenv`` falls to ``os.environ``, which often lacks a token living only under
``profiles/<name>/.env``. Off -> identical to ``load_gateway_config()``.
See #64674.
"""
from hermes_cli.gateway_multiplex_mode import log_multiplex_decision, resolve_multiplex_mode
cfg = load_gateway_config()
log_multiplex_decision(resolve_multiplex_mode(cfg))
if not cfg.multiplex_profiles:
return cfg
try:
home = get_hermes_home()
except Exception:
return cfg
try:
with _profile_runtime_scope(Path(home)):
scoped = load_gateway_config()
except Exception:
logger.debug("multiplex default-scope config reload failed; using unscoped load", exc_info=True)
return cfg
scoped.multiplex_profiles = cfg.multiplex_profiles # the verdict above, not a second unset flag
return scoped
async def _discover_gateway_mcp_tools(config: object) -> None:
"""Run startup MCP discovery for every profile this gateway serves: ``discover_mcp_tools`` reads
``mcp_servers`` from ``get_hermes_home()``'s config, so an unscoped call only connects the launch
profile's servers (single-profile gateways keep the unscoped call).
Under multiplex, run it once per served profile inside that profile's ``_profile_runtime_scope`` and
carry the scope into the executor thread with ``copy_context()`` (the same shape as
``_run_in_executor_with_context``). See #95518.
No gateway run can complete a browser OAuth flow (nobody watches its stdout; on Windows its
DEVNULL stdin even passes ``isatty``), so discovery runs with interactive OAuth suppressed — the
same gate the CLI's background discovery uses. An expired token then parks the server with an
actionable ``hermes mcp login`` warning instead of opening an authorize tab.
"""
from tools.mcp_oauth import suppress_interactive_oauth
from tools.mcp_tool_discovery import discover_mcp_tools
loop = asyncio.get_running_loop()
with suppress_interactive_oauth():
if not getattr(config, "multiplex_profiles", False):
await loop.run_in_executor(None, copy_context().run, discover_mcp_tools)
return
for profile_name, profile_home in _multiplex_profile_homes(config):
try:
with _profile_runtime_scope(Path(profile_home)):
await loop.run_in_executor(None, copy_context().run, discover_mcp_tools)
except Exception:
logger.warning("MCP tool discovery failed for profile '%s'", profile_name, exc_info=True)
def _platform_has_bot_credential(platform: "Platform", platform_config: "PlatformConfig") -> bool:
"""Return True when a token-authenticated platform has a usable bot credential; platforms not using
``PlatformConfig.token`` (Signal session paths, port-binding HTTP adapters) always return True."""
from gateway.config import PLATFORM_TOKEN_ENV_NAMES, Platform
if platform not in PLATFORM_TOKEN_ENV_NAMES:
return True
for attr in ("token", "api_key"): # some adapters accept api_key as the primary credential
value = getattr(platform_config, attr, None) or ""
if isinstance(value, str) and value.strip():
return True
# Matrix also authenticates by password; a token-only check would evict a reconnectable config from
# the retry queue. Read ONLY extra (build_config() copies env there): env fallback = every config OK.
# Those credentials land in ``extra`` rather than ``.token``, so a token-only check reads a perfectly
# reconnectable password-auth config as credential-less and evicts it from the retry queue on the first
# transient failure — after which it stays down until the gateway is restarted by hand. Mirror the
# adapter's own gate: homeserver + user_id + password. Read ONLY from extra, never os.getenv:
# build_config() already copies all three env vars onto extra, and importing this module loads
# ~/.hermes/.env, so an env fallback would report "has credential" for every Matrix config on the box —
# including the empty-primary multiplex case (#64674) this check exists to evict.
if platform is not Platform.MATRIX:
return False
extra = getattr(platform_config, "extra", None) or {}
return all(str(extra.get(key) or "").strip() for key in ("homeserver", "user_id", "password"))
_DOCKER_VOLUME_SPEC_RE = re.compile(r"^(?P<host>.+):(?P<container>/[^:]+?)(?::(?P<options>[^:]+))?$")
_DOCKER_MEDIA_OUTPUT_CONTAINER_PATHS = {"/output", "/outputs"}
# Internal bridge, not a config source: seed from the canonical default after dotenv so an ambient
# process/.env value can never control lease safety.
from hermes_cli.config_defaults import DEFAULT_CONFIG as _DEFAULT_CONFIG
os.environ["HERMES_TURN_LEASE_TIMEOUT"] = str(_DEFAULT_CONFIG["agent"]["gateway_turn_lease_timeout"])
# Bridge config.yaml values into env so os.getenv() picks them up. config.yaml unconditionally wins
# over .env for these keys; a `not in os.environ` guard would let stale .env entries shadow config.
_AGENT_ENV_BRIDGE = {
"gateway_timeout": "HERMES_AGENT_TIMEOUT",
"gateway_turn_lease_timeout": "HERMES_TURN_LEASE_TIMEOUT",
"gateway_timeout_warning": "HERMES_AGENT_TIMEOUT_WARNING",
"gateway_notify_interval": "HERMES_AGENT_NOTIFY_INTERVAL",
"session_stall_timeout": "HERMES_SESSION_STALL_TIMEOUT",
"restart_drain_timeout": "HERMES_RESTART_DRAIN_TIMEOUT",
"cron_drain_timeout": "HERMES_CRON_DRAIN_TIMEOUT",
"gateway_auto_continue_freshness": "HERMES_AUTO_CONTINUE_FRESHNESS",
"gateway_startup_restore_drain_timeout": "HERMES_STARTUP_RESTORE_DRAIN_TIMEOUT",
"gateway_startup_warmup_timeout": "HERMES_STARTUP_WARMUP_TIMEOUT"}
# config-authoritative knobs for the session-search index (env stays the cross-process carrier).
_SESSIONS_ENV_BRIDGE = {"cjk_fts": "HERMES_CJK_FTS", "search_slow_ms": "HERMES_SEARCH_SLOW_MS"}
_DISPLAY_ENV_BRIDGE = {
"busy_input_mode": "HERMES_GATEWAY_BUSY_INPUT_MODE",
"busy_text_mode": "HERMES_GATEWAY_BUSY_TEXT_MODE",
"busy_ack_enabled": "HERMES_GATEWAY_BUSY_ACK_ENABLED"}
def _bridge_section_to_env(section: Any, mapping: Dict[str, str]) -> None:
"""Export every present ``mapping`` key of a config section as ``str(value)``."""
if isinstance(section, dict):
for cfg_key, env_var in mapping.items():
if cfg_key in section:
os.environ[env_var] = str(section[cfg_key])
def _bridge_max_turns_to_env(agent_cfg: Any) -> None:
"""Bridge ``agent.max_turns`` preserving its raw spelling ("none", "unlimited", "120"); Python None
(`null` / bare `key:`) clears a stale bridge instead, since str(None) -> "None" would map to the
unlimited sentinel rather than "absent = default"."""
if not isinstance(agent_cfg, dict) or "max_turns" not in agent_cfg:
return
raw = agent_cfg["max_turns"]
if raw is not None:
os.environ["HERMES_MAX_ITERATIONS"] = str(raw)
elif "HERMES_MAX_ITERATIONS" in os.environ:
del os.environ["HERMES_MAX_ITERATIONS"]
def _bridge_terminal_config_to_env(_terminal_cfg: dict) -> None:
"""Bridge nested ``terminal.*`` config to TERMINAL_* env vars (config.yaml overrides .env here)."""
_terminal_backend = str(
_terminal_cfg.get("backend") or os.environ.get("TERMINAL_ENV") or "").strip().lower()
_terminal_env_map = {
"backend": "TERMINAL_ENV",
"degraded_mode": "TERMINAL_DEGRADED_MODE",
"cwd": "TERMINAL_CWD",
"timeout": "TERMINAL_TIMEOUT",
"home_mode": "TERMINAL_HOME_MODE",
"lifetime_seconds": "TERMINAL_LIFETIME_SECONDS",
"docker_image": "TERMINAL_DOCKER_IMAGE",
"docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV",
"singularity_image": "TERMINAL_SINGULARITY_IMAGE",
"modal_image": "TERMINAL_MODAL_IMAGE",
"daytona_image": "TERMINAL_DAYTONA_IMAGE",
"vercel_runtime": "TERMINAL_VERCEL_RUNTIME",
"ssh_host": "TERMINAL_SSH_HOST",
"ssh_user": "TERMINAL_SSH_USER",
"ssh_port": "TERMINAL_SSH_PORT",
"ssh_key": "TERMINAL_SSH_KEY",
"container_cpu": "TERMINAL_CONTAINER_CPU",
"container_memory": "TERMINAL_CONTAINER_MEMORY",
"container_disk": "TERMINAL_CONTAINER_DISK",
"container_persistent": "TERMINAL_CONTAINER_PERSISTENT",
"docker_volumes": "TERMINAL_DOCKER_VOLUMES",
"docker_env": "TERMINAL_DOCKER_ENV",
"docker_extra_args": "TERMINAL_DOCKER_EXTRA_ARGS",
"docker_shm_size": "TERMINAL_DOCKER_SHM_SIZE",
"docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
"docker_network": "TERMINAL_DOCKER_NETWORK",
"docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER",
"docker_snap_compat": "TERMINAL_DOCKER_SNAP_COMPAT",
"docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES",
"docker_shared_container_key": "TERMINAL_DOCKER_SHARED_CONTAINER_KEY",
"docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER",
"sandbox_dir": "TERMINAL_SANDBOX_DIR",
"persistent_shell": "TERMINAL_PERSISTENT_SHELL"}
for _cfg_key, _env_var in _terminal_env_map.items():
if _cfg_key not in _terminal_cfg:
continue
_val = _terminal_cfg[_cfg_key]
if _cfg_key == "cwd":
# Placeholders (".", "auto", "cwd") resolve to Path.home() later; only explicit paths bridge.
if str(_val) in {".", "auto", "cwd"}:
continue
# Expand "~" for local/container cwd so Popen never gets a literal "~/" (kernel rejects it);
# SSH cwd is interpreted by the remote shell: keep "~". Predicate shared w/ terminal_tool.
if isinstance(_val, str) and not _is_ssh_remote_tilde_cwd(_terminal_backend, _val.strip()):
_val = os.path.expanduser(_val)
os.environ[_env_var] = json.dumps(_val) if isinstance(_val, (list, dict)) else str(_val)
def _bridge_auxiliary_config_to_env(_auxiliary_cfg: dict) -> None:
"""Bridge auxiliary model/endpoint overrides (vision, approval, plugins); compression reads yaml."""
_aux_bridged_keys = {"vision", "approval"}
try:
from hermes_cli.plugins import get_plugin_auxiliary_tasks
for _entry in get_plugin_auxiliary_tasks():
_aux_bridged_keys.add(_entry["key"])
except Exception:
pass # plugin discovery failure must not break startup; built-in bridging stays intact
for _task_key in _aux_bridged_keys:
_task_cfg = _auxiliary_cfg.get(_task_key, {})
if not isinstance(_task_cfg, dict):
continue
_upper = _task_key.upper()
_prov = str(_task_cfg.get("provider", "")).strip()
if _prov and _prov != "auto":
os.environ[f"AUXILIARY_{_upper}_PROVIDER"] = _prov
for _field, _suffix in (("model", "MODEL"), ("base_url", "BASE_URL"), ("api_key", "API_KEY")):
_value = str(_task_cfg.get(_field, "")).strip()
if _value:
os.environ[f"AUXILIARY_{_upper}_{_suffix}"] = _value
def _bridge_config_to_env(_cfg: dict) -> None:
"""Export config.yaml settings to the env vars os.getenv() consumers read."""
for _key, _val in _cfg.items(): # top-level scalars: fallback only, never override .env
if isinstance(_val, (str, int, float, bool)) and _key not in os.environ:
os.environ[_key] = str(_val)
_terminal_cfg = _cfg.get("terminal", {})
if _terminal_cfg and isinstance(_terminal_cfg, dict):
_bridge_terminal_config_to_env(_terminal_cfg)
_auxiliary_cfg = _cfg.get("auxiliary", {})
if _auxiliary_cfg and isinstance(_auxiliary_cfg, dict):
_bridge_auxiliary_config_to_env(_auxiliary_cfg)
# config.yaml is the documented, authoritative source for these settings — it unconditionally wins over
# .env values. Previously the guards below read `if X not in os.environ` and let stale .env entries
# (e.g. HERMES_MAX_ITERATIONS=60 written by an old `hermes setup` run) silently shadow the user's
# current config. See PR #18413 / the 60-vs-500 max_turns incident.
_agent_cfg = _cfg.get("agent", {})
_bridge_max_turns_to_env(_agent_cfg)
_bridge_section_to_env(_agent_cfg, _AGENT_ENV_BRIDGE)
_bridge_section_to_env(_cfg.get("sessions", {}), _SESSIONS_ENV_BRIDGE)
_display_cfg = _cfg.get("display", {})
_bridge_section_to_env(_display_cfg, _DISPLAY_ENV_BRIDGE)
# Documented service-manager override: env wins when set (other display bridges stay config-first).
if (isinstance(_display_cfg, dict) and "busy_steer_ack_enabled" in _display_cfg
and "HERMES_GATEWAY_BUSY_STEER_ACK_ENABLED" not in os.environ):
os.environ["HERMES_GATEWAY_BUSY_STEER_ACK_ENABLED"] = str(_display_cfg["busy_steer_ack_enabled"])
_tz_cfg = _cfg.get("timezone", "")
if _tz_cfg and isinstance(_tz_cfg, str):
os.environ["HERMES_TIMEZONE"] = _tz_cfg.strip()
_security_cfg = _cfg.get("security", {})
if isinstance(_security_cfg, dict) and _security_cfg.get("redact_secrets") is not None:
os.environ["HERMES_REDACT_SECRETS"] = str(_security_cfg["redact_secrets"]).lower()
# Media policy uses the shared bridge so standalone entrypoints (`hermes cron run`) match.
_gateway_cfg = _cfg.get("gateway", {})
if isinstance(_gateway_cfg, dict):
from gateway.media_policy import apply_media_policy_env
apply_media_policy_env(_cfg)
_trust_recent_seconds = _gateway_cfg.get("trust_recent_files_seconds")
if _trust_recent_seconds is not None:
os.environ["HERMES_MEDIA_TRUST_RECENT_SECONDS"] = str(_trust_recent_seconds)
# platform_connect_timeout is an escape hatch, unlike the bridges above: env WINS if already set.
if ("platform_connect_timeout" in _gateway_cfg
and not os.environ.get("HERMES_GATEWAY_PLATFORM_CONNECT_TIMEOUT", "").strip()):
os.environ["HERMES_GATEWAY_PLATFORM_CONNECT_TIMEOUT"] = str(_gateway_cfg["platform_connect_timeout"])
def _load_bridge_config(config_path: Path) -> dict:
"""Effective USER config (no defaults) for the presence-sensitive env bridge: only keys the user
or the managed layer wrote get bridged, else all of DEFAULT_CONFIG would be exported."""
from hermes_cli.config_effective import load_user_config_effective
return load_user_config_effective(config_path)
_config_path = _hermes_home / 'config.yaml'
_cfg: dict = {}
if _config_path.exists():
try:
_cfg = _load_bridge_config(_config_path)
_bridge_config_to_env(_cfg)
except Exception as _bridge_err:
# stderr, not logger: the module logger is not initialized yet at import time.
print(
f" Warning: config.yaml → env bridge failed: {type(_bridge_err).__name__}: {_bridge_err}",
file=sys.stderr)
print(
" Gateway will fall back to .env values, which may not match "
"your current config.yaml. Run `hermes doctor` to investigate.",
file=sys.stderr)
# IPv4 preference must apply before any HTTP clients are created.
try:
from hermes_constants import apply_ipv4_preference
_network_cfg = _cfg.get("network", {})
if isinstance(_network_cfg, dict) and _network_cfg.get("force_ipv4"):
apply_ipv4_preference(force=True)
except Exception as _bootstrap_exc:
print(f" Warning: IPv4 preference application failed: {_bootstrap_exc}", file=sys.stderr)
try:
from hermes_cli.config import print_config_warnings
print_config_warnings()
except Exception as _bootstrap_exc:
print(f" Warning: config validation failed: {_bootstrap_exc}", file=sys.stderr)
try:
from hermes_cli.config import warn_deprecated_cwd_env_vars
warn_deprecated_cwd_env_vars()
except Exception as _bootstrap_exc:
print(f" Warning: deprecation check failed: {_bootstrap_exc}", file=sys.stderr)
os.environ["HERMES_QUIET"] = "1" # gateway runs quiet: no debug output, cwd used directly
# HERMES_EXEC_ASK is set in start_gateway(), NOT at import: CLI tools importing this module must not
# flip interactive sessions into ask-mode (approval prompts would become silent pending_approval).
# Terminal cwd: config.yaml terminal.cwd is canonical (bridged above); MESSAGING_CWD is legacy fallback.
from gateway.cwd_placeholder import CWD_PLACEHOLDERS, resolve_placeholder_terminal_cwd
_configured_cwd = os.environ.get("TERMINAL_CWD", "")
if not _configured_cwd or _configured_cwd in CWD_PLACEHOLDERS:
_resolved_cwd = resolve_placeholder_terminal_cwd(
configured_cwd=_configured_cwd,
terminal_backend=os.environ.get("TERMINAL_ENV", ""),
messaging_cwd=os.getenv("MESSAGING_CWD"),
docker_mount_cwd_to_workspace=os.getenv(
"TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "false").lower()
in {"true", "1", "yes"},
home_fallback=str(Path.home()))
if _resolved_cwd is None:
os.environ.pop("TERMINAL_CWD", None)
else:
os.environ["TERMINAL_CWD"] = _resolved_cwd
from gateway.config import (
ChannelOverride, Platform, GatewayConfig, PlatformConfig, _getenv, load_gateway_config)
from gateway.session import (
AsyncSessionStore, SessionStore, SessionSource, SessionContext, build_session_key,
profile_from_session_key_namespace)
# Telegram topic routing (#22773, regression fixed #52060): a
# ``telegram:<positive_chat_id>:<numeric_thread_id>`` cron target is ambiguous — a forum-style topic in a
# private chat and a genuine Bot API channel Direct-Messages topic share the same shape and need OPPOSITE
# routing. Disambiguate at delivery time via ``_is_channel_dm_topic`` (see its docstring for the full
# rationale); ``thread_id`` goes in ``route_metadata`` so the anchorless cron send bypasses the
# DeliveryRouter's private-chat reply-anchor requirement. Compute the routed metadata ONCE so both the text
# send (via DeliveryRouter) and the media send agree.
from gateway.delivery import DeliveryRouter
from gateway.turn_lease import SessionTurnLeaseRegistry
from gateway.session_state import SessionState, legacy_dict_property, legacy_lease_token_property
from gateway.authz_mixin import GatewayAuthorizationMixin
from gateway.kanban_watchers import GatewayKanbanWatchersMixin
from gateway.slash_commands import GatewaySlashCommandsMixin
from gateway.run_voice import GatewayVoiceMixin
from gateway.run_adapters import GatewayAdapterLifecycleMixin
from gateway.run_topics import GatewayTopicThreadsMixin
from gateway.run_turn import GatewayTurnMixin, is_context_overflow_failure_result
from gateway.run_shutdown import GatewayShutdownMixin, _resolve_gateway_exit_verdict
from gateway.run_busy import GatewayBusySessionMixin
from gateway.run_config_loaders import GatewayConfigLoadersMixin
from gateway.run_startup import GatewayStartupMixin
from gateway.run_watchers import GatewaySessionWatchersMixin
from gateway.run_notifications import GatewayNotificationsMixin
from gateway.run_inbound import GatewayInboundMixin
from gateway.run_goals import GatewayGoalsMixin
from gateway.run_agent_cache import GatewayAgentCacheMixin
from gateway.run_profile_reconcile import GatewayProfileReconcileMixin
from gateway.platforms.base import (
BasePlatformAdapter,
_reply_anchor_for_event,
_terminal_sentinel_start,
)
from gateway.platforms.event import MessageEvent, MessageType
from gateway.restart import (
DEFAULT_GATEWAY_CRON_DRAIN_TIMEOUT,
DEFAULT_GATEWAY_RESTART_AFTER_TURN_TIMEOUT,
DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT,
DEFAULT_GATEWAY_SIGNAL_INTERRUPT_GRACE_TIMEOUT)
logger = logging.getLogger(__name__)
def _best_effort(fn: Callable[[], Any], debug_msg: Optional[str] = None) -> Any:
"""Call ``fn``; return None on any Exception (debug-logged via ``debug_msg`` ``%s`` if given)."""
try:
return fn()
except Exception as exc:
if debug_msg:
logger.debug(debug_msg, exc)
return None
# Shutdown quiesce ceiling for the gateway-owned thread pool. Drain already waited for the agents; what
# remains is short blocking work; anything slower is a stuck worker not worth waiting on (leash-clamped).
_EXECUTOR_QUIESCE_TIMEOUT = 2.0
_OWN_POLICY_OPEN_ENV = {
Platform.WECOM: ("WECOM_DM_POLICY", "WECOM_GROUP_POLICY", "WECOM_ALLOW_ALL_USERS"),
Platform.WEIXIN: ("WEIXIN_DM_POLICY", "WEIXIN_GROUP_POLICY", "WEIXIN_ALLOW_ALL_USERS"),
Platform.YUANBAO: ("YUANBAO_DM_POLICY", "YUANBAO_GROUP_POLICY", "YUANBAO_ALLOW_ALL_USERS"),
Platform.QQBOT: (None, None, "QQ_ALLOW_ALL_USERS"),
Platform.WHATSAPP: ("WHATSAPP_DM_POLICY", "WHATSAPP_GROUP_POLICY", "WHATSAPP_ALLOW_ALL_USERS")}
def _own_policy_open_startup_violation(config) -> Optional[str]:
"""Return a startup-abort reason when open policy lacks allow-all opt-in."""
for platform, platform_config in getattr(config, "platforms", {}).items():
if not getattr(platform_config, "enabled", False):
continue
open_env = _OWN_POLICY_OPEN_ENV.get(platform)
if not open_env:
continue
dm_env, group_env, allow_all_env = open_env
extra = getattr(platform_config, "extra", None) or {}
dm_policy = str(extra.get("dm_policy")
or (_getenv(dm_env, "pairing") if dm_env else "pairing")).strip().lower()
group_policy = str(
extra.get("group_policy") or (_getenv(group_env, "pairing") if group_env else "pairing")
).strip().lower()
if dm_policy != "open" and group_policy != "open":
continue
gateway_allow_all = _getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}
if gateway_allow_all or (
allow_all_env and _getenv(allow_all_env, "").lower() in {"true", "1", "yes"}):
continue
return f"{platform.value}: open policy without allow-all opt-in"
return None
# Placed into _running_agents *before* any await so a second message can't slip past the "already
# running" guard before the agent exists.
_AGENT_PENDING_SENTINEL = object()
# Conversation-scoped per-session state registry (legacy contract). State lives in
# ``SessionState.conversation`` (cleared via ``ConversationState.clear()``); this list remains for
# plain-dict stores not yet folded in (``_pending_model_notes``, popped per-key by
# _clear_conversation_scope) and the public test contract. NOT listed (different lifecycles): turn-scoped
# _running_agents*/_active_session_leases/_busy_ack_ts/_turn_lease_tokens (_release_running_agent_state +
# dispatch finally); _session_run_generation (monotonic; clearing breaks stale-run detection);
# _agent_cache (_evict_cached_agent); approval/slash-confirm (_clear_session_boundary_security_state).
# The state itself now lives in ``SessionState.conversation`` (see gateway/session_state.py) and boundaries
# clear it structurally via ``ConversationState.clear()`` — adding a field to ConversationState means every
# boundary picks it up automatically. History: boundaries used to each carry a hand-copied pop-list that
# drifted whenever a new dict was added (#48031, #58403, #10702, #35809). - _agent_cache: has its own
# eviction path (_evict_cached_agent) with resource cleanup; boundaries call it explicitly.
_CONVERSATION_SCOPED_STATE: tuple = (
"_session_model_overrides",
"_pending_one_turn_model_restores",
"_session_reasoning_overrides",
"_session_service_tier_overrides",
"_pending_model_notes",
"_last_resolved_model",
"_queued_events",
# Stall-watchdog "already notified" latch; cleared on /new so a fresh conversation can warn again.
# See #72016.
"_session_stall_notified",
# Transcript-lag streak counter (#114266); a fresh conversation starts with no lag history.
"_transcript_lag_streaks",
# Sidecar notes staged but never consumed (turn aborted before run_sync) must not leak into a
# future conversation's first user message — session keys are source-derived and REUSED.
"_pending_turn_sidecar_notes")
def _resolve_runtime_agent_kwargs() -> dict:
"""Resolve provider credentials for gateway-created AIAgent instances.
``resolve_runtime_provider()`` may fall back to env vars; behavioral config is config.yaml only.
An ``AuthError`` from the primary walks the configured fallback chain through the shared
``resolve_runtime_with_fallback`` (the gateway keeps no resolver loop of its own)."""
from hermes_cli.runtime_provider import (
resolve_runtime_with_fallback, format_runtime_provider_error, _get_model_config)
# Capture primary provider/model from config before the try block so we
# can include it in the fallback notice if the primary fails (#74349).
_model_cfg = _get_model_config()
_primary_model = (_model_cfg.get("default") or "").strip()
_primary_provider = (_model_cfg.get("provider") or "").strip()
try:
runtime, fallback_entry = resolve_runtime_with_fallback(_load_gateway_config())
except Exception as exc:
raise RuntimeError(format_runtime_provider_error(exc)) from exc
if fallback_entry is not None:
# The entry's model is the one this agent must send (#112600). Carry the fallback notice so the
# gateway can surface a user-visible provider switch (#74349); the caller must pop
# ``_fallback_notice`` before forwarding kwargs to AIAgent.
return {**_runtime_agent_kwargs(runtime), "model": fallback_entry["model"],
"_fallback_notice": pre_agent_fallback_notice(
_primary_provider, _primary_model,
runtime.get("provider") or fallback_entry.get("provider") or "unknown",
fallback_entry.get("model") or "default")}
capabilities = runtime.get("capabilities")
capabilities = (
{k: v for k, v in capabilities.items() if isinstance(k, str) and isinstance(v, bool)}
if isinstance(capabilities, dict) else {})
return {**_runtime_agent_kwargs(runtime), "capabilities": capabilities}
def _runtime_agent_kwargs(runtime: dict) -> dict:
"""AIAgent constructor kwargs shared by every runtime-provider resolution.
``request_overrides`` passes through as resolved so the provider's request body reaches each turn."""
return {
"api_key": runtime.get("api_key"),
"base_url": runtime.get("base_url"),
"provider": runtime.get("provider"),
"requested_provider": runtime.get("requested_provider"),
"api_mode": runtime.get("api_mode"),
"command": runtime.get("command"),
"args": list(runtime.get("args") or []),
"credential_pool": runtime.get("credential_pool"),
"request_overrides": runtime.get("request_overrides")}
@dataclasses.dataclass(frozen=True)
class _GatewayModelContext:
"""Effective gateway model route and context-window resolution."""
model: str
provider: str
base_url: str
context_length: int
context_source: str
def _resolve_gateway_model_context(
model: Optional[str] = None, route: Optional[dict] = None,
) -> _GatewayModelContext:
"""Resolve the configured gateway route and effective context window. Call off-loop (may block).
``route`` (``provider`` / ``base_url`` / ``api_key`` of a session-only /model switch) replaces the
default runtime credentials so the window is looked up against the endpoint that actually serves
``model``; a ``model.context_length`` pin only survives when that route still matches the config.
``context_source`` is ``"default"`` only for a model unknown to the catalog that fell through to
``DEFAULT_FALLBACK_CONTEXT`` — a catalog-listed 256K model is ``"detected"``.
"""
from agent.model_metadata import (
DEFAULT_CONTEXT_LENGTHS, DEFAULT_FALLBACK_CONTEXT, _longest_key_match, get_model_context_length)
resolved_model = model or _resolve_gateway_model()
config_context_length = provider = base_url = api_key = custom_providers = None
configured_model = configured_provider = configured_base_url = None
def _read_config() -> None:
nonlocal config_context_length, provider, base_url, custom_providers
nonlocal configured_model, configured_provider, configured_base_url
data = _load_gateway_config()
if not data:
return
model_cfg = data.get("model", {})
if isinstance(model_cfg, dict):
configured_model = model_cfg.get("default") or model_cfg.get("model")
raw_ctx = model_cfg.get("context_length")
if raw_ctx is not None:
with suppress(TypeError, ValueError):
config_context_length = int(raw_ctx)
configured_provider = provider = model_cfg.get("provider") or None
configured_base_url = base_url = model_cfg.get("base_url") or None
try:
from hermes_cli.config import get_compatible_custom_providers
custom_providers = get_compatible_custom_providers(data)
except Exception:
custom_providers = data.get("custom_providers")
def _read_runtime() -> None:
nonlocal provider, base_url, api_key
if route and route.get("base_url"):
# A session route with its own endpoint (a /model switch) replaces the default runtime
# read; a route without one (persisted / SessionDB / plain config) still resolves the
# default runtime credentials so a custom endpoint and its context_length pin survive.
provider = route.get("provider") or provider
base_url = route["base_url"]
api_key = route.get("api_key")
return
runtime = _resolve_runtime_agent_kwargs()
provider = runtime.get("provider") or provider
base_url = runtime.get("base_url") or base_url
api_key = runtime.get("api_key")
def _pin_still_applies() -> bool:
# Drop a configured context_length pin when the effective route no longer matches (or on error).
from hermes_cli.route_identity import should_clear_context_pin
return not should_clear_context_pin(
configured_model, resolved_model, configured_base_url, base_url, configured_provider, provider)
def _custom_ctx() -> Optional[int]:
from hermes_cli.config import get_custom_provider_context_length
return get_custom_provider_context_length(
model=resolved_model, base_url=base_url, custom_providers=custom_providers)
_best_effort(_read_config)
_best_effort(_read_runtime)
if config_context_length is not None and not _best_effort(_pin_still_applies):
config_context_length = None
if config_context_length is None and custom_providers and base_url:
config_context_length = _best_effort(_custom_ctx) or None
context_length = get_model_context_length(
resolved_model, base_url=base_url or "", api_key=api_key or "",
config_context_length=config_context_length, provider=provider or "",
custom_providers=custom_providers)
fell_through = (context_length == DEFAULT_FALLBACK_CONTEXT
and _longest_key_match(DEFAULT_CONTEXT_LENGTHS, str(resolved_model).lower()) is None)
context_source = ("config" if config_context_length is not None
else "default" if fell_through else "detected")
return _GatewayModelContext(
model=resolved_model, provider=provider or "", base_url=base_url or "",
context_length=context_length, context_source=context_source)
def _resolve_runtime_agent_kwargs_for_provider(provider: str, target_model: Optional[str] = None) -> dict:
"""Resolve runtime credentials for a specific provider (e.g. from channel override).
``target_model`` is the model the override will actually send: the ladder's model-keyed rungs
(Zen/Go relay + api_mode) must see it rather than config's ``default``, or a Go-only override
resolves an api_mode/base_url the sent model cannot use (#112600)."""
from hermes_cli.runtime_provider import resolve_runtime_provider, format_runtime_provider_error
try:
runtime = resolve_runtime_provider(requested=provider, target_model=target_model or None)
except Exception as exc:
raise RuntimeError(format_runtime_provider_error(exc)) from exc
return {
**_runtime_agent_kwargs(runtime),
"request_overrides": dict(runtime.get("request_overrides") or {}),
"capabilities": dict(runtime.get("capabilities") or {})}
def _deep_merge_request_overrides(base: Optional[dict], override: Optional[dict]) -> dict:
"""Merge request_overrides dicts, deep-merging nested dictionaries."""
from hermes_cli.config import _deep_merge
base_dict = dict(base or {})
override_dict = dict(override or {})
if not base_dict:
return override_dict
if not override_dict:
return base_dict
return _deep_merge(base_dict, override_dict)
def _credential_pool_for_provider(provider: Optional[str]):
"""Return the live credential pool for a provider id (e.g. ``custom:hyper``)."""
if not provider or not str(provider).strip():
return None
try:
return _resolve_runtime_agent_kwargs_for_provider(str(provider).strip()).get("credential_pool")
except Exception:
logger.debug("Failed to resolve credential pool for provider=%s", provider, exc_info=True)
return None
def _event_media_type_at(event, index: int) -> str:
"""Per-attachment MIME at *index*; "" when the adapter set only a message-level type."""
media_types = getattr(event, "media_types", None) or []
return media_types[index] if index < len(media_types) else ""
def _event_media_kind_is(event, index: int, mime_prefix: str, fallback_types: frozenset) -> bool:
"""Per-attachment MIME first, message-level type only when unknown (else a document uploaded
alongside an image is base64'd as vision and the provider 400s)."""
mtype = _event_media_type_at(event, index)
if mtype:
return mtype.startswith(mime_prefix)
return getattr(event, "message_type", None) in fallback_types
def _event_media_is_image(event, index: int) -> bool:
return _event_media_kind_is(event, index, "image/", frozenset({MessageType.PHOTO}))
def _event_media_is_audio(event, index: int) -> bool:
return _event_media_kind_is(event, index, "audio/", frozenset({MessageType.VOICE, MessageType.AUDIO}))
def _event_media_is_stt_input(event, index: int) -> bool:
"""True when an audio attachment should enter the automatic STT pipeline."""
message_type = getattr(event, "message_type", None)
if message_type in {MessageType.AUDIO, MessageType.DOCUMENT}:
return False
return message_type == MessageType.VOICE or _event_media_type_at(event, index).startswith("audio/")
def _event_media_is_video(event, index: int) -> bool:
return _event_media_kind_is(event, index, "video/", frozenset({MessageType.VIDEO}))
def _build_media_placeholder(event) -> str:
"""Text placeholder for media-only events (later replaced by vision enrichment).
Queued media is dequeued via .text only, so a caption-less event would otherwise be lost."""
parts = []
media_urls = getattr(event, "media_urls", None) or []
for i, url in enumerate(media_urls):
if _event_media_is_image(event, i):
parts.append(f"[User sent an image: {url}]")
elif _event_media_is_audio(event, i):
parts.append(f"[User sent audio: {url}]")
elif _event_media_is_video(event, i):
parts.append(f"[User sent a video: {url}]")
else:
parts.append(f"[User sent a file: {url}]")
return "\n".join(parts)
def _build_document_context_note(
display_name: str, agent_path: str, mtype: str, *, content_inlined: bool = True) -> str:
"""Context note prepended to a user turn when they attach a document.
``content_inlined=False`` = cached without content, so tell the agent to read it. Binary docs must
say *extract* the text; "ask the user" made it punt."""
if mtype.startswith("text/") and content_inlined:
return (
f"[The user sent a text document: '{display_name}'. Its content has been included below. "
f"The file is also saved at: {agent_path}]")
if mtype.startswith("text/"):
return (
f"[The user sent a text document: '{display_name}'. It is saved at: {agent_path}. "
f"Its content is not inlined here. Read the cached file yourself before answering "
f"when the user's request involves its contents.]")
return (
f"[The user sent a document: '{display_name}'. It is saved at: {agent_path}. "
f"Its text is not inlined here (it's a binary format such as PDF or DOCX). "
f"To read it, extract the document's text yourself — for example with the "
f"terminal tool or the ocr-and-documents skill — before answering, instead "
f"of asking the user to paste the contents.]")
def _format_duration(seconds: float) -> str:
total = max(0, int(round(seconds)))
hours, rem = divmod(total, 3600)
minutes, secs = divmod(rem, 60)
if hours:
return f"{hours}:{minutes:02d}:{secs:02d}"
return f"{minutes}:{secs:02d}"
async def _probe_audio_duration(path: str) -> Optional[str]:
"""Best-effort duration probe. Returns formatted MM:SS / HH:MM:SS, or None on failure."""
ext = os.path.splitext(path)[1].lower()
if ext == ".wav":
try:
def _wav_duration() -> float:
import wave
with wave.open(path, "rb") as wf:
frames = wf.getnframes()
rate = wf.getframerate() or 1
return frames / float(rate)
return _format_duration(await asyncio.to_thread(_wav_duration))
except Exception:
pass
if ext in (".ogg", ".opus", ".oga"):
try:
def _ogg_duration() -> float:
from mutagen.oggopus import OggOpus
return float(OggOpus(path).info.length)
return _format_duration(await asyncio.to_thread(_ogg_duration))
except Exception:
pass
try:
proc = await asyncio.create_subprocess_exec(
"ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", path,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5.0)
if proc.returncode == 0:
return _format_duration(float(stdout.decode().strip()))
except Exception:
pass
return None
def _dequeue_pending_event(adapter, session_key: str) -> MessageEvent | None:
"""Consume and return the pending event; media metadata is kept so follow-ups re-enter preprocessing.
"""
return adapter.get_pending_message(session_key)
_INTERRUPT_REASON_STOP = "Stop requested"
_INTERRUPT_REASON_RESET = "Session reset requested"
_INTERRUPT_REASON_TIMEOUT = "Execution timed out (inactivity)"
# ``tool_reason`` for the inactivity timeout: attributes the stop to the gateway watchdog (#112647).
_INTERRUPT_TOOL_REASON_TIMEOUT = "gateway inactivity watchdog"
_INTERRUPT_REASON_EVICTED = "Session ended while the turn was running"
# ``tool_reason`` for eviction / shutdown: these stops are system-issued, not user stops (#112647).
_INTERRUPT_TOOL_REASON_EVICTED = "session evicted"
_INTERRUPT_TOOL_REASON_GATEWAY_SHUTDOWN = "gateway shutdown"
_INTERRUPT_REASON_SSE_DISCONNECT = "SSE client disconnected"
_INTERRUPT_REASON_GATEWAY_SHUTDOWN = "Gateway shutting down"
_INTERRUPT_REASON_GATEWAY_RESTART = "Gateway restarting"
def _reap_gateway_turn_processes(
task_id: str, process_baseline, *, source: str,
is_still_current: Optional[Callable[[], bool]] = None) -> int:
"""Reap only background processes created by one abandoned turn.
``task_id`` is session-scoped, so a *replacement* turn can spawn its own process mid-reap;
``is_still_current`` lets the caller bail instead of killing it (that turn owns its own baseline)."""
if not task_id:
# Blank task_id (sessionless callers) would match and kill every unrelated empty-task process.
return 0
if is_still_current is not None:
try:
if not is_still_current():
logger.debug(
"Skipping reap for turn %s (%s): a newer turn already "
"claimed this session; it owns its own baseline.", task_id, source)
return 0
except Exception:
logger.debug(
"is_still_current check failed for turn %s (%s); reaping anyway",
task_id, source, exc_info=True)
from tools.process_registry import process_registry
try:
killed = process_registry.kill_started_since(task_id, process_baseline, source=source)
except Exception:
# Detached daemon thread: an uncaught exception would only reach threading.excepthook.
logger.warning(
"Failed to reap background processes for turn %s (%s)", task_id, source, exc_info=True)
return 0
if killed:
logger.warning(
"Reaped %d background process(es) created by abandoned turn %s (%s)",
killed, task_id, source)
return killed
_TURN_STACK_DUMP_FRAME_MARKERS = (
"run_conversation", "run_sync", "_run_sync_with_timeout_lifecycle",
"finalize_turn", "end_turn", "run_in_session")
def _dump_wedged_turn_stacks(task_id: str) -> None:
"""Log the stack of every thread that looks like turn work, at reap time.
The hard interrupt frees the wedged worker before a profiler can attach, so dump BEFORE it.
Best-effort, bounded (turn-machinery threads only, capped output), never raises."""
try:
frames = sys._current_frames()
names = {t.ident: t.name for t in threading.enumerate()}
dumped = 0
for ident, frame in frames.items():
if ident == threading.get_ident():
continue # the reaper itself
stack = traceback.format_stack(frame)
joined = "".join(stack)
if not any(marker in joined for marker in _TURN_STACK_DUMP_FRAME_MARKERS):
continue
dumped += 1
if dumped > 8:
logger.error(
"Wedged-turn stack dump for task %s truncated: more than 8 candidate threads",
task_id)
break
logger.error(
"Wedged-turn stack dump (task=%s thread=%s ident=%s):\n%s",
task_id, names.get(ident, "?"), ident, "".join(stack[-25:]))
if dumped == 0:
logger.error(
"Wedged-turn stack dump for task %s: no thread with "
"turn-machinery frames found (worker may have already exited)", task_id)
except Exception:
logger.debug("Wedged-turn stack dump failed", exc_info=True)
def _abandon_timed_out_gateway_turn(
*, agent_holder, task_id: str, process_baseline, worker_done: threading.Event,
timeout_fired: threading.Event, cleanup_lock: threading.Lock,
is_still_current: Optional[Callable[[], bool]] = None) -> bool:
"""Interrupt one timed-out turn and reap only processes it created."""
with cleanup_lock:
if worker_done.is_set() or timeout_fired.is_set():
return False
timeout_fired.set()
# BEFORE interrupting: the interrupt frees the blocked frame, destroying the only evidence.
_dump_wedged_turn_stacks(task_id)
agent = agent_holder[0] if agent_holder else None
if agent is not None:
try:
request_hard_interrupt(agent, _INTERRUPT_REASON_TIMEOUT, tool_reason=_INTERRUPT_TOOL_REASON_TIMEOUT)
except Exception:
logger.debug("Timed-out agent interrupt failed", exc_info=True)
try:
_reap_gateway_turn_processes(
task_id, process_baseline, source="gateway_turn_timeout",
is_still_current=is_still_current)
except Exception:
logger.warning(
"Failed to reap background processes for timed-out turn %s", task_id, exc_info=True)
return True
def _watch_gateway_turn_inactivity(
*, agent_holder, task_id: str, process_baseline, timeout: float, worker_done: threading.Event,
timeout_fired: threading.Event, cleanup_lock: threading.Lock, poll_interval: float = 5.0,
is_still_current: Optional[Callable[[], bool]] = None) -> None:
"""Thread watchdog that remains runnable when gateway asyncio is starved.
Until an agent publishes a usable activity snapshot, elapsed worker time is the
liveness clock. Otherwise a provider hang before activity initialization can
retain the session turn lease forever because every watchdog poll just skips it.
"""
activity_origin = time.monotonic()
while not worker_done.wait(max(0.01, poll_interval)):
now = time.monotonic()
idle_seconds = now - activity_origin
agent = agent_holder[0] if agent_holder else None
if agent is not None and hasattr(agent, "get_activity_summary"):
try:
reported_idle = agent.get_activity_summary().get("seconds_since_activity")
if reported_idle is not None:
idle_seconds = max(0.0, float(reported_idle))
# Preserve the most recent usable activity clock as the fallback if
# a later provider-side diagnostic read raises or returns None.
activity_origin = now - idle_seconds
except Exception:
pass
if idle_seconds < timeout:
continue
_abandon_timed_out_gateway_turn(
agent_holder=agent_holder, task_id=task_id, process_baseline=process_baseline,
worker_done=worker_done, timeout_fired=timeout_fired, cleanup_lock=cleanup_lock,
is_still_current=is_still_current)
return
_CONTROL_INTERRUPT_MESSAGES = frozenset({
_INTERRUPT_REASON_STOP.lower(), _INTERRUPT_REASON_RESET.lower(),
_INTERRUPT_REASON_TIMEOUT.lower(), _INTERRUPT_REASON_SSE_DISCONNECT.lower(),
_INTERRUPT_REASON_EVICTED.lower(), _INTERRUPT_REASON_GATEWAY_SHUTDOWN.lower(),
_INTERRUPT_REASON_GATEWAY_RESTART.lower()})
def _is_control_interrupt_message(message: Optional[str]) -> bool:
"""Return True when an interrupt message is internal control flow."""
if not message:
return False
return " ".join(str(message).strip().split()).lower() in _CONTROL_INTERRUPT_MESSAGES
def _strip_response_attachments_for_direct_send(response: str, adapter) -> str:
"""Return the visible text portion of a response before direct send().
Only explicit ``MEDIA:`` attachments are stripped; bare paths/URLs stay visible. No broad regex after
``extract_media()``: it deliberately preserves protected code spans and unvalidated tags.
Queued follow-up resends only replay explicit ``MEDIA:`` attachments in this path. Keep bare local paths
and ordinary image URLs visible because the post-stream uploader intentionally ignores them (#20834).
"""
_, cleaned = adapter.extract_media(response)
return cleaned.replace("[[audio_as_voice]]", "").replace("[[as_document]]", "").strip()
def _skill_slug_from_frontmatter(skill_md: Path) -> tuple[str | None, str | None]:
"""Derive ``(slug, declared_name)`` from a SKILL.md; ``(None, None)`` if unreadable or no ``name:``.
Matches ``scan_skill_commands``: the slug comes from frontmatter ``name:``, NOT the directory."""
try:
content = skill_md.read_text(encoding="utf-8", errors="replace")
except Exception:
return None, None
content = content.lstrip("\ufeff") # tolerate UTF-8 BOM (Windows editors)
if not content.startswith("---"):
return None, None
end = content.find("\n---", 3)
if end < 0:
return None, None
declared_name: str | None = None
for line in content[3:end].splitlines():
line = line.strip()
if line.startswith("name:"):
raw = line.split(":", 1)[1].strip()
if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in {'"', "'"}:
raw = raw[1:-1]
declared_name = raw.strip()
break
if not declared_name:
return None, None
slug = declared_name.lower().replace(" ", "-").replace("_", "-")
# Mirrors _SKILL_INVALID_CHARS / _SKILL_MULTI_HYPHEN from skill_commands
slug = re.sub(r"[^a-z0-9-]", "", slug)
slug = re.sub(r"-{2,}", "-", slug).strip("-")
return (slug or None), declared_name
def _check_unavailable_skill(command_name: str) -> str | None:
"""Hint when a command matches a skill that is disabled or optional-install only; else None."""
normalized = command_name.lower().replace("_", "-")
try:
from tools.skills_tool import _get_disabled_skill_names
from agent.skill_utils import get_all_skills_dirs, is_excluded_skill_path
disabled = _get_disabled_skill_names()
for skills_dir in get_all_skills_dirs():
if not skills_dir.exists():
continue
for skill_md in skills_dir.rglob("SKILL.md"):
if is_excluded_skill_path(skill_md):
continue
slug, declared_name = _skill_slug_from_frontmatter(skill_md)
if not slug or not declared_name:
continue
# disabled is keyed by the declared frontmatter name (what skills.disabled stores).
if slug == normalized and declared_name in disabled:
return (
f"The **{command_name}** skill is installed but disabled.\n"
f"Enable it with: `hermes skills config`")
# Check optional skills (shipped with repo but not installed)
from hermes_constants import get_optional_skills_dir
repo_root = Path(__file__).resolve().parent.parent
optional_dir = get_optional_skills_dir(repo_root / "optional-skills")
if optional_dir.exists():
for skill_md in optional_dir.rglob("SKILL.md"):
if is_excluded_skill_path(skill_md):
continue
slug, _declared = _skill_slug_from_frontmatter(skill_md)
if not slug or slug != normalized:
continue
# Install path: official/<category>/<name>
rel = skill_md.parent.relative_to(optional_dir)
install_path = f"official/{'/'.join(rel.parts)}"
return (
f"The **{command_name}** skill is available but not installed.\n"
f"Install it with: `hermes skills install {install_path}`")
except Exception:
pass
return None
def _platform_config_key(platform: "Platform") -> str:
"""Map a Platform enum to its config.yaml key (LOCAL→"cli", rest→enum value)."""
return "cli" if platform == Platform.LOCAL else platform.value
def _teams_pipeline_plugin_enabled() -> bool:
"""Return True when the standalone Teams pipeline plugin is enabled."""
enabled = cfg_get(_load_gateway_config(), "plugins", "enabled", default=[])
return isinstance(enabled, list) and ("teams_pipeline" in enabled or "teams-pipeline" in enabled)
def _gateway_config_home() -> Path:
"""Return the Hermes home that gateway config reads should use."""
override = get_hermes_home_override()
return Path(override) if override else _hermes_home
def _load_gateway_config(config_path: "Path | None" = None) -> dict:
"""The effective user config.yaml (managed overlay, ``${VAR}`` expansion, model-key canon; no
DEFAULT_CONFIG merge) — ``{}`` on any error (fail-open). Defaults to the active gateway home
(``_hermes_home`` monkeypatches apply); multiplexers pass a path.
"""
if config_path is None:
config_path = _gateway_config_home() / 'config.yaml'
try:
from hermes_cli.config_effective import load_user_config_effective
return load_user_config_effective(config_path)
except Exception:
logger.debug("Could not load gateway config from %s", config_path, exc_info=True)
return {}
def _checkpoint_agent_kwargs(config: dict | None) -> dict:
"""Translate gateway checkpoint config into ``AIAgent`` constructor args.
Gateway bypasses ``load_config()``, so defaults are here; legacy ``checkpoints: true`` works."""
cp_cfg = config.get("checkpoints", {}) if isinstance(config, dict) else {}
if isinstance(cp_cfg, bool):
cp_cfg = {"enabled": cp_cfg}
elif not isinstance(cp_cfg, dict):
cp_cfg = {}
from hermes_cli.config import DEFAULT_CONFIG
defaults = DEFAULT_CONFIG["checkpoints"]
return {
"checkpoints_enabled": cp_cfg.get("enabled", defaults["enabled"]),
"checkpoint_max_snapshots": cp_cfg.get("max_snapshots", defaults["max_snapshots"]),
"checkpoint_max_total_size_mb": cp_cfg.get("max_total_size_mb", defaults["max_total_size_mb"]),
"checkpoint_max_file_size_mb": cp_cfg.get("max_file_size_mb", defaults["max_file_size_mb"])}
def _resolve_gateway_model(config: dict | None = None) -> str:
"""Read model from config.yaml (single source of truth), else temporary AIAgents (e.g. /compress)
use the hardcoded default, which fails under openai-codex."""
cfg = config if config is not None else _load_gateway_config()
model_cfg = cfg.get("model", {})
if isinstance(model_cfg, str):
return model_cfg
elif isinstance(model_cfg, dict):
return model_cfg.get("default") or model_cfg.get("model") or ""
return ""
def _channel_override_lookup_keys(
chat_id: str, *, thread_id: Optional[str] = None, parent_id: Optional[str] = None) -> list[str]:
"""Ordered, de-duplicated ``channel_overrides`` lookup keys (matches ``resolve_channel_prompt``:
exact id first, then parent — Discord threads inherit parent overrides)."""
return list(dict.fromkeys(str(key) for key in (chat_id, thread_id, parent_id) if key))
def _get_channel_override(
config: GatewayConfig, platform: Platform, chat_id: str, *, thread_id: Optional[str] = None,
parent_id: Optional[str] = None) -> Optional[ChannelOverride]:
"""Per-channel override via chat_id, then thread_id, then parent_id; None if absent."""
platforms = getattr(config, "platforms", None)
if not platforms:
return None
platform_config = platforms.get(platform)
if not platform_config or not platform_config.channel_overrides:
return None
overrides = platform_config.channel_overrides
for key in _channel_override_lookup_keys(chat_id, thread_id=thread_id, parent_id=parent_id):
ov = overrides.get(key)
if ov is not None:
return ov
return None
def _resolve_hermes_bin() -> Optional[list[str]]:
"""Hermes update/restart argv: the running interpreter's ``python -m hermes_cli.main``
(exactly this install), else ``hermes`` on PATH, else None. The module argv must win: a
PATH-first lookup lets an attacker-planted ``hermes`` shadow the running install when
/update or /restart re-execs it (#111569)."""
try:
import importlib.util
if importlib.util.find_spec("hermes_cli") is not None:
return [sys.executable, "-m", "hermes_cli.main"]
except Exception:
pass
import shutil
hermes_bin = shutil.which("hermes")
if hermes_bin:
return [hermes_bin]
return None
_PROFILE_ID_KEY_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
def _parse_session_key(session_key: str) -> "dict | None":
"""Parse a session key (``agent:{ns}:{platform}:{chat_type}:{chat_id}[:{extra}...]``).
``{ns}`` is ``main`` for the default profile, ``main~`` for a profile literally named ``main``
(``gateway.session._session_key_namespace``), or a named-profile id (profile ids match
``[a-z0-9][a-z0-9_-]{0,63}`` — never contain ``:`` — so a plain split stays unambiguous).
For group/channel sessions the suffix may be a user_id, not a thread_id, so ``thread_id``
is omitted. Named profiles are reported as ``profile``; ``main`` keys keep their historical
shape exactly (no ``profile`` key) so equality assertions on parsed dicts stay stable.
"""
parts = session_key.split(":")
if (
len(parts) >= 5
and parts[0] == "agent"
and (parts[1] in ("main", "main~") or _PROFILE_ID_KEY_RE.match(parts[1]))
):
result = {"platform": parts[2], "chat_type": parts[3], "chat_id": parts[4]}
if parts[1] != "main":
result["profile"] = profile_from_session_key_namespace(parts[1])
if len(parts) > 5 and parts[3] in {"dm", "thread"}:
result["thread_id"] = parts[5]
return result
return None
def _shorten_command_for_display(command: str, limit: int = 80) -> str:
"""Collapse a shell command onto one line and cap its length for display."""
one_line = " ".join((command or "").split())
if len(one_line) > limit:
one_line = one_line[: limit - 1] + "…"
return one_line
def _format_concise_process_notification(
session_id: str, command: str, exit_code, output: str, duration_seconds=None) -> str:
"""One-line completion message for ``concise`` display mode; failure appends a short output tail."""
ok = exit_code in {0, None}
icon = "✅" if ok else "❌"
parts = [f"{icon} Background task {'finished' if ok else 'failed'}"]
short_cmd = _shorten_command_for_display(command)
if short_cmd:
parts.append(f"— `{short_cmd}`")
details = []
if isinstance(duration_seconds, (int, float)) and duration_seconds >= 0:
secs = int(duration_seconds)
if secs >= 3600:
details.append(f"{secs // 3600}h {(secs % 3600) // 60}m")
elif secs >= 60:
details.append(f"{secs // 60}m {secs % 60}s")
else:
details.append(f"{secs}s")
if not ok:
details.append(f"exit {exit_code}")
if details:
parts.append(f"({', '.join(details)})")
text = " ".join(parts)
if not ok and output:
tail_lines = [ln for ln in output.strip().splitlines() if ln.strip()][-5:]
tail = "\n".join(tail_lines)
if len(tail) > 500:
tail = tail[-500:]
if tail:
text += f". Last output:\n```\n{tail}\n```"
if not ok:
text += "\nAsk me to rerun it or show the full log."
return text
def _format_gateway_process_notification(evt: dict) -> "str | None":
"""Format a watch pattern event from completion_queue into a [IMPORTANT:] message."""
evt_type = evt.get("type", "completion")
_sid = evt.get("session_id", "unknown")
_cmd = evt.get("command", "unknown")
# watch_disabled / overflow events carry their summary in `message` (process_registry formatter).
if evt_type in ("watch_disabled", "watch_overflow_tripped", "watch_overflow_released"):
return f"[IMPORTANT: {evt.get('message', '')}]"
if evt_type == "watch_match":
_pat = evt.get("pattern", "?")
_out = evt.get("output", "")
_sup = evt.get("suppressed", 0)
text = (
f"[IMPORTANT: Background process {_sid} matched "
f"watch pattern \"{_pat}\".\n"
f"Command: {_cmd}\nMatched output:\n{_out}")
if _sup:
text += f"\n({_sup} earlier matches were suppressed by rate limit)"
text += "]"
return text
if evt_type == "async_delegation":
from tools.process_registry_notifications import format_process_notification
return format_process_notification(evt)
return None
def _drain_gateway_watch_events(completion_queue) -> "list[dict]":
"""Drain gateway-owned watch events without spinning on requeued events.
Foreign events requeued inside ``while not queue.empty()`` never terminate: detach, then requeue."""
watch_events: list[dict] = []
requeue: list[dict] = []
while not completion_queue.empty():
try:
evt = completion_queue.get_nowait()
except Exception:
break
evt_type = evt.get("type", "completion")
if evt_type in {
"watch_match", "watch_disabled", "watch_overflow_tripped", "watch_overflow_released"}:
watch_events.append(evt)
elif evt_type == "async_delegation":
requeue.append(evt)
# else: process completion events are handled by the watcher task
for evt in requeue:
completion_queue.put(evt)
return watch_events
# Weak ref to the active GatewayRunner; tools like send_message route through its live adapters.
import weakref as _weakref
_gateway_runner_ref: _weakref.ref = lambda: None
def _normalize_empty_agent_response(
agent_result: dict, response: str, *, history_len: int = 0) -> str:
"""Normalize empty/None agent responses into user-facing messages.
Covers ``failed``, work done (api_calls > 0) with no text, and never-ran (api_calls == 0, the
post-/stop silent-drop from a stale generation token) with a retry hint.
Consolidates the existing ``failed`` handler and adds a catch-all for the case where the agent did work
(api_calls > 0) but returned no text. Fix for #18765.
Also surfaces a retry hint when the agent never ran at all (api_calls == 0) for a non-interrupted,
non-failed turn -- this is the silent-drop pattern observed after ``/stop`` where the next user message
hits a stale generation token and returns an empty result, leaving the platform with nothing to send.
(#31884)
A failed context-overflow turn whose ``final_response`` is only the raw provider envelope
(``HTTP 400: {...}``) is rewritten too: returned unchanged, chat sanitizers turn it into a
generic provider-failed reply and the user never sees /compact. Curated agent text survives.
"""
is_overflow = is_context_overflow_failure_result(agent_result, history_len)
if response and not (is_overflow and _looks_like_gateway_provider_error(response)):
return response
if agent_result.get("failed"):
# ``error`` can be an EXPLICIT None (bypasses dict.get default) -> would render "failed: None".
error_detail = agent_result.get("error") or "unknown error"
error_str = str(error_detail).lower()
# Persistence failures: suggesting /reset would destroy context without fixing storage.
failure_reason = str(agent_result.get("failure_reason") or "")
if failure_reason.startswith("session_persistence_failed") or "session storage" in error_str:
if failure_reason.endswith(":disk") or "disk" in error_str:
return (
"⚠️ Session storage was temporarily unavailable, so this "
"turn was stopped to protect your conversation history. "
"Please check available disk space, then send your message again.")
return (
"⚠️ Session storage was temporarily unavailable, so this "
"turn was stopped to protect your conversation history. "
"Your message should already be saved — please send it again in a moment.")
if is_overflow:
return _CONTEXT_OVERFLOW_REPLY
# Raw exception text (class names, JSON bodies, URLs) stays in the gateway log.
logger.warning("Agent turn failed; reply sanitized for chat. Detail: %s", str(error_detail)[:500])
return (
"⚠️ Something went wrong and I couldn't finish this reply. Use /retry to try again, "
"or /new to start a fresh conversation. Technical details are in the gateway log "
"(`hermes logs`).")
api_calls = int(agent_result.get("api_calls", 0) or 0)
if agent_result.get("interrupted"):
# Interrupted with api_calls > 0 = deliberately stopped/steered; silence is intentional (queued
# messages arrive via the recursive drain). ZERO api_calls = never processed (stale /stop flag).
# An interrupted run that did work (api_calls > 0) is the drain of a run the user deliberately
# stopped or steered — its silence is intentional, and any queued/interrupting message is delivered
# by the recursive drain inside _run_agent before this result is seen. An interrupted run with ZERO
# api_calls never processed the user's message at all: it was killed at the top of the tool loop by
# an interrupt flag left over from a recent /stop (#44212). Pure silence there swallows a real user
# message, so surface it.
# api_calls == 0, not failed, not interrupted: the agent never ran for this turn. This is the
# post-/stop generation-race pattern where the gateway would otherwise silently drop the turn
# (response=0 chars) and the user sees no reply at all. Surface a short retry hint so the message
# isn't lost in silence. (#31884)
if api_calls == 0:
return (
"⚠️ Your message was interrupted before processing started "
"(likely by a recent /stop). Please send it again.")
return response
if api_calls > 0:
# Hidden-reasoning-only retry exhaustion: the loop's sentinel text ("Codex response remained
# incomplete after 3 continuation attempts") doubles as final_response, so it would be delivered
# verbatim into the channel — where peer agents can ingest it as a completed assistant turn
# (#51628). Blank it here so the normal empty-response handling (and the suppression below) applies.
if _is_gateway_hidden_reasoning_incomplete_turn(agent_result):
return ""
if agent_result.get("partial"):
# ``error`` mirrors the loop's own final text (curated, e.g. "Response truncated due to
# output length limit") and is kept; a raw provider envelope goes to the log instead.
err = str(agent_result.get("error") or "processing incomplete")
# A loop site code (truncated, context_overflow, ...) already wrote the full
# what-happened / what-to-do sentence: deliver it verbatim. Wrapping it would cut it
# mid-sentence at 200 chars and append a second, conflicting set of instructions.
from agent.turn_failure_copy import SITE_FAILURE_CODES
if (str(agent_result.get("failure_reason") or "") in SITE_FAILURE_CODES
and err.strip() and not _looks_like_gateway_provider_error(err)):
return err if err.startswith("⚠️") else f"⚠️ {err}"
if _looks_like_gateway_provider_error(err):
logger.warning("Agent turn ended partially; reply sanitized for chat. Detail: %s", err[:500])
reason = ""
else:
reason = f": {err[:200]}"
return (
f"⚠️ I had to stop before finishing{reason}. Use /retry to try again, or /compress "
"if this conversation has grown very long.")
return (
"⚠️ Processing completed but no response was generated. "
"This may be a transient error — try sending your message again.")
# api_calls == 0, not failed/interrupted: agent never ran (post-/stop race); don't drop silently.
if api_calls == 0 and not agent_result.get("partial"):
return (
"⚠️ Your message wasn't processed (the previous turn was still "
"being cleaned up). Please send it again.")
return response
def _is_gateway_hidden_reasoning_incomplete_turn(agent_result: dict) -> bool:
"""Detect retry-exhausted turns with hidden reasoning but no visible answer.
The loop returns the retry-exhaustion sentinel as BOTH ``final_response`` and ``error``, so a
non-empty ``final_response`` proves nothing; any text other than the sentinel is a real answer."""
if (not isinstance(agent_result, dict) or agent_result.get("failed")
or agent_result.get("interrupted") or not agent_result.get("partial")):
return False
error_text = str(agent_result.get("error", "") or "").strip()
if "remained incomplete after" not in error_text.lower():
return False
final_response = str(agent_result.get("final_response") or "").strip()
return not final_response or final_response == error_text
def _should_clear_resume_pending_after_turn(agent_result: dict) -> bool:
"""True only when a gateway turn really completed successfully.
``resume_pending`` is a durable restart-recovery marker; a soft interrupt can look like a normal
empty result, and clearing then loses the signal."""
if not isinstance(agent_result, dict) or agent_result.get("interrupted"):
return False
if agent_result.get("failed") or agent_result.get("partial") or agent_result.get("error"):
return False
return agent_result.get("completed") is not False
def _preserve_queued_followup_history_offset(
current_result: dict, followup_result: dict) -> dict:
"""Carry the outer history offset through queued follow-up drains.
Each recursive ``_run_agent()`` advances ``history_offset``; uncorrected, the outer persistence
step sees only the *last* queued turn as "new" and drops earlier ones."""
if not isinstance(followup_result, dict) or not isinstance(current_result, dict):
return followup_result
current_offset = current_result.get("history_offset")
followup_offset = followup_result.get("history_offset")
if not isinstance(current_offset, int):
return followup_result
if isinstance(followup_offset, int) and followup_offset <= current_offset:
return followup_result
return {**followup_result, "history_offset": current_offset}
async def _dispose_unused_adapter(adapter: "BasePlatformAdapter | None") -> None:
"""Best-effort dispose for an adapter that never made it onto ``self.adapters`` (may be ``None``).
Nothing else calls ``disconnect()`` on it, so ``__init__`` resources (e.g. SQLite fds) would leak
until GC (not prompt for asyncio-bound objects) and exhaust the fd ulimit over a long retry loop.
The reconnect watcher in ``GatewayRunner._platform_reconnect_watcher`` constructs a fresh adapter on
every retry attempt. When the connect call fails — for any of the three reasons (non-retryable error,
retryable error, exception during connect) — the adapter is dropped without ever being installed, so
nothing else will call its ``disconnect()``. ``APIServerAdapter`` opens a SQLite ``ResponseStore`` that
holds 2 fds — the db file and its WAL sidecar) stay open until garbage collection sweeps the unreachable
object, which Python's cyclic GC does not do promptly for asyncio-bound objects with native handles. The
cumulative leak is 2 fds × every retry at the 300s backoff cap ≈ 12 fds/hour, and the default 2560-fd
ulimit is exhausted in ~12h of continuous failure, after which every open() call on the gateway raises
``OSError: [Errno 24] Too many open files`` and the gateway becomes a zombie (#37011).
"""
if adapter is None:
return
try:
await adapter.disconnect()
except Exception:
# Half-constructed adapters may raise; must not abort the watcher (CancelledError propagates).
logger.debug(
"Adapter dispose raised on unowned adapter %r",
getattr(adapter, "name", type(adapter).__name__), exc_info=True)
# Max seconds between platform reconnect retries (primary watcher and secondary profiles share it).
_RECONNECT_BACKOFF_CAP = 300
def _reconnect_backoff(attempt: int) -> int:
"""Exponential reconnect backoff: 30s, 60s, 120s, ... capped at 5 min."""
return min(30 * (2 ** (attempt - 1)), _RECONNECT_BACKOFF_CAP)
def _reconnect_attention_after_secs() -> float:
"""``agent.reconnect_attention_after`` of the profile whose scope is bound at call time (the launch
profile's when unbound). Seconds continuously in the reconnect queue before NEEDS_ATTENTION; retrying
never stops (transient outages must self-heal), this only makes a permanently-failing loop loud.
Non-positive disables. Read per call, never cached: one process serves many profiles and a config
edit must not need a gateway restart (#115635)."""
from hermes_cli.config import load_config_readonly
agent_cfg = load_config_readonly().get("agent")
raw = agent_cfg.get("reconnect_attention_after") if isinstance(agent_cfg, dict) else None
try:
return float(raw)
except (TypeError, ValueError):
return float(_DEFAULT_CONFIG["agent"]["reconnect_attention_after"])
def _reconnect_needs_attention(info: dict, now: float) -> bool:
"""True when a reconnect-queue entry has waited long enough for NEEDS_ATTENTION.
``queued_at`` is re-stamped on each (re)entry, so only *continuous* failure escalates."""
threshold = _reconnect_attention_after_secs()
if threshold <= 0:
return False # escalation disabled
queued_at = info.get("queued_at")
if queued_at is None:
info["queued_at"] = now
return False
return (now - queued_at) >= threshold
# "No session DB pinned": lets ``_session_db`` distinguish "resolve from profile scope" from a
# deliberate ``runner._session_db = None`` (disables DB commands). Mirrors gateway.session._DB_UNPINNED.
_SESSION_DB_UNPINNED = object()
# Only explicit suspension can replace a routed conversation.
_AUTO_RESET_CONTEXT_NOTES = {
"suspended": "[System note: The user's previous session was stopped and suspended. This is a fresh conversation with no prior context.]",
}
def _write_runtime_status_quiet(**fields: Any) -> None:
"""Best-effort status publication; persistence must never abort or block the caller."""
try:
from gateway.status import publish_runtime_status
publish_runtime_status(**fields)
except Exception:
pass
def _command_origin_for_source(source: Any) -> Optional[dict]:
"""Delivery origin for a shared CLI/gateway command so its job replies to this chat/thread."""
try:
platform = getattr(source.platform, "value", None) or str(getattr(source, "platform", "") or "")
chat_id = getattr(source, "chat_id", None)
if platform and chat_id:
return {
"platform": platform,
"chat_id": str(chat_id),
"chat_name": getattr(source, "chat_name", None),
"thread_id": getattr(source, "thread_id", None)}
except Exception:
pass
return None
def _builtin_adapter_import(module: str, adapter_name: str, requirement: str):
"""Lazy-import ``(adapter_cls, requirements_ok)`` from ``gateway.platforms.<module>``."""
import importlib
mod = importlib.import_module(f"gateway.platforms.{module}")
return getattr(mod, adapter_name), getattr(mod, requirement)
# platform -> (module, adapter class, requirements probe, warning on probe failure).
_BUILTIN_ADAPTERS: dict[Platform, tuple[str, str, str, str]] = {
Platform.WHATSAPP_CLOUD: ("whatsapp_cloud", "WhatsAppCloudAdapter", "check_whatsapp_cloud_requirements",
"WhatsApp Cloud: aiohttp/httpx missing — reinstall hermes-agent"),
Platform.SIGNAL: ("signal", "SignalAdapter", "check_signal_requirements",
"Signal: runtime requirements not met"),
Platform.WEIXIN: ("weixin", "WeixinAdapter", "check_weixin_requirements",
"Weixin: aiohttp/cryptography not installed"),
Platform.API_SERVER: ("api_server", "APIServerAdapter", "check_api_server_requirements",
"API Server: aiohttp not installed"),
Platform.WEBHOOK: ("webhook", "WebhookAdapter", "check_webhook_requirements",
"Webhook: aiohttp not installed"),
Platform.MSGRAPH_WEBHOOK: ("msgraph_webhook", "MSGraphWebhookAdapter", "check_msgraph_webhook_requirements",
"MSGraph webhook: aiohttp not installed"),
Platform.BLUEBUBBLES: ("bluebubbles", "BlueBubblesAdapter", "check_bluebubbles_requirements",
"BlueBubbles: aiohttp/httpx missing or BLUEBUBBLES_SERVER_URL/BLUEBUBBLES_PASSWORD not configured"),
Platform.QQBOT: ("qqbot", "QQAdapter", "check_qq_requirements",
"QQBot: aiohttp/httpx missing or QQ_APP_ID/QQ_CLIENT_SECRET not configured"),
Platform.YUANBAO: ("yuanbao", "YuanbaoAdapter", "WEBSOCKETS_AVAILABLE",
"Yuanbao: websockets not installed. Run: pip install websockets")}
def _instantiate_builtin_adapter(platform: Platform, config: Any) -> Optional[BasePlatformAdapter]:
"""Instantiate a core (non-plugin) adapter, or None when its requirements are unmet/unknown."""
spec = _BUILTIN_ADAPTERS.get(platform)
if spec is None:
return None
module, adapter_name, requirement, warning = spec
adapter_cls, requirements_ok = _builtin_adapter_import(module, adapter_name, requirement)
if not (requirements_ok() if callable(requirements_ok) else requirements_ok):
logger.warning(warning)
return None
if platform == Platform.SIGNAL:
from gateway.platforms.signal import validate_signal_config
if not validate_signal_config(config):
logger.warning("Signal: SIGNAL_HTTP_URL or SIGNAL_ACCOUNT not configured")
return None
return adapter_cls(config)
class GatewayRunner(
GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, GatewaySlashCommandsMixin,
GatewayVoiceMixin, GatewayAdapterLifecycleMixin, GatewayTopicThreadsMixin, GatewayTurnMixin,
GatewayShutdownMixin, GatewayBusySessionMixin, GatewayConfigLoadersMixin, GatewayStartupMixin,
GatewaySessionWatchersMixin, GatewayNotificationsMixin, GatewayInboundMixin, GatewayGoalsMixin,
GatewayAgentCacheMixin, GatewayProfileReconcileMixin):
"""Main gateway controller: manages adapter lifecycles, routes messages to/from the agent."""
# Class-level defaults so partial construction in tests doesn't blow up on attribute access.
_busy_input_mode: str = "interrupt"
_busy_text_mode: str = "interrupt"
_restart_drain_timeout: float = DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT
_restart_after_turn_timeout: float = DEFAULT_GATEWAY_RESTART_AFTER_TURN_TIMEOUT
_cron_drain_timeout: float = DEFAULT_GATEWAY_CRON_DRAIN_TIMEOUT
_signal_interrupt_grace_timeout: float = DEFAULT_GATEWAY_SIGNAL_INTERRUPT_GRACE_TIMEOUT
_exit_code: Optional[int] = None
_draining: bool = False
_external_drain_active: bool = False
_restart_requested: bool = False
_restart_task_started: bool = False
_restart_detached: bool = False
_restart_via_service: bool = False
_detached_restart_helper_started: bool = False
_restart_command_source: Optional[SessionSource] = None
_stop_task: Optional[asyncio.Task] = None
_restart_task: Optional[asyncio.Task] = None
_profile_failed_platforms: Optional[Dict[str, Dict[Platform, asyncio.Task]]] = None
_systemd_watchdog: Optional[Any] = None
_startup_restore_in_progress: bool = False
_startup_warmup_task: Optional[asyncio.Task] = None
# Legacy per-session dict attrs as LIVE views over ``self._sessions``; new code: _session_state(key)
_running_agents = legacy_dict_property("_running_agents")
_running_agents_ts = legacy_dict_property("_running_agents_ts")
_active_session_leases = legacy_dict_property("_active_session_leases")
_busy_ack_ts = legacy_dict_property("_busy_ack_ts")
_turn_lease_tokens = legacy_lease_token_property()
_session_run_generation = legacy_dict_property("_session_run_generation")
_session_model_overrides = legacy_dict_property("_session_model_overrides")
_pending_one_turn_model_restores = legacy_dict_property("_pending_one_turn_model_restores")
_session_reasoning_overrides = legacy_dict_property("_session_reasoning_overrides")
_session_service_tier_overrides = legacy_dict_property("_session_service_tier_overrides")
_last_resolved_model = legacy_dict_property("_last_resolved_model")
_queued_events = legacy_dict_property("_queued_events")
_pending_turn_sidecar_notes = legacy_dict_property("_pending_turn_sidecar_notes")
_pending_messages = legacy_dict_property("_pending_messages")
_pending_native_image_paths_by_session = legacy_dict_property(
"_pending_native_image_paths_by_session")
_session_ephemeral_pin = legacy_dict_property("_session_ephemeral_pin")
_session_vc_last = legacy_dict_property("_session_vc_last")
_pending_approvals = legacy_dict_property("_pending_approvals")
_update_prompt_pending = legacy_dict_property("_update_prompt_pending")
def _sessions_map(self) -> Dict[str, "SessionState"]:
"""Per-session state map; lazily created so bare ``object.__new__`` test runners work."""
sessions = self.__dict__.get("_sessions")
if sessions is None:
sessions = {}
self.__dict__["_sessions"] = sessions
return sessions
def _session_state(self, session_key: str) -> "SessionState":
"""Get-or-create the :class:`SessionState` for ``session_key``."""
sessions = self._sessions_map()
state = sessions.get(session_key)
if state is None:
state = SessionState()
sessions[session_key] = state
return state
def _peek_session_state(self, session_key: str) -> Optional["SessionState"]:
"""Return the SessionState for ``session_key`` without creating one."""
sessions = self.__dict__.get("_sessions")
return sessions.get(session_key) if sessions else None
def _is_session_running(self, session_key: str) -> bool:
"""True when the session holds a running-turn slot (agent or sentinel)."""
state = self._peek_session_state(session_key)
return state is not None and state.turn.agent is not None
def _running_agent_items(self) -> List[tuple]:
"""(session_key, agent) pairs for sessions with a running turn (incl. pending sentinels)."""
return [(key, state.turn.agent) for key, state in self._sessions_map().items()
if state.turn.agent is not None]
# Loop-liveness / watchdog handles; class-level defaults so partially constructed test runners work.
# Class-level defaults so partial construction in tests doesn't blow up on access; the real values are
# set in __init__ / start() / stop(). See #66892, #69089.
_loop_heartbeat_task: Optional["asyncio.Task"] = None
_loop_floor_timer_handle: Optional[Any] = None
_loop_liveness_watchdog: Optional[Any] = None
_gateway_started_at: float = 0.0
_shutdown_watchdog_done: Optional["threading.Event"] = None
_platform_lock_takeover_on_start: bool = False
_reconnect_watcher_task: Optional["asyncio.Task"] = None
def __init__(self, config: Optional[GatewayConfig] = None):
global _gateway_runner_ref
# With multiplex_profiles on, load under the default profile secret scope so bot tokens in its
# .env resolve as secondary profiles' do; explicit config= injection (tests) is left untouched.
# See #64674.
# An injected config (tests, ``gateway run --config``) is taken verbatim: an unset flag there
# stays None (= standalone); only the loaded path runs the boot-time default-on guard.
self.config = config if config is not None else load_gateway_config_for_runner()
# Multiplexer flag flips agent.secret_scope.get_secret() to fail-closed on unscoped credential
# reads, so a missed migration crashes loudly instead of leaking a cross-profile value.
try:
from agent.secret_scope import set_multiplex_active
set_multiplex_active(bool(getattr(self.config, "multiplex_profiles", False)))
except Exception:
logger.debug("could not set multiplex-active flag", exc_info=True)
self.adapters: Dict[Platform, BasePlatformAdapter] = {}
# Non-None means SessionDB init failed — the gateway broadcasts a one-time warning to the home
# channel(s) after connecting so the user learns persistence is broken before /resume fails.
# See #88235.
self._session_db_init_error: Optional[str] = None
# Non-default profiles' adapters by profile then Platform; self.adapters stays the default's map.
self._profile_adapters: Dict[str, Dict[Platform, BasePlatformAdapter]] = {}
self._warn_if_docker_media_delivery_is_risky()
_gateway_runner_ref = _weakref.ref(self)
self._init_runtime_settings()
self._init_session_store()
self._init_lifecycle_state()
self._init_runtime_caches()
self._init_startup_checks()
self._init_session_db()
self._init_registries_and_clocks()
def _init_runtime_settings(self) -> None:
"""Load ephemeral per-call config (prefill, reasoning, busy modes, timeouts, routing)."""
self._prefill_messages = self._load_prefill_messages()
self._reasoning_config = self._load_reasoning_config()
self._service_tier = self._load_service_tier()
self._show_reasoning = self._load_show_reasoning()
self._busy_input_mode = self._load_busy_input_mode()
self._busy_text_mode = self._load_busy_text_mode()
# Secondary-profile busy modes snapshotted at multiplex startup; handlers never reread config.
self._busy_input_modes_by_profile: Dict[str, str] = {}
self._busy_text_modes_by_profile: Dict[str, str] = {}
self._busy_text_timing = self._busy_text_timing_from_config(_load_gateway_config())
self._busy_text_timing_by_profile: Dict[str, tuple[float, float]] = {}
self._human_delay = self._human_delay_from_config(_load_gateway_config())
self._human_delay_by_profile: Dict[str, Optional[tuple[int, int]]] = {}
self._restart_drain_timeout = self._load_restart_drain_timeout()
# Live launchd ``ExitTimeOut`` for this job (None when not launchd-owned). Read once at
# boot — launchd fixes it at load — and applied only to signal-driven stops, which are the
# only stops launchd times. See _load_launchd_exit_timeout().
self._stop_requested_by_signal = False
self._launchd_exit_timeout_s = self._load_launchd_exit_timeout(self._restart_drain_timeout)
self._restart_after_turn_timeout = self._load_restart_after_turn_timeout()
self._cron_drain_timeout = self._load_cron_drain_timeout()
self._signal_interrupt_grace_timeout = self._load_signal_interrupt_grace_timeout()
self._provider_routing = self._load_provider_routing()
self._fallback_model = self._load_fallback_model()
def _init_session_store(self) -> None:
"""Build the SessionStore (with process-registry reset guard), its async facade and the router."""
from tools.process_registry import process_registry
self.session_store = SessionStore(
self.config.sessions_dir, self.config,
has_active_processes_fn=lambda key: process_registry.has_active_for_session(
key))
# Loop-side boundary: sync helpers use ``session_store`` directly; async handlers await this facade.
self._async_session_store = AsyncSessionStore(self.session_store)
self.delivery_router = DeliveryRouter(self.config)
def _init_lifecycle_state(self) -> None:
"""Initialise run/exit/restart flags, per-session state, and completion-delivery bookkeeping."""
self._running = self._exit_cleanly = self._exit_with_failure = self._draining = False
self._gateway_loop: Optional[asyncio.AbstractEventLoop] = None
self._shutdown_event = asyncio.Event()
self._exit_reason: Optional[str] = None
self._exit_code: Optional[int] = None
self._profile_failed_platforms: Dict[str, Dict[Platform, asyncio.Task]] = {}
self._systemd_watchdog = None
# External (NAS-driven) drain, distinct from one-way ``_draining``: set while ``.drain_request.json``
# exists — NEW turns refused, process stays up, removing the marker reverts to ``running``.
self._external_drain_active = False
# ``_signal_initiated_shutdown``: SIGTERM/SIGINT with no planned-stop/takeover marker (container,
# OOM, bare kill); _stop_impl must NOT persist gateway_state=stopped or container_boot won't restart.
self._restart_requested = self._signal_initiated_shutdown = self._restart_task_started = False
self._restart_detached = self._restart_via_service = self._detached_restart_helper_started = False
self._restart_command_source: Optional[SessionSource] = None
# Construction clock: bounds the /restart redelivery guard's window (missing dedup marker = stale).
self._startup_time: float = time.time()
# True when booted from a chat /restart (.restart_notify.json existed). One-shot signal so the
# marker-missing fallback suppresses a /restart only when we KNOW we just restarted.
self._booted_from_restart: bool = False
self._stop_task: Optional[asyncio.Task] = None
self._restart_task: Optional[asyncio.Task] = None
self._executor_lock = threading.Lock()
self._executor: Optional[concurrent.futures.ThreadPoolExecutor] = None
# Set on gateway stop so the recreate-on-shutdown path can't resurrect the pool.
self._executor_closing = False
# ALL per-session state lives here (gateway/session_state.py); use _session_state / _peek_session_state.
self._sessions: Dict[str, SessionState] = {}
# Per-SESSION_ID turn lease: serializes [load history → run → flush] when two ROUTING KEYS resolve
# to one session_id (switch_session's many-to-one mapping), which routing-key guards cannot see.
self._turn_leases = SessionTurnLeaseRegistry()
# Stall-notified keys clear when pending clears / activity resumes / conversation boundary.
# Held turn-lease tokens live on SessionState.turn.lease_tokens keyed by run generation, so a
# stale unwind can never free a newer turn's lease (#28686). Runner-level queued interrupt text lives on
# SessionState.persistent.pending_command_text (NOTE: distinct from the adapter-level
# _pending_messages Dict[str, MessageEvent] in gateway/platforms/base.py, which shares the legacy
# name). Last successfully-resolved (non-empty) model, keyed by session. Used as a fallback when a
# fresh config read transiently returns an empty model (e.g. an mtime-keyed config-cache miss during
# a post-interrupt recovery turn). Without this, the agent is built with model="" and every API call
# fails HTTP 400 "No models provided" — the session goes silent until the user manually re-sends.
# See #35314. The ``"*"`` session entry holds a process-wide last-known-good for sessions seen for
# the first time. Lives on SessionState.conversation.last_resolved_model. Overflow buffer for
# explicit /queue commands. The adapter-level _pending_messages dict is a single slot per session
# (designed for "next-turn" follow-ups where repeated sends collapse into one event). /queue has
# different semantics: each invocation must produce its own full agent turn, in FIFO order, with no
# merging. When the slot is occupied, additional /queue items land here and are promoted
# one-at-a-time after each run's drain. Cleared on /new and /reset. /model and other mid-session
# operations preserve the queue. Lives on SessionState.conversation.queued_events; native image
# paths, busy-ack debounce timestamps and the monotonic run-generation counter (#28686, NEVER reset)
# live on SessionState too. See gateway.session_stall.
self._session_stall_notified: Dict[str, bool] = {}
# Consecutive "persisted transcript lagged live cached history" turns per session key; see
# run_turn_runner._load_turn_history (#114266). Cleared on /new.
self._transcript_lag_streaks: Dict[str, int] = {}
# Startup restore gate: while restart-interrupted sessions auto-resume, real inbound messages
# queue instead of competing with the synthetic resume turns; drained after all resume tasks end.
self._startup_restore_in_progress = False
self._startup_restore_queue: List[MessageEvent] = []
self._startup_restore_tasks: List[asyncio.Task] = []
# Set by start_gateway() only for an explicit ``--replace`` launch; scoped to each adapter's
# cold-start connect and removed before any reconnect can run.
self._platform_lock_takeover_on_start = False
# Capped LRU of live SessionSources for fallback routing (shutdown notices, synthetic events) when
# the persisted origin is missing and _parse_session_key can't recover thread_id.
self._session_sources: "OrderedDict[str, SessionSource]" = OrderedDict()
self._session_sources_max = 512
# Lifecycle-scoped completion dedup: closes queue/watcher races inside one gateway without claiming
# exactly-once across a crash; durable replay state stays owned by tools.async_delegation.
self._completion_delivery_lock = threading.Lock()
self._completion_deliveries_inflight: set[tuple[str, str, object]] = set()
self._completion_deliveries_delivered: "OrderedDict[tuple[str, str, object], None]" = OrderedDict()
self._completion_delivery_retention = 2048
# Agent-triggered terminal completions from one conversation often land in the same scheduler
# tick; hold them briefly so the agent gets one synthetic turn instead of one per process.
# See #70300.
self._completion_notification_batches: dict[tuple[str, ...], list[tuple[str, dict, asyncio.Future]]] = {}
self._completion_notification_batch_tasks: dict[tuple[str, ...], asyncio.Task] = {}
self._completion_notification_batch_flush_tasks: set[asyncio.Task] = set()
self._completion_notification_batch_window = 0.1
self._completion_notification_batches_stopping = False
def _init_runtime_caches(self) -> None:
"""Agent cache, profile identity, Teams runtime, failed-platform tracking, slash-confirm counter."""
# AIAgent per session preserves prompt caching (fresh agent per message ~10x cost on Anthropic).
# Value: (AIAgent, config_signature); LRU cap in _enforce_agent_cache_cap, TTL in expiry watcher.
self._agent_cache: "OrderedDict[str, tuple]" = OrderedDict()
self._agent_cache_lock = threading.Lock()
# Launch-time identity of the profile that owns ``self.adapters``; ``_authorization_adapter``
# compares against this rather than the per-turn ``_active_profile_name()``.
self._primary_profile_name = self._kanban_notifier_profile = self._active_profile_name()
# Teams meeting pipeline runtime (bound later when msgraph_webhook adapter exists).
self._teams_pipeline_runtime = None
self._teams_pipeline_runtime_error: Optional[str] = None
# Failed-to-connect platforms for background reconnection: Platform -> {config, attempts, next_retry}
self._failed_platforms: Dict[Platform, Dict[str, Any]] = {}
# Strong refs to detached fatal-error handler tasks so the loop can't GC them mid-run.
self._fatal_handler_tasks: set = set()
# Slash-confirm state lives in tools.slash_confirm (module-level) so adapters resolve callbacks
# without a runner backref; local counter keeps confirm_ids compact (64-byte callback_data caps).
import itertools
self._slash_confirm_counter = itertools.count(1)
def _init_startup_checks(self) -> None:
"""Ensure tirith is installed and warn when manual approvals have no automated assessor."""
def _ensure_tirith() -> None:
from tools.tirith_security import ensure_installed
ensure_installed(log_failures=False) # downloads if needed; fail-open at scan time
_best_effort(_ensure_tirith)
# Manual approvals with no automated assessor (tirith off AND no auxiliary.approval) fail closed
# on unattended gateways — surface it so operators knowingly enable one.
try:
from hermes_cli.config import load_config as _load_full_config
# Startup heads-up (#30882): a gateway in manual approval mode with no automated risk assessor
# (tirith disabled AND no auxiliary.approval model) can only gate dangerous commands /
# execute_code scripts via live in-chat approval.
_appr_cfg = _load_full_config()
_appr_mode = str(
cfg_get(_appr_cfg, "approvals", "mode", default="manual") or "manual"
).strip().lower()
_tirith_on = bool(cfg_get(_appr_cfg, "security", "tirith_enabled", default=True))
_aux_approval = cfg_get(_appr_cfg, "auxiliary", "approval", default=None)
if _appr_mode == "manual" and not _tirith_on and not _aux_approval:
logger.warning(
"Gateway approvals.mode=manual with no automated risk "
"assessor (security.tirith_enabled is false and "
"auxiliary.approval is unset): dangerous commands and "
"execute_code scripts will BLOCK until a human approves "
"them in chat. Enable security.tirith_enabled or configure "
"auxiliary.approval for unattended operation.")
except Exception:
logger.debug("approvals.mode startup check skipped", exc_info=True)
def _init_session_db(self) -> None:
"""Open the session DB for the active scope and run opportunistic state.db / checkpoint maintenance."""
# Session DB is a property caching one AsyncSessionDB per path (a handle bound here would pin the
# root home under multiplex); priming here keeps startup diagnostics at init.
# Initialize session database for session_search tool support. Same frozen-handle class of bug as
# SessionStore._db (#88532): a handle bound here is pinned to the process's root home, but /resume,
# /title, /history and session search all run inside _profile_runtime_scope on a multiplexed gateway
# and must see that profile's own state.db.
self._session_db_pinned: Any = _SESSION_DB_UNPINNED
self._session_db_handles: Dict[Path, Any] = {}
self._session_db_handles_lock = threading.Lock()
from gateway.session_db_recovery import RecoverableHandleCache
self._session_db_handle_cache = RecoverableHandleCache(
handles=self._session_db_handles, lock=self._session_db_handles_lock)
try:
self._open_session_db_for_active_scope(raise_on_error=True)
except Exception as e:
# WARNING (not DEBUG) so it lands in errors.log; else an NFS HERMES_HOME silently loses /resume etc.
logger.warning("SQLite session store not available: %s", e)
self._session_db_init_error = str(e) # surfaced on the home channel(s) once connected
# Opportunistic state.db maintenance (prune + optional VACUUM), at most once per min_interval_hours.
# A few blocking seconds per day is fine for a long-lived gateway; failures log, never raise.
# Surface the failure to the user via their home channel(s) once the gateway connects. Without this,
# state.db corruption or NFS/SMB lock failures silently degrade the entire gateway — messages may
# flow but nothing is persisted, and the user has no indication until they try /resume and find
# nothing (#88235).
# Once per SERVED profile, each under its own scope: both the store and the ``sessions:``
# config that governs it must be the profile's own. Bound to ``self._session_db`` this ran
# against the construction-time launch home only, so a multiplexed secondary profile's
# state.db was never pruned or vacuumed by anybody, and the launch profile's
# retention_days/auto_prune decided whether it happened at all.
from gateway.run_profile_reconcile import _for_each_served_profile
_launch_sessions = _launch_sessions_dir(self.config) # resolved OUTSIDE any profile scope
_housekeeping_chore(
"state.db startup maintenance",
lambda: _for_each_served_profile(
self, lambda _label: _housekeeping_state_db_maintenance(_launch_sessions)))
# Checkpoint store pruning is a housekeeping chore (``_housekeeping_checkpoint_prune``), not a
# constructor step: its ``git gc`` repacks the whole store (tens of seconds on a GB store) and
# here it ran before the control socket, adapters and the code_sha stamp — so the first
# restart of the day (the ``hermes update`` one) looked hung and failed fleet verification.
def _init_registries_and_clocks(self) -> None:
"""Pairing stores, hook registry, voice modes, background-task set, liveness and idle clocks."""
# ``pairing_store``: global/default store (CLI, callers without profile context); ``pairing_stores``:
# per-profile map ``authz_mixin._is_user_authorized`` routes through (one whitelist per profile).
from gateway.pairing import PairingStore
from gateway.hooks import ProfileHookRegistries
self.pairing_store = PairingStore()
self.pairing_stores: Dict[str, "PairingStore"] = {}
# One HookRegistry per served profile home, resolved from the active scope at emit time.
self.hooks = ProfileHookRegistries()
# Per-chat voice reply mode: "off" | "voice_only" | "all"
self._voice_mode: Dict[str, str] = self._load_voice_modes()
# Per-(guild,user) transcript dedup: the voice/STT pipeline can emit one utterance twice.
self._recent_voice_transcripts: Dict[tuple[int, int], List[tuple[float, str]]] = {}
# Background tasks kept referenced so they are not garbage-collected mid-execution.
self._background_tasks: set = set()
# Event-loop liveness heartbeat: rewritten every 30s while the loop dispatches; supervisors use
# the file mtime / updated_at to tell "process alive" from "loop frozen".
# See #66892.
self._gateway_started_at: float = time.time()
self._loop_heartbeat_task: Optional[asyncio.Task] = None
self._loop_floor_timer_handle = self._loop_liveness_watchdog = None
# scale-to-zero: gateway-scoped "last inbound seen" clock, stamped in _handle_message (the single
# inbound chokepoint) and seeded to "now" so a fresh gateway isn't idle from epoch.
self._last_inbound_at: float = time.time()
# Re-arm cooldown after a wake so we don't go dormant again before the drained backlog updates
# the clock; and a one-shot latch so the "platform owns the suspend" notice logs once.
self._scale_to_zero_cooldown_until: float = 0.0
self._scale_to_zero_no_suspend_logged: bool = False
self._scale_to_zero_direct_platform_logged: bool = False
def _open_session_db_for_active_scope(self, raise_on_error: bool = False) -> Any:
"""AsyncSessionDB for the active profile scope, resolved per access (not in ``__init__``) since
``SessionDB()`` reads the context-local HERMES_HOME; one handle cached per path. Construction
failure enters bounded backoff; ``raise_on_error=True`` (priming) propagates it.
Same per-path cache as ``SessionStore._open_session_db_for_active_scope`` (#88532): ``SessionDB()``
resolves ``_default_db_path()`` at call time through the context-local HERMES_HOME override
installed by ``_profile_runtime_scope``, so resolving per access — instead of once in ``__init__`` —
is what lets /resume, /title, /history and session search on a multiplexed gateway read the *serving
profile's* store rather than the root one.
One ``AsyncSessionDB`` is cached per resolved path, so the wrapper identity is stable per profile
(callers compare and stash it) and two profiles never share a handle. A construction failure enters
bounded backoff; one caller retries after the deadline while concurrent callers continue to see the
unavailable fallback. ``raise_on_error=True`` (construction-time priming) propagates the failure
after recording that recoverable state so ``__init__`` can record ``_session_db_init_error`` for the
#88235 broadcast.
"""
from hermes_state import AsyncSessionDB, _default_db_path
from hermes_state_registry import acquire
from gateway.session_db_recovery import RecoverableHandleCache
path = Path(_default_db_path())
cache = getattr(self, "_session_db_handle_cache", None)
if cache is None:
# Test runners built with object.__new__ skip __init__.
cache = RecoverableHandleCache(
handles=self._session_db_handles, lock=self._session_db_handles_lock)
self._session_db_handle_cache = cache
def _open():
# Borrow the SessionStore's handle (same path) so state.db doesn't get two writers/pools.
# The store owns/sweeps it at shutdown; this cache holds only the async wrapper (close_all).
# Both caches resolve the SAME ``_default_db_path()``, so the process was holding two writer
# connections and two read pools against one state.db — the fd budget doubled for nothing, and
# doubled again per profile on a multiplexed gateway (#98573). A borrowed wrapper cannot go
# stale in practice: the store's cache only drops handles in close_all_db_handles() (shutdown),
# and while the store's own open is failing there is nothing to borrow, so nothing is cached
# here either.
store = getattr(self, "session_store", None)
borrowed = getattr(store, "_db", None) if store is not None else None
if borrowed is not None:
wrapper = AsyncSessionDB(borrowed)
# close_all_session_db_handles() must not close what the store owns (its sweep runs first).
wrapper.__dict__["_hermes_borrowed_handle"] = True
return wrapper
if store is not None:
# Store handle unavailable: opening our own would resurrect the duplicate borrowed away.
raise RuntimeError("SessionStore SQLite handle unavailable")
try:
return AsyncSessionDB(acquire())
except Exception as exc:
logger.warning("SQLite session store not available: %s", exc)
raise
def _recovered() -> None:
self._session_db_init_error = None
logger.info("SQLite session store recovered")
return cache.get(path, _open, raise_on_error=raise_on_error, on_recovered=_recovered)
@property
def _session_db(self) -> Any:
"""The AsyncSessionDB for the active profile scope, or a pinned override (assigning
``runner._session_db`` pins it for every later read — tests install fakes/None this way)."""
if self._session_db_pinned is not _SESSION_DB_UNPINNED:
return self._session_db_pinned
return self._open_session_db_for_active_scope()
@_session_db.setter
def _session_db(self, value) -> None:
self._session_db_pinned = value
def close_all_session_db_handles(self) -> None:
"""Close every per-profile AsyncSessionDB this runner opened.
Drained under the lock, closed outside it; a pinned handle is the pinner's to close. Wrappers
BORROWED from ``session_store`` are skipped: the store's sweep (runs first) closes them.
See #98573.
"""
def _close(db) -> None:
if getattr(db, "__dict__", {}).get("_hermes_borrowed_handle"):
return
inner = getattr(db, "_db", db)
if inner is None or not hasattr(inner, "close"):
return
# Shared instances no-op on close() (the registry owns the lifecycle). Release the refcount
# instead (#90837).
from hermes_state_registry import release_or_close
try:
release_or_close(inner)
except Exception as exc:
logger.debug("SessionDB close error during handle sweep: %s", exc)
self._session_db_handle_cache.close_all(_close)
def _wire_teams_pipeline_runtime(self) -> None:
"""Bind the Teams meeting pipeline runtime to Graph webhook ingress (no-op if adapter/plugin off)."""
if Platform.MSGRAPH_WEBHOOK not in self.adapters:
return
if not _teams_pipeline_plugin_enabled():
logger.debug("Teams pipeline plugin is disabled; skipping runtime wiring")
return
try:
from plugins.teams_pipeline.runtime import bind_gateway_runtime
except Exception as exc:
logger.warning("Teams pipeline runtime import failed: %s", exc)
return
try:
bound = bind_gateway_runtime(self)
except Exception as exc:
logger.warning("Teams pipeline runtime wiring failed: %s", exc)
return
if bound:
logger.info("Teams pipeline runtime bound to msgraph webhook ingress")
elif self._teams_pipeline_runtime_error:
logger.warning(
"Teams pipeline runtime unavailable: %s", self._teams_pipeline_runtime_error)
def _warn_if_docker_media_delivery_is_risky(self) -> None:
"""Warn when Docker-backed gateways lack an explicit export mount: MEDIA delivery runs in the
gateway process, so model-emitted paths like `/output/report.txt` must be host-readable."""
if os.getenv("TERMINAL_ENV", "").strip().lower() != "docker":
return
connected = self.config.get_connected_platforms()
messaging_platforms = [p for p in connected if p not in {Platform.LOCAL, Platform.API_SERVER, Platform.WEBHOOK}]
if not messaging_platforms:
return
raw_volumes = os.getenv("TERMINAL_DOCKER_VOLUMES", "").strip()
volumes: List[str] = []
if raw_volumes:
try:
parsed = json.loads(raw_volumes)
if isinstance(parsed, list):
volumes = [str(v) for v in parsed if isinstance(v, str)]
except Exception:
logger.debug("Could not parse TERMINAL_DOCKER_VOLUMES for gateway media warning", exc_info=True)
for spec in volumes:
match = _DOCKER_VOLUME_SPEC_RE.match(spec)
if match and match.group("container") in _DOCKER_MEDIA_OUTPUT_CONTAINER_PATHS:
return
logger.warning(
"Docker backend is enabled for the messaging gateway but no explicit host-visible "
"output mount (for example '/home/user/.hermes/cache/documents:/output') is configured. "
"This is fine if the model already emits host-visible paths, but MEDIA file delivery can fail "
"for container-local paths like '/workspace/...' or '/output/...'.")
_VOICE_MODE_PATH = _hermes_home / "gateway_voice_mode.json"
should_exit_cleanly = property(lambda self: self._exit_cleanly)
should_exit_with_failure = property(lambda self: self._exit_with_failure)
exit_reason = property(lambda self: self._exit_reason)
exit_code = property(lambda self: self._exit_code)
def _session_key_for_source(self, source: SessionSource) -> str:
"""Resolve the current session key for a source, honoring gateway config when available."""
if hasattr(self, "session_store") and self.session_store is not None:
try:
session_key = self.session_store._generate_session_key(source)
if isinstance(session_key, str) and session_key:
return session_key
except Exception:
pass
config = getattr(self, "config", None)
# Mirror SessionStore._resolve_profile_for_key so this fallback yields the primary path's
# namespace: None (legacy agent:main) unless multiplexing is on, then the pinned identity's
# runtime profile, the source stamp, or the active profile.
from gateway.session_identity import identity_of
identity = identity_of(source)
_profile = None
if identity is not None:
_profile = identity.session_key_profile
elif getattr(config, "multiplex_profiles", False):
if source.profile:
_profile = source.profile
else:
try:
from hermes_cli.profiles import get_active_profile_name
_profile = get_active_profile_name() or "default"
except Exception:
_profile = None
return build_session_key(
source, group_sessions_per_user=getattr(config, "group_sessions_per_user", True),
thread_sessions_per_user=getattr(config, "thread_sessions_per_user", False),
profile=_profile)
# Telegram General topic in forum-enabled private chats: clients omit message_thread_id or send "1"; both = root.
_TELEGRAM_GENERAL_TOPIC_IDS = frozenset({"", "1"})
_TELEGRAM_LOBBY_REMINDER_COOLDOWN_S = 30.0
def _normalize_source_for_session_key(self, source: SessionSource) -> SessionSource:
"""Apply Telegram DM topic recovery to a source for session-key purposes. Always derive override
storage keys from the result: ``_handle_message_with_agent`` rewrites ``thread_id`` before
deriving the session key, so keys from the raw ``event.source`` are never read next turn.
``_handle_message_with_agent`` rewrites ``source.thread_id`` via
``_recover_telegram_topic_thread_id`` *before* deriving the session key for a normal message turn (a
lobby/stripped reply gets pinned to the user's last-active topic). Session-scoped command handlers
like ``/model`` and ``/reasoning`` derive their override key from the raw inbound ``event.source``,
which skips that recovery — so the override is stored under a different key than the next message
turn reads, and the override is silently dropped on Telegram forum topics and after compression
session splits (#30479).
"""
try:
recovered = self._recover_telegram_topic_thread_id(source)
except Exception:
return source
return source if recovered is None else dataclasses.replace(source, thread_id=recovered)
def _resolve_session_key_or_none(self, source, session_key: Optional[str]) -> Optional[str]:
"""``session_key`` if given, else the key for ``source`` (None when it cannot be derived)."""
if session_key or source is None:
return session_key
try:
return self._session_key_for_source(source)
except Exception:
return None
def _running_agent_count(self) -> int:
return len(self._running_agents)
def _status_action_label(self) -> str:
return "restart" if self._restart_requested else "shutdown"
def _status_action_gerund(self) -> str:
return "restarting" if self._restart_requested else "shutting down"
def _update_runtime_status(self, gateway_state: Optional[str] = None, exit_reason: Optional[str] = None) -> None:
# ``active_work`` names each unit only while draining — that is when an observer (``hermes
# update``) needs to know WHAT holds the gateway open; a per-turn write would be wasted I/O.
active_work = self._describe_active_work() if gateway_state == "draining" else None
_write_runtime_status_quiet(
gateway_state=gateway_state, exit_reason=exit_reason,
restart_requested=self._restart_requested, active_agents=self._active_work_count(),
active_work=active_work)
def _persist_active_agents(self) -> None:
"""Persist the live in-flight agent count to ``gateway_state.json`` at every turn boundary.
Passes ONLY ``active_agents`` so the read-merge-write keeps lifecycle state (gateway_state=None
would clobber it). Best-effort: a failed write must never disrupt a turn."""
_write_runtime_status_quiet(active_agents=self._active_work_count())
def _running_agent_ids(self) -> set:
"""``id()`` of every agent mid-turn — identity-keyed so the lookup is O(1) and independent of
``AIAgent.__eq__`` (MagicMock overrides it in tests)."""
return {id(a) for _, a in self._running_agent_items()
if a is not None and a is not _AGENT_PENDING_SENTINEL}
def _snapshot_running_agents(self) -> Dict[str, Any]:
return {k: a for k, a in self._running_agent_items() if a is not _AGENT_PENDING_SENTINEL}
# ---- Tunables consumed by the run_* mixins (kept on the class: tests and plugins patch them) ----
# Per-session pending follow-up cap for busy_input_mode=queue (and paths sharing that entry point):
# a stuck agent + rapid-fire user must not grow the overflow list unboundedly.
_BUSY_QUEUE_MAX_PENDING = 32
@dataclasses.dataclass
class _BusySteerOutcome:
effective_mode: str
demoted_for_subagents: bool
demoted_for_compression: bool
steered: bool
redirected: bool
# Worker bound for _cleanup_agent_resources: sync, can block long (subprocess teardown, memory IO).
_CLEANUP_TIMEOUT_S = 30.0
# Budget for one finalize_session() dispatch (plugin on_session_finalize hooks + Relay close):
# enough for a normal trace-export flush, small enough a wedged plugin can't eat the stop window.
_FINALIZE_TIMEOUT_S = 10.0
_STUCK_LOOP_THRESHOLD = 3 # restarts while active before auto-suspend
_STUCK_LOOP_FILE = ".restart_failure_counts"
# Reasons set by _stop_impl() on force-interrupt; "restart_interrupted" by suspend_recently_active()
# on crash recovery (no .clean_shutdown marker). All mean "killed mid-turn" -> startup auto-resume.
_AUTO_RESUME_REASONS = frozenset({"restart_timeout", "shutdown_timeout", "restart_interrupted"})
_MAX_SUPERVISED_RESTARTS = 5
# Ran this long before crashing = HEALTHY (isolated crash, not a crash-loop); restart counter resets.
_SUPERVISED_HEALTHY_SECS = 300
# Slow respawn tier once the watcher's restart budget is spent; long on purpose (crashes on contact).
_RECONNECT_WATCHER_SLOW_RETRY_SECS = 300
# Slow-tier respawns while work is queued; if 30 min of 5-min retries can't keep it up, fail loudly.
_MAX_SLOW_WATCHER_RESPAWNS = 6
_TELEGRAM_CAPABILITY_HINT_COOLDOWN_S = 300.0
_APPROVAL_TIMEOUT_SECONDS = 300 # 5 minutes
_MAX_INTERRUPT_DEPTH = 3 # Cap recursive interrupt handling
# Command-specific mid-run reject texts (busy_policy == "reject" with a busy_handler naming an
# entry here); all other rejected commands get the generic text in _dispatch_busy_slash_command.
_BUSY_REJECT_TEXT: Dict[str, str] = {
"model": "Agent is running — wait or /stop first, then switch models.",
"codex-runtime": "Agent is running — wait or /stop first, then change runtime.",
"moa": "Agent is running — wait or /stop first, then run /moa."}
def _active_profile_name(self) -> str:
"""Return the profile name this gateway represents."""
try:
from hermes_cli.profiles import get_active_profile_name
return get_active_profile_name() or "default"
except Exception:
return "default"
def _is_user_authorized_for_source(
self, source: SessionSource, *, allow_adapter_delegation: bool = True) -> bool:
"""Authorize under the live transport's profile, not the routed runtime (which need not copy the
shared bot token/allowlist); the transport home is stamped on the source for this read only."""
def _check() -> bool:
# Keep the one-argument seam used by plugins/tests; pass the keyword only when disabling.
if allow_adapter_delegation:
return self._is_user_authorized(source)
return self._is_user_authorized(source, allow_adapter_delegation=False)
return self._under_authorization_profile(source, _check)
def _admit_bot_message_for_source(self, source: SessionSource) -> bool:
"""Count a bot message under the profile that authorized it, so the guard's peek, count and
config all read the transport profile's ``gateway.bot_loop_guard``."""
return self._under_authorization_profile(source, lambda: self._admit_bot_message(source))
def _under_authorization_profile(self, source: SessionSource, check):
authorization_home = self._authorization_home_for_source(source)
if authorization_home is None:
return check()
with _profile_runtime_scope(Path(authorization_home)):
return check()
def _cache_session_source(self, session_key: str, source) -> None:
if not session_key or source is None:
return
cached_sources = getattr(self, "_session_sources", None)
if cached_sources is None:
cached_sources = OrderedDict()
self._session_sources = cached_sources
try:
cached_sources[session_key] = dataclasses.replace(source)
except Exception:
logger.debug("Failed to cache live session source for %s", session_key, exc_info=True)
return
try:
cached_sources.move_to_end(session_key)
max_size = getattr(self, "_session_sources_max", 512)
while len(cached_sources) > max_size:
cached_sources.popitem(last=False)
except Exception:
pass
@property
def async_session_store(self) -> AsyncSessionStore:
"""Return the single async facade for this runner's SessionStore."""
facade = getattr(self, "_async_session_store", None)
if facade is None or facade._store is not self.session_store:
facade = AsyncSessionStore(self.session_store)
self._async_session_store = facade
return facade
def _get_cached_session_source(self, session_key: str):
cached_sources = getattr(self, "_session_sources", None) if session_key else None
if not cached_sources:
return None
source = cached_sources.get(session_key)
if source is not None:
with suppress(Exception):
cached_sources.move_to_end(session_key)
return source
@dataclasses.dataclass
class _HygieneSettings:
"""Resolved session-hygiene configuration for one inbound turn."""
model: str
threshold_pct: float
compression_enabled: bool
hard_msg_limit: int
timeout_seconds: float
total_ceiling_seconds: float
max_turn_hold_seconds: float
failure_cooldown_seconds: float
config_context_length: Optional[int]
provider: Optional[str]
base_url: Optional[str]
api_key: Optional[str]
data: Any
@dataclasses.dataclass
class _HygieneAttempt:
"""One detached hygiene compression attempt. ``cleanup_deferred`` is shared mutable state: wait
handlers set it on raise paths; the owning ``finally`` reads it to decide on cleanup now."""
agent: Any
meta: Any
commit_fence: Any = None
future: Any = None
wait_started: float = 0.0
cleanup_deferred: bool = False
history: Any = None
def _thread_metadata_for_source(
self, source, reply_to_message_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Build the metadata dict platforms need for thread-aware replies."""
metadata = self._thread_metadata_for_target(
getattr(source, "platform", None), getattr(source, "chat_id", None),
getattr(source, "thread_id", None), chat_type=getattr(source, "chat_type", None),
reply_to_message_id=reply_to_message_id or getattr(source, "message_id", None))
if getattr(source, "platform", None) == Platform.SLACK:
# Per-turn egress identity: Slack chat.startStream needs recipient_user_id/team_id; the relay
# adapter's _with_scope fallback reads per-chat caches a CONCURRENT turn overwrites.
# Slack's chat.startStream requires recipient_user_id (+ recipient_team_id) when streaming to a
# channel, and the relay connector fills those from metadata.user_id / metadata.scope_id. The
# relay adapter's _with_scope fallback resolves BOTH from per-chat caches keyed only by chat_id
# — mutable state that a CONCURRENT turn overwrites: two users with overlapping turns in one
# channel would open U1's stream with U2 as the recipient. Stamp the authentic per-turn values
# from THIS turn's source here, where they are still turn-scoped; _with_scope only fills keys
# that are absent, so the cache degrades to what it should be — a restart/synthetic-send
# fallback. See #210.
team_id = getattr(source, "scope_id", None)
user_id = getattr(source, "user_id", None)
if team_id or user_id:
metadata = dict(metadata or {})
if team_id:
metadata["slack_team_id"] = str(team_id)
metadata.setdefault("scope_id", str(team_id))
if user_id:
metadata.setdefault("user_id", str(user_id))
from gateway.session_context import source_route_metadata
metadata = source_route_metadata(source, metadata)
# Routed profile for shared state.db namespaces: under profile_routes the transport adapter's
# stamp is not the profile that wrote the binding (Telegram prune path needs it).
# See #76423.
profile = str(getattr(source, "profile", None) or "").strip()
if profile and metadata is not None:
metadata = dict(metadata)
metadata["hermes_profile"] = profile
return metadata
def _thread_metadata_for_target(
self, platform: Optional[Platform], chat_id: Optional[str], thread_id: Optional[str], *,
chat_type: Optional[str] = None, reply_to_message_id: Optional[str] = None,
adapter: Optional[Any] = None) -> Optional[Dict[str, Any]]:
"""Build thread metadata for synthetic sends that only have routing state."""
if thread_id is None:
return None
metadata: Dict[str, Any] = {"thread_id": thread_id}
if self._is_telegram_dm_topic_target(
platform, chat_id, thread_id, chat_type=chat_type, adapter=adapter):
metadata["telegram_dm_topic_reply_fallback"] = True
# DM topic lanes need direct_messages_topic_id so synthetic sends reach the topic without a reply anchor.
tid = str(thread_id)
if tid and tid not in {"", "1"}:
metadata["direct_messages_topic_id"] = tid
if reply_to_message_id is not None:
metadata["telegram_reply_to_message_id"] = str(reply_to_message_id)
if platform == Platform.SLACK and reply_to_message_id is not None:
# Slack's reply_in_thread=false path uses message_id to tell real threads from synthetic keys.
metadata["message_id"] = str(reply_to_message_id)
return metadata
@staticmethod
def _is_telegram_dm_topic_target(
platform: Optional[Platform], chat_id: Optional[str], thread_id: Optional[str], *,
chat_type: Optional[str] = None, adapter: Optional[Any] = None) -> bool:
"""Return True when a target is a Telegram private DM topic lane."""
if platform != Platform.TELEGRAM or thread_id is None:
return False
if chat_type == "dm":
return True
# Resolve the lookup on the CLASS, not the instance: getattr() on a MagicMock auto-creates callable
# children, so an instance lookup would report a DM topic for every test double. Only a dict counts.
if adapter is not None and chat_id:
get_dm_topic_info = getattr(type(adapter), "_get_dm_topic_info", None)
if callable(get_dm_topic_info):
try:
topic_info = get_dm_topic_info(adapter, str(chat_id), str(thread_id))
except Exception:
logger.debug("Failed to inspect Telegram DM topic metadata", exc_info=True)
else:
return isinstance(topic_info, dict)
return False
_reply_anchor_for_event = staticmethod(_reply_anchor_for_event)
# Built-in platforms where ``/update`` is allowed (programmatic interfaces must not trigger updates).
# Plugin-migrated platforms declare ``allow_update_command=True`` on their ``PlatformEntry`` instead.
_UPDATE_ALLOWED_PLATFORMS = frozenset({
Platform.TELEGRAM, Platform.SLACK, Platform.WHATSAPP, Platform.SIGNAL, Platform.MATRIX,
Platform.EMAIL, Platform.SMS, Platform.DINGTALK,
Platform.FEISHU, Platform.WECOM, Platform.WECOM_CALLBACK, Platform.WEIXIN, Platform.BLUEBUBBLES, Platform.QQBOT, Platform.LOCAL,
})
def _set_session_env(self, context: SessionContext) -> list:
"""Set session context variables (contextvars, not os.environ, so concurrent messages can't
overwrite each other). Returns reset tokens for ``_clear_session_env`` in a ``finally``."""
from gateway.session_context import set_session_vars
# Async-delivery capability tells async tools whether this channel can wake a later turn. Default
# True keeps CLI/unknown paths working; stateless adapters (api_server) declare False.
_adapter = (getattr(self, "adapters", None) or {}).get(context.source.platform)
_async_delivery = getattr(_adapter, "supports_async_delivery", True)
return set_session_vars(
platform=context.source.platform.value,
chat_id=context.source.chat_id,
chat_type=str(context.source.chat_type) if context.source.chat_type else "",
chat_name=context.source.chat_name or "",
thread_id=str(context.source.thread_id) if context.source.thread_id else "",
user_id=str(context.source.user_id) if context.source.user_id else "",
user_id_alt=str(context.source.user_id_alt) if context.source.user_id_alt else "",
user_name=str(context.source.user_name) if context.source.user_name else "",
scope_id=str(getattr(context.source, "scope_id", "") or ""),
parent_chat_id=str(getattr(context.source, "parent_chat_id", "") or ""),
session_key=context.session_key,
message_id=str(context.source.message_id) if context.source.message_id else "",
profile=getattr(context.source, "profile", "") or "",
async_delivery=_async_delivery,
cron_session="")
def _clear_session_env(self, tokens: list) -> None:
"""Restore session context variables to their pre-handler values."""
from gateway.session_context import clear_session_vars
clear_session_vars(tokens)
async def _run_in_executor_with_context(self, func, *args):
"""Run blocking work in the thread pool while preserving session contextvars."""
loop = asyncio.get_running_loop()
ctx = copy_context()
return await loop.run_in_executor(self._get_executor(), ctx.run, func, *args)
def _get_executor(self) -> concurrent.futures.ThreadPoolExecutor:
"""Return the gateway-owned executor for blocking agent work."""
lock = getattr(self, "_executor_lock", None)
if lock is None:
lock = threading.Lock()
self._executor_lock = lock
with lock:
if getattr(self, "_executor_closing", False):
raise RuntimeError("Gateway is shutting down; executor unavailable")
executor = getattr(self, "_executor", None)
if executor is None or getattr(executor, "_shutdown", False):
executor = concurrent.futures.ThreadPoolExecutor(
max_workers=10, thread_name_prefix="hermes-gateway")
self._executor = executor
return executor
def _shutdown_executor(self, drain_timeout: float = 0.0) -> int:
"""Stop the gateway-owned executor; returns the number of worker threads still running.
``drain_timeout=0`` is fire-and-forget; shutdown passes a bounded budget so blocking DB work
cannot outlive ``SessionDB.close()``. ``cancel_futures`` only drops unstarted work and cancelling
a ``run_in_executor`` awaitable does not stop its thread, so running workers are joined."""
lock = getattr(self, "_executor_lock", None)
if lock is None:
return 0
with lock:
self._executor_closing = True
executor = getattr(self, "_executor", None)
self._executor = None
if executor is None:
return 0
try:
executor.shutdown(wait=False, cancel_futures=True)
except TypeError:
executor.shutdown(wait=False)
# shutdown() has no timeout, so join workers directly; `_threads` is absent on test doubles (no wait).
workers = list(getattr(executor, "_threads", None) or ())
deadline = time.monotonic() + max(float(drain_timeout or 0.0), 0.0)
for worker in workers:
remaining = deadline - time.monotonic()
if remaining <= 0:
break
worker.join(remaining)
return sum(1 for worker in workers if worker.is_alive())
# (section, key) config values baked into the agent at construction: a change MUST invalidate the
# cached agent or a mid-gateway edit is silently ignored. Add new baked-in settings here.
# _MAX_INTERRUPT_DEPTH = 3 # Cap recursive interrupt handling (#816)
_CACHE_BUSTING_CONFIG_KEYS: tuple = (
("model", "context_length"), ("compression", "enabled"),
("compression", "progress_notices"), ("compression", "threshold"),
("compression", "model_thresholds"), ("compression", "threshold_tokens"),
("compression", "codex_gpt55_autoraise"), ("compression", "codex_app_server_auto"),
("compression", "codex_responses_native"),
("compression", "codex_responses_compact_threshold"), ("compression", "in_place"),
("compression", "checkpoint_required"), ("compression", "micro_compact"),
("compression", "micro_compact_every_n_turns"),
("compression", "micro_compact_defrag_threshold_tokens"), ("compression", "target_ratio"),
("compression", "tail_mode"), ("compression", "protect_last_n"),
("compression", "proactive_prune_tokens"),
("compression", "proactive_prune_min_result_chars"),
("compression", "proactive_prune_min_reclaim_tokens"),
("compression", "min_tail_user_messages"), ("agent", "disabled_toolsets"),
("memory", "provider"), ("checkpoints", "enabled"), ("checkpoints", "max_snapshots"),
("checkpoints", "max_total_size_mb"), ("checkpoints", "max_file_size_mb"))
@staticmethod
def _init_cached_agent_for_turn(agent: Any, interrupt_depth: int) -> None:
"""Reset per-turn state on a cached agent before a new turn starts.
The activity ts/desc/provenance triple resets together and only at depth 0 — else a session idle
29 min trips the watchdog before the first call; interrupt-recursive turns keep it so stuck-turn
idle time accumulates to the 30-min timeout.
``_last_activity_ts``, ``_last_activity_desc``, and ``_last_activity_provenance`` are only reset for
fresh external turns (depth 0); they are a semantic triple - description and provenance describe the
activity *at* ts, so updating one without the others would make get_activity_summary() misleading.
See #15654, #9051.
"""
if interrupt_depth == 0:
agent._last_activity_ts = time.time()
agent._last_activity_desc = "starting new turn (cached)"
agent._last_activity_provenance = ActivityProvenance.UNKNOWN
# Reset the SessionDB flush cursor so the new turn's messages are fully persisted — a stale
# value from the previous turn makes `_flush_messages_to_session_db` skip new rows.
# See #44327.
if hasattr(agent, "_last_flushed_db_idx"):
agent._last_flushed_db_idx = 0
agent._api_call_count = 0
def _profile_name_for_source(
self, source: SessionSource, adapter_profile: Optional[str] = None,
) -> Optional[str]:
"""Resolve the profile name for an inbound source via configured routes (most specific wins).
``None`` = default/active profile (or, for a secondary adapter, its own profile — the caller
stamps it). Gated on ``multiplex_profiles``, since the scoped run only activates under
multiplexing; otherwise keys would be profile-namespaced while the agent ran in ``agent:main``.
``adapter_profile`` is the profile owning the receiving bot; only routes declaring it as
``bot_profile`` apply (#104933)."""
config = getattr(self, "config", None)
if not getattr(config, "multiplex_profiles", False):
return None
routes = getattr(config, "profile_routes", None)
if not routes:
return None
if adapter_profile is None:
# Sources built outside ``build_source`` may still carry the receiving adapter as provenance.
owner = self._transport_owner(source) if callable(getattr(source, "_transport_adapter_ref", None)) else None
if isinstance(owner, tuple):
adapter_profile = owner[1]
from gateway.profile_routing import ProfileRouteRejected, match_profile_route
try:
matched = match_profile_route(
routes, platform=source.platform.value, guild_id=getattr(source, "guild_id", None),
chat_id=source.chat_id, thread_id=getattr(source, "thread_id", None),
parent_chat_id=getattr(source, "parent_chat_id", None),
adapter_profile=adapter_profile, user_id=getattr(source, "user_id", None))
except Exception as exc:
logger.warning(
"Rejecting %s/%s: profile route matching failed",
source.platform, source.chat_id, exc_info=True)
raise ProfileRouteRejected("matcher") from exc
if matched:
try:
served = {name for name, _home in _multiplex_profile_homes(config)}
except Exception as exc:
logger.warning(
"Rejecting profile route %r because the served-profile set could not be resolved",
matched.name, exc_info=True)
raise ProfileRouteRejected(matched.name) from exc
if matched.profile not in served:
logger.warning(
"Rejecting profile route %r: target profile %r is not served",
matched.name, matched.profile)
raise ProfileRouteRejected(matched.name)
return matched.profile
logger.debug(
"No profile route matched: platform=%s chat_id=%s thread_id=%s parent_chat_id=%s",
source.platform.value, source.chat_id,
getattr(source, "thread_id", None), getattr(source, "parent_chat_id", None))
return None
def _resolve_profile_home_for_source(self, source: SessionSource) -> "Path":
"""Resolve which profile's HERMES_HOME serves this source: the pinned identity's runtime
home, else ``source.profile``, then ``_profile_name_for_source`` (sources bypassing
``build_source``), then the active profile."""
from gateway.profile_routing import ProfileRouteRejected
from gateway.session_identity import identity_of
from hermes_cli.profiles import get_active_profile_name, get_profile_dir, profile_exists
from hermes_constants import get_hermes_home
identity = identity_of(source)
if identity is not None:
return identity.runtime_home
explicit_profile = None # explicitly requested (source or routing) vs. default fallback
try:
name = (source.profile or "").strip() or self._profile_name_for_source(source)
explicit_profile = name or None
if not name:
name = get_active_profile_name() or "default"
profile_dir = get_profile_dir(name)
if explicit_profile and not profile_exists(name):
logger.warning(
"Profile %r does not exist for source %s/%s (guild_id=%s), "
"falling back to global HERMES_HOME",
explicit_profile, source.platform.value, source.chat_id,
getattr(source, "guild_id", None))
return get_hermes_home()
return profile_dir
except ProfileRouteRejected:
raise
except Exception:
logger.warning(
"Failed to resolve profile directory for source %s/%s (guild_id=%s), "
"falling back to global HERMES_HOME: %s",
source.platform.value, source.chat_id, getattr(source, "guild_id", None),
explicit_profile or "(no profile)", exc_info=True)
return get_hermes_home()
@dataclasses.dataclass
class _RunAgentDisplay:
"""Per-turn display / progress settings resolved by ``_run_agent_display_settings``."""
user_config: Any = None
platform_key: Any = None
enabled_toolsets: Any = None
disabled_toolsets: Any = None
resolve_display_setting: Any = None
progress_mode: Any = None
progress_grouping: Any = None
_display_surface_mode: Any = None
tool_progress_enabled: Any = None
_live_status_mode: Any = None
_live_status_adapter: Any = None
log_mode_enabled: Any = None
log_queue: Any = None
interim_assistant_messages_enabled: Any = None
_thinking_enabled: Any = None
_native_slack_task_cards: Any = None
needs_progress_queue: Any = None
_generic_status_phrase: Any = None
@dataclasses.dataclass
class _RunAgentWorker:
"""Executor future + inactivity-watchdog handles for one ``_run_agent_inner`` turn."""
executor_task: Any = None
agent_timeout: Optional[float] = None
agent_warning: Optional[float] = None
task_id: str = ""
process_baseline: Any = None
worker_done: Any = None
timeout_fired: Any = None
cleanup_lock: Any = None
is_current: Any = None
def _run_planned_stop_watcher(
stop_event: threading.Event, runner, loop: asyncio.AbstractEventLoop, shutdown_handler, *,
poll_interval: float = 0.5) -> None:
"""Poll for the planned-stop marker and trigger graceful shutdown (Windows lacks
``add_signal_handler``, so ``hermes gateway stop`` would never drain). Runs everywhere; on POSIX
the signal handler consumes the marker first and ``_running``/``_draining`` guard re-triggers.
On Windows, ``asyncio.add_signal_handler`` raises NotImplementedError for SIGTERM/SIGINT, so the
standard signal-driven shutdown path never runs when ``hermes gateway stop`` signals the gateway. The
consequence is that the drain loop is skipped — in-flight agent sessions are killed mid-turn and
``resume_pending`` is never set, so the next gateway boot has no idea those sessions need to be
auto-resumed (issue #33778, v0.13.0 session-resume feature broken on native Windows).
"""
from gateway.status import (
_get_planned_stop_marker_path, planned_stop_marker_targets_self)
marker_path = _get_planned_stop_marker_path()
while not stop_event.is_set():
try:
if (
marker_path.exists()
and not getattr(runner, "_draining", False)
and getattr(runner, "_running", False)):
# A marker may target a PREVIOUS instance that exited before stop() cleaned up;
# firing on it means an "UNKNOWN" exit and a watchdog crash-loop; probe unlinks stale.
# A marker existing is NOT sufficient — it may have been written for a PREVIOUS gateway
# instance (different PID) and left behind because that process exited before the CLI's
# stop() could clean it up. Firing the handler on a stale/foreign marker drives the gateway
# into shutdown, then consume_planned_stop_marker_for_self() correctly reports a PID
# mismatch — but by then we're already stopping, so it's logged as an unexpected "UNKNOWN"
# exit and the watchdog crash-loops the gateway (issue #34597, a regression from PR #33798
# which added this watcher without the PID check). Only fire when the marker actually
# targets us. The probe is non-destructive on a match (the handler does the authoritative
# consume on the loop thread) and self-heals by unlinking stale/malformed markers so they
# cannot wedge a freshly booted gateway.
if not planned_stop_marker_targets_self():
stop_event.wait(poll_interval)
continue
# Same path as a real signal; the handler consumes the marker (validates pid + start_time).
loop.call_soon_threadsafe(shutdown_handler, None)
break
except Exception as _e:
logger.debug("Planned-stop watcher tick error: %s", _e)
stop_event.wait(poll_interval)
def _housekeeping_chore(label: str, fn, *args, **kwargs) -> None:
"""Run one housekeeping chore; failures log at debug (a persistent failure such as a broken
import after a partial update would otherwise warn every tick forever) and never stop the loop."""
try:
fn(*args, **kwargs)
except Exception as exc:
logger.debug("%s error: %s", label, exc)
def _housekeeping_channel_directory(adapters, loop) -> None:
from gateway.channel_directory import build_channel_directory
if loop is not None:
# build_channel_directory is async (Slack web calls) and this is a background thread:
# schedule onto the gateway loop and wait briefly so refresh failures still log.
fut = safe_schedule_threadsafe(
build_channel_directory(adapters), loop, logger=logger,
log_message="Channel directory refresh scheduling error")
if fut is not None:
fut.result(timeout=30)
def _housekeeping_media_caches() -> None:
"""Every platform media cache prunes on the same hourly cadence (24h max age)."""
from gateway.platforms.base import (
cleanup_audio_cache, cleanup_document_cache, cleanup_image_cache, cleanup_screenshot_cache,
cleanup_video_cache)
from tools.tool_result_storage import cleanup_spillover_cache
from tools.environments.local import cleanup_terminal_temp_cache
from tools.bot_mode_dm import cleanup_bot_dm_cache
from tools.bot_relay import cleanup_bot_relay_artifacts
for cache_name, cleanup_fn in (
("Image", cleanup_image_cache), ("Document", cleanup_document_cache),
("Audio", cleanup_audio_cache), ("Video", cleanup_video_cache),
("Screenshot", cleanup_screenshot_cache), ("Spillover", cleanup_spillover_cache),
("Terminal temp", cleanup_terminal_temp_cache), ("Bot DM", cleanup_bot_dm_cache),
("Bot relay", cleanup_bot_relay_artifacts)):
def _one(name=cache_name, fn=cleanup_fn):
removed = fn(max_age_hours=24)
if removed:
logger.info("%s cache cleanup: removed %d stale file(s)", name, removed)
_housekeeping_chore(f"{cache_name} cache cleanup", _one)
def _housekeeping_paste_sweep() -> None:
from hermes_cli.debug import _sweep_expired_pastes
deleted, remaining = _sweep_expired_pastes()
if deleted:
logger.info("Paste sweep: deleted %d expired paste(s), %d pending", deleted, remaining)
def _housekeeping_misfire_catch_up(cron_provider, adapters, loop) -> None:
"""External cron providers only: fire jobs whose time passed with no external fire delivered (dead
loopback hop). No-op for the built-in ticker; enforces misfire_grace_minutes; CAS claim de-dupes."""
from cron.scheduler_provider import fire_overdue_jobs
caught_up = fire_overdue_jobs(cron_provider, adapters=adapters, loop=loop)
if caught_up:
logger.info("Misfire catch-up: fired %d overdue job(s)", caught_up)
def _housekeeping_curator() -> None:
"""maybe_run_curator() is gated by config.interval_hours (7 days default); this is the poll."""
from agent.curator import maybe_run_curator
maybe_run_curator(idle_for_seconds=float("inf"), on_summary=lambda msg: logger.info("curator: %s", msg))
def _housekeeping_skill_sync() -> None:
"""Inert unless the access gate is open and a sync base URL is configured."""
from tools.skills_sync_client import maybe_pull_skills
maybe_pull_skills()
def _housekeeping_org_skill_sync() -> None:
"""Gated on real org membership (the token must carry an org role): solo accounts never reach the network."""
from tools.skills_sync_client_org import maybe_pull_org_skills
maybe_pull_org_skills()
def _launch_sessions_dir(config) -> Optional[Tuple[Path, Path]]:
"""``(launch home, its configured transcript dir)``, or ``None`` when the gateway carries none.
MUST be called outside any profile scope — ``get_hermes_home()`` is what identifies the launch
home. Consumed by :func:`_profile_sessions_dir`.
"""
sessions_dir = getattr(config, "sessions_dir", None)
if sessions_dir is None:
return None
return get_hermes_home(), Path(sessions_dir)
def _profile_sessions_dir(launch: Optional[Tuple[Path, Path]]) -> Path:
"""Transcript dir of the profile currently in scope.
``gateway.sessions_dir`` overrides the LAUNCH profile's transcript dir only; every other served
profile keeps ``<home>/sessions``. Hardcoding ``<home>/sessions`` for the launch home too wrote
transcripts to the configured dir while the prune unlinked under the default one, orphaning
every pruned session's ``.json``/``.jsonl``/``request_dump_*`` forever.
"""
home = get_hermes_home()
if launch is not None and Path(launch[0]) == home:
return Path(launch[1])
return home / "sessions"
def _housekeeping_state_db_maintenance(launch: Optional[Tuple[Path, Path]] = None) -> None:
"""Stale-session auto-archive plus auto-prune/VACUUM for ONE profile's state.db; both are gated
by sessions.min_interval_hours (VACUUM additionally by its own throttles). Opens its own
SessionDB — SQLite connections are thread-bound.
Profile-scoped by its caller: ``acquire()``, ``get_hermes_home()`` and ``load_config()`` all
resolve through the active scope, so an unscoped run swept only the LAUNCH profile's store with
the LAUNCH profile's retention settings and a multiplexed secondary was never archived, pruned
or vacuumed by anyone — the dashboard/serve trigger defers to the gateway for every profile a
gateway owns (``web_server_sessions``). *launch* carries the launch home's configured transcript
dir (:func:`_launch_sessions_dir`) so its override still governs its own profile."""
from hermes_cli.config import load_config as _load_full_config
from hermes_state_registry import acquire, release_or_close
_sess_cfg = (_load_full_config().get("sessions") or {})
if not (_sess_cfg.get("auto_archive", False) or _sess_cfg.get("auto_prune", False)):
return
_adb = acquire()
try:
if _sess_cfg.get("auto_archive", False):
_adb.maybe_auto_archive(
idle_days=float(_sess_cfg.get("auto_archive_days", 3)),
min_interval_hours=int(_sess_cfg.get("min_interval_hours", 24)))
if _sess_cfg.get("auto_prune", False):
_adb.maybe_auto_prune_and_vacuum(
retention_days=int(_sess_cfg.get("retention_days", 90)),
min_interval_hours=int(_sess_cfg.get("min_interval_hours", 24)),
min_vacuum_interval_days=int(_sess_cfg.get("min_vacuum_interval_days", 30)),
vacuum=bool(_sess_cfg.get("vacuum_after_prune", True)),
sessions_dir=_profile_sessions_dir(launch))
finally:
release_or_close(_adb)
def _housekeeping_deferred_fts_retry() -> None:
"""A SessionDB opened while another process held the rebuild lock fails closed onto the LIKE fallback
and the gateway stays up for days. Non-blocking, rate-limited inside SessionDB; no-op when not stale."""
# Retry here, on the existing tick, against the shared instances this process already holds:
# non-blocking admission, no new thread, rate-limited inside SessionDB. No-op when nothing is stale (one
# attribute read per instance). See #100108.
from hermes_state_registry import borrow_live_shared_session_dbs
with borrow_live_shared_session_dbs() as _session_dbs:
for _sdb in _session_dbs:
_retry = getattr(_sdb, "retry_deferred_fts_recovery", None)
if callable(_retry) and _retry():
logger.info(
"Deferred state.db FTS rebuild completed in-process for %s; full-text search restored.",
getattr(_sdb, "db_path", "state.db"))
def _housekeeping_memory_trim() -> None:
"""Messaging-gateway counterpart to the TUI idle reaper; config-gated and rate-limited inside."""
from hermes_cli.mem_trim import trim_memory
trim_memory(reason="messaging gateway housekeeping")
def _housekeeping_checkpoint_prune() -> None:
"""Checkpoint store retention + size cap on a live timer; ``auto_prune_from_config`` gates on
``checkpoints.auto_prune`` and the 24h ``.last_prune`` marker. Off the startup path because its
``git gc`` can block for tens of seconds on a large store."""
from tools.checkpoint_manager import auto_prune_from_config
auto_prune_from_config()
def _drain_restart_safe_cron_deliveries(adapters, loop, runner=None) -> None:
"""Drain each profile's worker queue through its matching live adapters. A credential-less satellite
profile (empty adapter map) drains through the primary's adapters routed by its own profile routes."""
from cron import scheduler as cron_scheduler
from cron import scheduler_preflight as sched_preflight
if runner is None:
if adapters is not None:
cron_scheduler.drain_delivery_queue(adapters, loop)
return
for profile_name, profile_home in _handoff_watch_scopes(runner):
if profile_name is None:
profile_adapters = adapters
else:
profile_adapters = getattr(runner, "_profile_adapters", {}).get(profile_name)
if profile_adapters is None:
continue
with _profile_runtime_scope(profile_home or get_hermes_home()):
if profile_name is not None and not profile_adapters and adapters:
routes = sched_preflight._primary_profile_routes_for_current_home()
if routes:
profile_adapters = sched_preflight.SharedRouteAdapters(adapters, routes)
cron_scheduler.drain_delivery_queue(profile_adapters, loop)
def _start_gateway_housekeeping(
stop_event: threading.Event, adapters=None, loop=None, interval: int = 60, cron_provider=None, runner=None,
cron_thread=None,
):
"""Background thread for gateway-only periodic chores (NOT cron). Separate from the cron trigger
so chores run under any ``CronScheduler`` provider (external scale-to-zero has no 60s loop).
Cadences are ticks of ``interval``; inner gates own the real cadence."""
from gateway.run_delivery_queue_watch import DRAIN_LABEL, DeliveryQueueWatch, wait_for_next_tick
from gateway.run_profile_reconcile import _mcp_config_reconciler, profile_scoped_chore
chores: list[tuple[int, str, Any]] = [
# First every tick: re-stamp ``updated_at`` in gateway_state.json so it is a real heartbeat.
# ``hermes gateway status`` / ``/api/status`` warn when it ages past 2x ``interval`` with the
# PID alive — the thread (or a chore blocked on the loop) wedged (#113372). Runs first so a
# wedged chore stops the NEXT stamp instead of a slow one delaying this tick's.
(1, "Runtime heartbeat", _write_runtime_status_quiet)]
if adapters is not None or runner is not None:
# Restart-safe cron workers run outside the gateway cgroup and queue their final send for
# whichever gateway is live; drained here (not the scheduler tick) so external providers get it too.
chores.append((1, DRAIN_LABEL, lambda: _drain_restart_safe_cron_deliveries(adapters, loop, runner)))
chores += [
(5, "Channel directory refresh", lambda: adapters and _housekeeping_channel_directory(adapters, loop)),
(60, "Media cache cleanup", _housekeeping_media_caches),
(60, "Paste sweep", _housekeeping_paste_sweep)]
if cron_provider is not None:
chores.append((5, "Misfire catch-up sweep", lambda: _housekeeping_misfire_catch_up(cron_provider, adapters, loop)))
if cron_thread is not None:
# The ticker's own guards keep its loop alive; this is the outer layer for a thread that has
# already ended (#111010). Runs every tick so the outage is bounded by one housekeeping interval.
chores.append((1, "Cron ticker supervisor", cron_thread.restart_if_dead))
chores += [
# Per served profile: each profile has its own skills tree, curator state, Nous login
# and state.db.
(60, "Curator tick", profile_scoped_chore(runner, _housekeeping_curator)),
(60, "Sync pull tick", profile_scoped_chore(runner, _housekeeping_skill_sync)),
(60, "Org sync pull tick", profile_scoped_chore(runner, _housekeeping_org_skill_sync)),
(60, "state.db maintenance tick", profile_scoped_chore(
runner,
# Default-bound now, i.e. OUTSIDE any profile scope: this is the launch home's override.
lambda _launch=_launch_sessions_dir(getattr(runner, "config", None)):
_housekeeping_state_db_maintenance(_launch))),
(1, "Deferred FTS retry tick", _housekeeping_deferred_fts_retry),
(1, "gateway housekeeping memory trim", _housekeeping_memory_trim),
(1, "MCP config reconcile", _mcp_config_reconciler(runner)),
# Last: a real prune can hold this thread for a while; every other chore of the tick runs first.
(1, "Checkpoint prune tick", _housekeeping_checkpoint_prune)]
# Between ticks the queue file is watched so a worker's send goes out when it is queued,
# not up to ``interval`` later (#117307); the tick's drain above remains the fallback.
queue_watch = None
if adapters is not None or runner is not None:
def served_homes() -> list:
return [home for _name, home in _handoff_watch_scopes(runner)] if runner is not None else [None]
queue_watch = DeliveryQueueWatch(
served_homes, lambda: _drain_restart_safe_cron_deliveries(adapters, loop, runner))
logger.info("Gateway housekeeping started (interval=%ds)", interval)
tick_count = 0
while not stop_event.is_set():
tick_count += 1
for every, label, fn in chores:
if tick_count % every == 0:
_housekeeping_chore(label, fn)
wait_for_next_tick(stop_event, interval, queue_watch, _housekeeping_chore)
logger.info("Gateway housekeeping stopped")
def _start_cron_ticker(stop_event: threading.Event, adapters=None, loop=None, interval: int = 60):
"""DEPRECATED shim — runs ONLY the built-in in-process cron tick loop; the trigger now lives behind
the ``CronScheduler`` provider and housekeeping in ``_start_gateway_housekeeping``."""
from cron.scheduler_provider import InProcessCronScheduler
InProcessCronScheduler().start(stop_event, adapters=adapters, loop=loop, interval=interval)
def _stop_cron_provider(provider) -> None:
"""Stop a cron provider without letting it choose the gateway exit code."""
try:
provider.stop()
except SystemExit as exc:
logger.warning(
"Cron provider stop() attempted to exit the gateway with code %s; ignoring", exc.code)
except Exception as exc:
logger.debug("Cron provider stop() error: %s", exc)
# Cron thread blocks on future.result(timeout=60) (cron/scheduler.py::_deliver_result) + margin.
_CRON_SHUTDOWN_DRAIN_TIMEOUT = 65.0
# Housekeeping's channel-directory refresh blocks on fut.result(timeout=30); cover that + margin.
_HOUSEKEEPING_SHUTDOWN_DRAIN_TIMEOUT = 35.0
async def _await_thread_exit(
thread: Optional[threading.Thread], timeout: float, poll: float = 0.1) -> bool:
"""Wait for a daemon thread to exit WITHOUT blocking the event loop; True if it exited in time.
A synchronous ``join()`` freezes the loop — fatal for the cron ticker, whose in-flight delivery is a
coroutine on *this* loop: it could never run, so the join timed out and the message dropped.
See #58818.
"""
if thread is None:
return True
deadline = asyncio.get_running_loop().time() + max(0.0, timeout)
while thread.is_alive() and asyncio.get_running_loop().time() < deadline:
await asyncio.sleep(poll)
return not thread.is_alive()
async def _shutdown_mcp_servers_nonblocking(timeout: float = 5.0) -> bool:
"""Close MCP servers off-loop with a bounded wait; True when done within ``timeout``.
``shutdown_mcp_servers()`` can block ~15s; on the loop thread short-grace supervisors (s6 3s)
SIGKILL us before ``mark_exited()`` runs, so every later boot reports a phantom unclean death.
On timeout shutdown proceeds and the daemon thread is left to finish or die.
See #82874.
"""
def _do() -> None:
try:
from tools.mcp_tool_lifecycle import shutdown_mcp_servers
shutdown_mcp_servers()
except Exception:
logger.debug("MCP shutdown raised", exc_info=True)
thread = threading.Thread(target=_do, name="mcp-shutdown", daemon=True)
thread.start()
done = await _await_thread_exit(thread, timeout=timeout)
if not done:
logger.warning(
"MCP shutdown did not finish within %.1fs; continuing gateway "
"teardown (background thread will be reaped at process exit)", timeout)
return done
def _shutdown_gateway_health_export(runner: Any) -> None:
"""Idempotently drain and detach Gateway Health OTLP export."""
runtime = getattr(runner, "_gateway_health_export_runtime", None)
if runtime is None:
return
runner._gateway_health_export_runtime = None
try:
runtime.shutdown()
except Exception:
logger.debug("gateway health OTLP export shutdown failed", exc_info=True)
def _gateway_stderr_formatter() -> logging.Formatter:
"""Return the redacting formatter used by the gateway stderr stream."""
from agent.redact import RedactingFormatter
return RedactingFormatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
# ownership guard inserted below (PR #93084)
def _replace_target_belongs_to_other_profile(existing_pid: int) -> bool:
"""Return True when ``--replace`` must refuse to signal ``existing_pid``.
A poisoned/stale PID record can point at another profile's LIVE gateway (cross-profile SIGTERM
restart loop). Ownership is decided by the persisted identity record ALONE, bound to the live target
by exact PID + start-time; live argv can never PROVE ownership (no HERMES_HOME), it is only a
consistency check. Missing, legacy, conflicting or unprovable identity → refuse (fail closed)."""
# On Windows there is no systemd/launchd service query at all (_get_service_pids() returns an empty
# set), so a gateway supervised by a Scheduled Task / Startup VBS looks like an unsupervised orphan to
# the process scan (#86098). The same holds on every platform for a healthy gateway launched standalone
# (no service registration) whose PID the runtime record can see (#83683). Exempt the recorded healthy
# gateway PID and its parent chain: a recorded, liveness-verified gateway is by definition not an orphan
# "the pidfile/runtime record can't see", and the Scheduled-Task bootstrap's argv (``gateway run``)
# matches the gateway scan — killing that bootstrap takes the detached gateway it spawned down with it.
# Exclusion evidence comes from the RAW registration record, not the liveness-validated probe.
# ``get_running_pid`` (any flags) returns None whenever a record fails validation — start-time mismatch
# after PID-reuse checks, argv drift, lock hiccups — which is exactly when a healthy standalone gateway
# (no service supervisor — e.g. `hermes gateway run` on Windows) is at risk: its PID never joins the
# exclusion set and the sweep hard-kills it. On Windows SIGTERM is TerminateProcess, so the gateway's
# planned-stop watcher never gets a chance to drain. Reading the raw pidfile + lock records (no
# validation, no unlink side effects) is strictly safer for a KILL exclusion list: a stale recorded PID
# at worst spares one process this sweep, while a validation false-negative would kill a live gateway.
# The validated probe is still consulted for the runtime-status fallback PID it can surface when no
# pidfile exists.
try:
from gateway.status import (
_get_pid_path, _get_process_hermes_home, _get_process_start_time, _pid_from_record,
_read_pid_record, _record_looks_like_gateway, _read_process_cmdline, _same_hermes_home)
our_home = _get_process_hermes_home()
def refuse(msg: str, *args, level=logging.WARNING) -> bool:
logger.log(level, "Refusing --replace: " + msg, *args)
return True
# Bound claim: the record must name THIS pid with THIS live start time, else it proves nothing.
record = _read_pid_record(_get_pid_path())
if not isinstance(record, dict) or not _record_looks_like_gateway(record):
return refuse("no valid gateway pid record to prove ownership of PID %s.", existing_pid)
record_pid = _pid_from_record(record)
if record_pid != existing_pid:
return refuse("pid record names %s, not target %s.", record_pid, existing_pid)
recorded_start = record.get("start_time")
if not isinstance(recorded_start, int) or isinstance(recorded_start, bool):
return True
if _get_process_start_time(existing_pid) != recorded_start:
return refuse("pid record start-time does not match the live process %s (stale/PID-reuse record).",
existing_pid)
recorded_home = record.get("hermes_home")
if not isinstance(recorded_home, str) or not recorded_home.strip():
return refuse("pid record predates hermes_home stampings; ownership of PID %s unprovable.",
existing_pid)
if not _same_hermes_home(recorded_home, our_home):
return refuse("pid record belongs to a different HERMES_HOME (%s, ours %s). Remove the stale PID "
"record or stop the owning profile explicitly.", recorded_home, our_home,
level=logging.ERROR)
# Argv never proves ownership; an explicit contradicting --profile / HERMES_HOME= still refuses.
live_cmdline = _best_effort(lambda: _read_process_cmdline(existing_pid))
if live_cmdline and _looks_like_profile_conflict_from_cmdline(live_cmdline, our_home):
return refuse("target PID %s command line explicitly advertises a different profile than "
"HERMES_HOME %s.", existing_pid, our_home, level=logging.ERROR)
return False
except Exception:
# Destructive action + unknown ownership => fail closed.
logger.warning("cross-profile --replace ownership probe failed for PID %s; refusing to signal",
existing_pid, exc_info=True)
return True
def _looks_like_profile_conflict_from_cmdline(command: str, our_home) -> bool:
"""Token-exact contradiction check between a target argv and our home (authority is the pid record).
Substring matching is not identity: ``--profile timothy`` must NOT read as profile ``tim``. Returns
False whenever the argv does not clearly contradict our home."""
from gateway.status import _profile_name_for_home
profile_name = _profile_name_for_home(our_home)
try:
tokens = shlex.split(command)
except ValueError:
tokens = command.split()
def _flag_value(flag: str) -> Optional[str]:
"""Value of ``--flag X`` / ``--flag=X`` occurrences, token-exact."""
values = []
i = 0
while i < len(tokens):
tok = tokens[i]
if tok == flag and i + 1 < len(tokens):
values.append(tokens[i + 1])
i += 2
continue
if tok.startswith(flag + "="):
values.append(tok[len(flag) + 1:])
i += 1
return values[-1] if values else None
def _env_home_value() -> Optional[str]:
"""HERMES_HOME=<path> env-style assignment on the argv, token-exact."""
prefix = "HERMES_HOME="
for tok in reversed(tokens):
if tok.startswith(prefix):
return tok[len(prefix):]
return None
def _norm(path: str) -> str:
return os.path.normcase(os.path.normpath(path))
for flag in ("--profile", "-p"):
value = _flag_value(flag)
if value is None:
continue
# Named-profile home: a DIFFERENT explicit profile contradicts it (legacy default argv never carried
# profile flags). Default/root home: ANY explicit named-profile flag contradicts it.
if profile_name is None or profile_name == "default" or value != profile_name:
return True
home_value = _flag_value("--hermes-home") or _env_home_value()
return bool(home_value is not None and _norm(home_value) != _norm(str(our_home)))
def _clear_takeover_marker_quiet() -> None:
"""Best-effort: the marker is scoped to one target; a stale one would grief an unrelated shutdown."""
try:
from gateway.status import clear_takeover_marker
clear_takeover_marker()
except Exception:
pass
async def _wait_for_pid_exit(pid: int, attempts: int, delay: float) -> bool:
"""Poll for process exit without blocking the loop (a blocking sleep freezes signal handlers and
health checks). ``os.kill(pid, 0)`` on Windows is NOT a no-op — use the handle-based check."""
from gateway.status import _pid_exists
for _ in range(attempts):
if not _pid_exists(pid):
return True
await asyncio.sleep(delay)
return False
async def _start_gateway_replace_existing_instance(existing_pid: int, replace: bool) -> bool:
"""Handle a live gateway PID under this HERMES_HOME: replace it (``--replace``) or refuse.
Returns False when startup must abort (refused, permission denied, target still alive)."""
from gateway.status import get_process_start_time, remove_pid_file, terminate_pid
if not replace:
hermes_home = str(get_hermes_home())
logger.error(
"Another gateway instance is already running (PID %d, HERMES_HOME=%s). "
"Use 'hermes gateway restart' to replace it, or 'hermes gateway stop' first.",
existing_pid, hermes_home)
print(
f"\n❌ Gateway already running (PID {existing_pid}).\n"
f" Use 'hermes gateway restart' to replace it,\n"
f" or 'hermes gateway stop' to kill it first.\n"
f" Or use 'hermes gateway run --replace' to auto-replace.\n")
return False
# Never signal a process not provably ours (a poisoned PID record → cross-profile restart loop).
if _replace_target_belongs_to_other_profile(existing_pid):
from gateway.status import _get_process_hermes_home
logger.error(
"Refusing --replace: PID %d cannot be proven to belong "
"to this profile's gateway (HERMES_HOME %s). Remove the "
"stale PID record or stop the owning profile explicitly.",
existing_pid, _get_process_hermes_home())
return False
existing_start_time = get_process_start_time(existing_pid)
logger.info("Replacing existing gateway instance (PID %d) with --replace.", existing_pid)
# Takeover marker: target exits 0 on our SIGTERM (exit 1 → systemd Restart=on-failure flap loop).
try:
from gateway.status import write_takeover_marker
write_takeover_marker(existing_pid)
except Exception as e:
logger.debug("Could not write takeover marker: %s", e)
# Snapshot children BEFORE signalling: reparented orphans are invisible yet hold scoped token locks.
try:
from gateway.status import _snapshot_gateway_children
_old_gateway_children = _snapshot_gateway_children(existing_pid)
except Exception:
_old_gateway_children = []
try:
terminate_pid(existing_pid, force=False)
except ProcessLookupError:
pass # Already gone
except (PermissionError, OSError):
logger.error("Permission denied killing PID %d. Cannot replace.", existing_pid)
_clear_takeover_marker_quiet()
return False
# Up to 10s for SIGTERM, then SIGKILL.
if not await _wait_for_pid_exit(existing_pid, 20, 0.5):
logger.warning("Old gateway (PID %d) did not exit after SIGTERM, sending SIGKILL.", existing_pid)
old_gateway_exited = False
try:
terminate_pid(existing_pid, force=True, expected_start_time=existing_start_time)
except ProcessLookupError:
old_gateway_exited = True
except (PermissionError, OSError):
pass
# Confirm SIGKILL took (D-state/zombie) before clearing PID/locks, or two gateways share a token.
if not old_gateway_exited and not await _wait_for_pid_exit(existing_pid, 20, 0.25):
logger.error(
"Old gateway (PID %d) still appears alive after SIGKILL; "
"aborting replacement to avoid a duplicate gateway.", existing_pid)
_clear_takeover_marker_quiet()
return False
# Reap orphaned children (POSIX; mirrors Windows taskkill /T) so they stop holding scoped token locks.
try:
from gateway.status import reap_gateway_children
reap_gateway_children(_old_gateway_children, parent_pid=existing_pid)
except Exception:
logger.debug("Child reap for replaced gateway PID %d failed", existing_pid, exc_info=True)
remove_pid_file()
# remove_pid_file() is a no-op when the PID doesn't match; force-unlink covers a crashed old process.
with suppress(Exception):
(get_hermes_home() / "gateway.pid").unlink(missing_ok=True)
# The old process may not have consumed the marker (SIGKILL'd before its handler read it).
_clear_takeover_marker_quiet()
# Stopped (Ctrl+Z) processes don't release scoped locks on exit; stale lock files block the new gateway.
try:
from gateway.status import release_all_scoped_locks
_released = release_all_scoped_locks(owner_pid=existing_pid, owner_start_time=existing_start_time)
if _released:
logger.info("Released %d stale scoped lock(s) from old gateway.", _released)
except Exception:
pass
return True
def _start_gateway_configure_logging(verbosity: Optional[int]) -> None:
"""Sync bundled skills, set up file logging + startup security audit, and the -v/-q stderr handler."""
def _sync_skills() -> None:
from tools.skills_sync import sync_skills
sync_skills(quiet=True)
_best_effort(_sync_skills)
# Centralized logging (agent.log INFO+, errors.log WARNING+, gateway.log gateway-only); idempotent.
from hermes_logging import setup_logging, _safe_stderr
setup_logging(hermes_home=_hermes_home, mode="gateway")
def _security_audit() -> None:
# Warn-on-load, never blocks: surfaces root / weak-SSH / unauthenticated-listener exposure.
from hermes_cli.security_audit_startup import log_startup_security_warnings
def _raw_cfg():
from hermes_cli.config import read_raw_config
return read_raw_config()
log_startup_security_warnings(hermes_home=_hermes_home, config=_best_effort(_raw_cfg))
_best_effort(_security_audit, "Startup security audit failed (non-fatal): %s")
# Optional stderr handler from -v/-q: None (quiet) = none; 0 = WARNING; 1 = INFO; 2+ = DEBUG.
if verbosity is not None:
_stderr_level = {0: logging.WARNING, 1: logging.INFO}.get(verbosity, logging.DEBUG)
_stderr_handler = logging.StreamHandler(_safe_stderr())
_stderr_handler.setLevel(_stderr_level)
_stderr_handler.setFormatter(_gateway_stderr_formatter())
root = logging.getLogger()
root.addHandler(_stderr_handler)
if _stderr_level < root.level: # so DEBUG records can reach the handler
root.setLevel(_stderr_level)
def _start_gateway_make_restart_signal_handler(runner):
"""Build the SIGUSR1 handler: log what the signal means, then the drain-aware service restart."""
def restart_signal_handler():
# systemd's `reload` verb (ExecReload=kill -USR1) lands here too; say so, because operators
# expect `reload` to mean an in-process config reload, not a drain-and-relaunch (#117267).
logger.info(
"SIGUSR1 received (systemctl reload / hermes gateway restart): performing a graceful "
"gateway restart — drain active turns, exit, supervisor relaunches. Not an in-process "
"config reload.")
runner.request_restart(detached=False, via_service=True)
return restart_signal_handler
def _start_gateway_make_shutdown_signal_handler(runner, _signal_initiated_shutdown: list):
"""Build the SIGINT/SIGTERM handler; ``_signal_initiated_shutdown[0]`` records an unplanned signal."""
def shutdown_signal_handler(received_signal=None):
# Planned --replace takeover (sibling marked this PID): exit 0 so systemd won't revive us.
def _takeover() -> bool:
from gateway.status import consume_takeover_marker_for_self
return consume_takeover_marker_for_self()
# Planned stop: CLI marks first, else its SIGTERM looks like an external kill. SIGINT = Ctrl+C.
def _planned_stop() -> bool:
from gateway.status import consume_planned_stop_marker_for_self
return consume_planned_stop_marker_for_self()
# Fast (<10ms) sync snapshot: stdlib + /proc, no subprocesses (`ps aux` here once blocked ~3s).
def _snapshot():
from gateway.shutdown_forensics import snapshot_shutdown_context
return snapshot_shutdown_context(received_signal)
planned_takeover = bool(_best_effort(_takeover, "Takeover marker check failed: %s"))
planned_stop = received_signal == signal.SIGINT or (
not planned_takeover and bool(_best_effort(_planned_stop, "Planned stop marker check failed: %s")))
_shutdown_ctx = _best_effort(_snapshot, "snapshot_shutdown_context failed: %s")
sig_name = _shutdown_ctx["signal"] if _shutdown_ctx else None
if planned_takeover:
logger.info("Received %s as a planned --replace takeover — exiting cleanly", sig_name or "SIGTERM")
elif planned_stop:
logger.info("Received %s as a planned gateway stop — exiting cleanly", sig_name or "SIGTERM/SIGINT")
else:
# Mirrored onto the runner so _stop_impl suppresses the gateway_state=stopped persist for
# unexpected signals; operator stops take the `planned_stop` branch and leave it False (DO persist).
_signal_initiated_shutdown[0] = runner._signal_initiated_shutdown = True
logger.info("Received %s — initiating shutdown", sig_name or "SIGTERM/SIGINT")
if _shutdown_ctx is not None:
def _log_context() -> None:
# The most useful line for "gateway keeps dying" tickets.
from gateway.shutdown_forensics import format_context_for_log
logger.warning("Shutdown context: %s", format_context_for_log(_shutdown_ctx))
def _diagnostic() -> None:
# Heavyweight (comm-only ps, pstree, dmesg), detached so it finishes even if our cgroup is torn
# down; bounded by an internal timeout, never blocks.
from gateway.shutdown_forensics import spawn_async_diagnostic
spawn_async_diagnostic(
_hermes_home / "logs" / "gateway-shutdown-diag.log", _shutdown_ctx["signal"], timeout_seconds=5.0)
_best_effort(_log_context, "format_context_for_log failed: %s")
_best_effort(_diagnostic, "spawn_async_diagnostic failed: %s")
if not planned_takeover:
# Supervisor/operator SIGNAL stop (bootout, kickstart -k, systemd, s6, bare kill) — the
# only kind launchd times with ExitTimeOut. In-band SIGUSR1 restarts never pass through
# here, and a sibling-driven --replace takeover is not launchd-timed either, so both
# keep the configured drain. _stop_impl uses this to cap the drain to the live budget.
runner._stop_requested_by_signal = True
asyncio.create_task(runner.stop())
return shutdown_signal_handler
def _start_gateway_claim_pid_file() -> bool:
"""Claim the runtime lock + PID file (O_EXCL winner is the authoritative gateway). False = lost."""
import atexit
from gateway.status import (
acquire_gateway_runtime_lock, get_running_pid, release_gateway_runtime_lock,
remove_pid_file, write_pid_file)
_current_pid = get_running_pid()
if _current_pid is not None and _current_pid != os.getpid():
logger.error("Another gateway instance (PID %d) started during our startup. "
"Exiting to avoid double-running.", _current_pid)
return False
if not acquire_gateway_runtime_lock():
logger.error("Gateway runtime lock is already held by another instance. Exiting.")
return False
try:
write_pid_file()
except FileExistsError:
release_gateway_runtime_lock()
logger.error("PID file race lost to another gateway instance. Exiting.")
return False
atexit.register(remove_pid_file)
atexit.register(release_gateway_runtime_lock)
_claim_host_gateway_role()
return True
def _claim_host_gateway_role() -> None:
"""Take the HOST-wide gateway lock alongside the per-home one and publish the record.
Observe-only in this step: the per-home lock above still decides whether this process runs,
so a host with two gateways (the shape the multiplex-only ruling forbids) starts as it always
did and says so in the log. Flipping this into a refusal is a separate, reviewable change.
"""
from gateway import host_rendezvous as hr
try:
outcome, error = hr.claim_host_lock(hr.ROLE_GATEWAY)
if outcome is hr.HostLockOutcome.ACQUIRED:
hr.publish_record(hr.ROLE_GATEWAY, profiles=hr.served_profiles())
# SIGTERM (systemd stop, docker stop, the update relaunch) does not run atexit.
hr.cleanup_on_exit(hr.ROLE_GATEWAY)
return
if outcome is hr.HostLockOutcome.COULD_NOT_OPEN:
logger.warning(
"Host gateway lock could not be opened (%s); this gateway is not discoverable. "
"No second gateway is implied — the lock directory itself is unusable.", error)
return
owner = hr.read_record(hr.ROLE_GATEWAY)
logger.warning(
"Another gateway already owns this host (%s). Multiplex-only expects exactly one "
"gateway per host; starting anyway (observe-only).",
hr.describe(owner) if owner else "owner unknown",
)
except Exception:
logger.debug("host gateway rendezvous failed", exc_info=True)
async def _start_gateway_start_control_socket(runner):
"""Start the gateway control socket (identify/status/pause-for-update); None when unavailable."""
import atexit
_control_server = None
try:
# Started immediately after the PID-file claim: winning that O_EXCL race is the moment this process
# becomes the authoritative gateway for its HERMES_HOME, so from here on "does a socket answer?" is
# a truthful liveness/identity query for updater and fleet consumers. Strictly non-fatal: a bind
# failure only means consumers fall back to the process-scan/state-file layer, exactly as before
# this feature. See #92091.
from gateway.control_socket import GatewayControlServer
from gateway.run_profile_reconcile import migrate_profile_identity_verb, purge_profile_identity_verb
# pause-for-update: the updater asks us to drain + exit (freeing venv handles) vs. a tree-kill
# (same path as SIGUSR1). Handler runs on the socket executor thread, so marshal onto the loop.
# pause-for-update (#92091 step 2): the updater asks this gateway to drain in-flight turns and exit
# cleanly — releasing every venv file handle — instead of being tree-killed mid-turn. Same drain
# path as SIGUSR1/service restarts (request_restart(via_service=True)); the updater (or the service
# manager) relaunches after the code swap.
_main_loop = asyncio.get_running_loop()
def _pause_for_update_handler() -> dict:
try:
from hermes_cli.gateway import _get_restart_drain_timeout
_drain = float(_get_restart_drain_timeout())
except Exception:
_drain = 30.0
accepted_box: list[bool] = []
_done = threading.Event()
def _request() -> None:
try:
accepted_box.append(runner.request_restart(detached=False, via_service=True))
finally:
_done.set()
_main_loop.call_soon_threadsafe(_request)
_done.wait(timeout=5.0)
accepted = bool(accepted_box and accepted_box[0])
return {
"pausing": accepted, "already_stopping": not accepted,
"pid": os.getpid(), "drain_timeout": _drain}
def _rescan_profiles_handler() -> dict:
"""``hermes profile create/delete`` asks the multiplexer to reconcile ``profiles/`` now
(the watcher also rescans periodically). Runs on the socket executor: marshal onto the loop
and wait briefly so the caller learns whether the profile is served."""
if not getattr(runner.config, "multiplex_profiles", False):
return {"multiplex": False, "served_profiles": runner.served_profile_names()}
future = asyncio.run_coroutine_threadsafe(
runner.reconcile_served_profiles(reason="control-socket"), _main_loop)
try:
# Bounded: a token-less create reconciles in milliseconds; a credential-add whose adapter
# connect outlasts this keeps running and the caller sees ``pending`` (not an error).
return {"multiplex": True, **future.result(timeout=5.0)}
except concurrent.futures.TimeoutError:
return {"multiplex": True, "pending": True, "served_profiles": runner.served_profile_names()}
_control_server = GatewayControlServer(
verb_handlers={"pause-for-update": _pause_for_update_handler,
"rescan-profiles": _rescan_profiles_handler,
"migrate-profile-identity": migrate_profile_identity_verb(runner),
"purge-profile-identity": purge_profile_identity_verb(runner)})
if not await _control_server.start():
_control_server = None
else:
atexit.register(_control_server.cleanup_files)
except Exception as _cs_exc:
logger.debug("Control socket startup failed (non-fatal): %s", _cs_exc)
_control_server = None
return _control_server
def _start_gateway_start_cron_and_housekeeping(runner):
"""Start the cron scheduler thread + gateway housekeeping thread; returns
``(cron_stop, cron_provider, cron_thread, housekeeping_thread)``."""
# The event loop is passed so cron delivery can use live adapters (E2EE support).
from cron.scheduler_provider import (
InProcessCronScheduler, resolve_cron_scheduler, scheduler_for_profile_mode)
cron_stop = threading.Event()
# ONE gateway process per host multiplexes every profile, so its cron ticker owns EVERY
# profile's store — `gateway.multiplex_profiles` gates adapters, not cron. Gating the tick set
# on that flag left every non-launch profile's jobs in a store no ticker visited: they
# silently never fired.
try:
cron_profile_homes = _cron_tick_profile_homes(runner.config)
except Exception as exc:
logger.warning("Could not resolve profile homes for cron: %s", exc)
cron_profile_homes = []
# External providers own one unscoped remote registry, so they can only serve a single home.
cron_provider = scheduler_for_profile_mode(
resolve_cron_scheduler(), multiplex_profiles=len(cron_profile_homes) > 1)
cron_start_kwargs: Dict[str, Any] = {"adapters": runner.adapters, "loop": asyncio.get_running_loop()}
if isinstance(cron_provider, InProcessCronScheduler) and cron_profile_homes:
# Live enumerator: the ticker re-reads profiles/ every cycle so a profile created while
# the gateway runs gets its jobs fired without a restart (hot-serve).
cron_start_kwargs["profile_homes"] = lambda: _cron_tick_profile_homes(runner.config)
# Stand down, per tick, for a profile whose OWN gateway process ticks it.
cron_start_kwargs["profile_gate"] = _cron_profile_gate
# Per-profile adapters so each profile's cron output goes via its own bot, not the
# default's. Absent (no multiplexed adapters), delivery for a secondary profile falls
# back to the primary's routed adapters or fails closed — the job still FIRES.
cron_start_kwargs["profile_adapters"] = getattr(runner, "_profile_adapters", None)
# runner.adapters belongs to the LAUNCH profile (``default``, or the ``--profile``
# name); naming it keeps the ticker from routing a secondary's cron through that bot
# and lets a named multiplexer's own jobs reuse its live adapters.
cron_start_kwargs["default_profile"] = runner._primary_profile_name
logger.info(
"Cron scheduler will tick %d profile(s): %s", len(cron_profile_homes),
[p[0] if isinstance(p, tuple) else p for p in cron_profile_homes])
# Only the in-process ticker polls local due jobs, so only it gets the external-drain dispatch gate.
if isinstance(cron_provider, InProcessCronScheduler):
cron_start_kwargs["can_dispatch"] = lambda: not (
runner._draining or runner._external_drain_active)
# Supervised: a ticker that dies without a stop request is respawned by housekeeping (#111010).
from cron.scheduler_thread import SupervisedTickerThread
cron_thread = SupervisedTickerThread(
cron_provider.start, args=(cron_stop,), kwargs=cron_start_kwargs, stop_event=cron_stop)
cron_thread.start()
# External providers fire over loopback HTTP to THIS process's api_server; if it never came up (usually
# API_SERVER_KEY missing) every fire fails while manual runs work — misread as a job bug. Say it ONCE.
if not isinstance(cron_provider, InProcessCronScheduler):
try:
_has_api_server = Platform.API_SERVER in (runner.adapters or {})
except Exception:
_has_api_server = True # never let the tell break startup
if not _has_api_server:
logger.warning(
"Cron provider '%s' is active but the api_server adapter is "
"NOT running in this gateway — scheduled fires arrive over "
"loopback HTTP and will all fail (jobs only run when "
"triggered manually). Most common cause: API_SERVER_KEY is "
"missing from this gateway process's environment. Restart "
"the gateway through its supervisor (`hermes gateway "
"restart`) so the profile env loads.",
getattr(cron_provider, "name", "external"))
# Gateway-only housekeeping runs independently of the cron provider; shares cron_stop for shutdown.
housekeeping_thread = threading.Thread(
target=_start_gateway_housekeeping, args=(cron_stop,),
kwargs={"adapters": runner.adapters, "loop": asyncio.get_running_loop(),
"cron_provider": cron_provider, "runner": runner, "cron_thread": cron_thread},
daemon=True, name="gateway-housekeeping")
housekeeping_thread.start()
return cron_stop, cron_provider, cron_thread, housekeeping_thread
async def _start_gateway_shutdown_tail(
runner, _control_server, cron_stop: threading.Event, cron_provider,
cron_thread: Any, housekeeping_thread: threading.Thread,
_planned_stop_watcher_stop: threading.Event, _planned_stop_watcher_thread: threading.Thread,
_signal_initiated_shutdown: list) -> bool:
"""Post-``wait_for_shutdown`` teardown; returns the process exit verdict (True = exit 0)."""
# Control socket first: once shutdown begins we are no longer a truthful "serving here" answer and a
# successor must be able to bind. Early-exit paths rely on the atexit cleanup_files hook instead.
if _control_server is not None:
try:
await _control_server.stop()
except Exception:
logger.debug("Control socket stop failed (non-fatal)", exc_info=True)
def _stop_keepalive() -> None:
from hermes_cli.nous_auth_keepalive import stop_nous_auth_keepalive
stop_nous_auth_keepalive()
_best_effort(_stop_keepalive)
# Never join(): an in-flight cron delivery is a coroutine on THIS loop; a sync join would drop it.
# Stop cron scheduler + housekeeping cleanly. These MUST be awaited cooperatively, not join()ed. A cron
# delivery in flight when the gateway restarts is a coroutine scheduled onto THIS event loop
# (safe_schedule_threadsafe); the ticker thread is blocked on its future.result(). A synchronous
# cron_thread.join() would block the loop, so that delivery could never run — it timed out and the
# message was silently dropped (#58818). Awaiting keeps the loop alive so the in-flight delivery
# finishes before we tear down.
cron_stop.set()
_stop_cron_provider(cron_provider)
if not await _await_thread_exit(cron_thread, timeout=_CRON_SHUTDOWN_DRAIN_TIMEOUT):
logger.warning("Cron ticker did not exit within %.0fs of shutdown — an in-flight "
"delivery may have been dropped.", _CRON_SHUTDOWN_DRAIN_TIMEOUT)
await _await_thread_exit(housekeeping_thread, timeout=_HOUSEKEEPING_SHUTDOWN_DRAIN_TIMEOUT)
# Stop the planned-stop watcher (daemon=True so this is belt-and-suspenders).
_planned_stop_watcher_stop.set()
_planned_stop_watcher_thread.join(timeout=2)
with suppress(Exception):
await _shutdown_mcp_servers_nonblocking()
# The failure verdict comes AFTER the cooperative teardown: returning early here leaked the
# cron ticker + housekeeping threads (and open MCP connections) for embedded callers (#12175).
return _resolve_gateway_exit_verdict(runner, _signal_initiated_shutdown[0])
async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool = False, verbosity: Optional[int] = 0) -> bool:
"""Start the gateway and run until interrupted; False if it failed to start (non-zero exit so
systemd can auto-restart). ``replace`` kills any existing instance first (avoids restart-loop deadlocks)."""
# Set here (not at import) so incidental gateway.run imports from CLI code don't poison it.
os.environ["HERMES_EXEC_ASK"] = "1"
from hermes_cli.resource_limits import apply_nofile_soft_limit
apply_nofile_soft_limit()
# Snapshot the revision while sys.modules matches disk so a later `git pull` is detected safely.
from gateway.code_skew import record_boot_fingerprint
record_boot_fingerprint()
# Duplicate-instance guard scoped to HERMES_HOME; distinct-home multi-profile setups coexist.
from gateway.status import get_running_pid
existing_pid = get_running_pid()
if (existing_pid is not None and existing_pid != os.getpid()
and not await _start_gateway_replace_existing_instance(existing_pid, replace)):
return False
_start_gateway_configure_logging(verbosity)
runner = GatewayRunner(config)
# Multiplex: swap the launch-home file handlers for per-profile routers so each profile's records
# land in its own logs/. Must run after the runner resolved (possibly None) config and setup_logging.
# See #82936.
_enable_multiplex_log_routing(runner.config)
# ``--replace`` is explicit startup authority, not a durable reconnect policy: GatewayRunner scopes
# it to cold adapter connects and clears it before the background reconnect watcher starts.
runner._platform_lock_takeover_on_start = bool(replace)
# Unexpected signals exit non-zero so service managers revive us; planned stops write a marker first.
_signal_initiated_shutdown = [False]
shutdown_signal_handler = _start_gateway_make_shutdown_signal_handler(
runner, _signal_initiated_shutdown)
restart_signal_handler = _start_gateway_make_restart_signal_handler(runner)
loop = asyncio.get_running_loop()
# Swallow transient network errors from background tasks; one unhandled httpx error would kill us.
# Issues #31066 / #31110: an unhandled ``telegram.error.TimedOut`` (or peer NetworkError / httpx
# connection error) in any awaited coroutine would propagate to the loop and kill the gateway process,
# taking down every profile attached to the same runner. systemd then restarts the service after ~5s but
# the active conversation turn is lost. The fix is intentionally narrow: only well-known transient
# network errors are swallowed (and logged with full traceback so the originating call site is still
# discoverable). Anything else is forwarded to the default handler so real bugs still surface.
loop.set_exception_handler(_gateway_loop_exception_handler)
if threading.current_thread() is threading.main_thread():
# add_signal_handler raises NotImplementedError on Windows; SIGUSR1 is POSIX-only.
handlers = [(sig, shutdown_signal_handler, (sig,)) for sig in (signal.SIGINT, signal.SIGTERM)]
if hasattr(signal, "SIGUSR1"):
handlers.append((signal.SIGUSR1, restart_signal_handler, ())) # windows-footgun: ok — hasattr-guarded
for sig, handler, args in handlers:
with suppress(NotImplementedError):
loop.add_signal_handler(sig, handler, *args) # windows-footgun: ok — suppress(NotImplementedError)
else:
logger.info("Skipping signal handlers (not running in main thread).")
# Windows has no add_signal_handler, so `hermes gateway stop`'s SIGTERM would never drain; poll the
# planned-stop marker (written BEFORE the kill) instead. Runs everywhere so masked-SIGTERM drains.
# Windows fallback: asyncio.add_signal_handler raises NotImplementedError on Windows, so `hermes gateway
# stop`'s SIGTERM (which Python maps to TerminateProcess on Windows) never invokes
# shutdown_signal_handler. That means the drain loop never runs, mark_resume_pending never fires, and
# sessions are silently lost across restarts (issue #33778). The fix is a marker-polling thread: `hermes
# gateway stop` writes the planned-stop marker BEFORE killing, and this thread notices it and drives the
# same shutdown path the signal handler would have. Runs on every platform (cheap, defensive) so
# non-signal-bearing environments (Windows native, sandboxed CI runners that mask SIGTERM) still get a
# clean drain.
_planned_stop_watcher_stop = threading.Event()
_planned_stop_watcher_thread = threading.Thread(
target=_run_planned_stop_watcher,
args=(_planned_stop_watcher_stop, runner, loop, shutdown_signal_handler), daemon=True,
name="planned-stop-watcher")
_planned_stop_watcher_thread.start()
# PID file BEFORE adapters: of two concurrent `run --replace`, only the O_EXCL winner opens sockets.
if not _start_gateway_claim_pid_file():
return False
# Right after the PID claim (which makes us authoritative); non-fatal — consumers fall back to scan.
_control_server = await _start_gateway_start_control_socket(runner)
def _lifecycle_record_startup() -> None:
# Report if the previous life died uncleanly (SIGKILL / OOM / VM death), then claim the
# sentinel for this life. After the PID-file claim so a --replace loser can't clobber it.
from gateway.lifecycle_ledger import record_startup
record_startup()
def _start_keepalive() -> None:
from hermes_cli.nous_auth_keepalive import start_nous_auth_keepalive
start_nous_auth_keepalive()
_best_effort(_lifecycle_record_startup, "Lifecycle ledger startup record failed: %s")
_best_effort(_start_keepalive, "Nous auth keepalive did not start: %s")
_ensure_windows_gateway_venv_imports()
# discover_mcp_tools() blocks up to 120s; on the loop thread it would freeze platform heartbeats.
try:
# MCP tool discovery — run in an executor so the asyncio event loop stays responsive even when a
# configured MCP server is slow or unreachable. discover_mcp_tools() uses a blocking 120s wait
# internally; calling it from the loop thread would freeze platform heartbeats (Discord shard,
# Telegram polling) until it returned. See #16856.
await _discover_gateway_mcp_tools(runner.config)
except Exception as e:
logger.debug("MCP tool discovery failed: %s", e)
try:
success = await runner.start()
except BaseException:
_shutdown_gateway_health_export(runner)
raise
if not success:
_shutdown_gateway_health_export(runner)
return False
def _recover_pending() -> None:
from gateway.shutdown_flush import recover_pending_to_db
recovered = recover_pending_to_db(
session_resolver=runner.session_store.resolve_session_id_for_key,
)
if recovered:
logger.info("Recovered %d pending message(s) from shutdown flush", recovered)
_best_effort(_recover_pending)
if runner.should_exit_cleanly:
_shutdown_gateway_health_export(runner)
if runner.exit_reason:
logger.error("Gateway exiting cleanly: %s", runner.exit_reason)
# Explicit exit codes (GATEWAY_FATAL_CONFIG_EXIT_CODE) must propagate so s6 finish maps 78 → 125.
if runner.exit_code is not None:
raise SystemExit(runner.exit_code)
return True
if not runner._running:
# Startup aborted by restart/shutdown before running mode; preserve that path without starting cron.
try:
await runner.wait_for_shutdown()
with suppress(Exception):
await _shutdown_mcp_servers_nonblocking()
return _resolve_gateway_exit_verdict(runner, _signal_initiated_shutdown[0])
finally:
_shutdown_gateway_health_export(runner)
cron_stop, cron_provider, cron_thread, housekeeping_thread = (
_start_gateway_start_cron_and_housekeeping(runner))
# READY only once adapters, cron and housekeeping run; missing systemd state just disables watchdog.
runner._start_systemd_watchdog()
await runner.wait_for_shutdown()
return await _start_gateway_shutdown_tail(
runner, _control_server, cron_stop, cron_provider, cron_thread, housekeeping_thread,
_planned_stop_watcher_stop, _planned_stop_watcher_thread, _signal_initiated_shutdown)
def _guard_corrupt_user_config() -> None:
"""Fail closed when the active profile's config.yaml cannot be parsed: nobody can repair it on this
surface, and defaults would let provider auto-detection adopt ``.env`` credentials the config never
named. Same policy and escape hatch (``HERMES_IGNORE_USER_CONFIG=1``) as ``hermes_cli/main.py``."""
from hermes_cli.config import InvalidUserConfigError, require_parseable_user_config
try:
require_parseable_user_config()
except InvalidUserConfigError as exc:
print(f"Error: {exc}", file=sys.stderr)
raise SystemExit(2) from exc
def main():
"""CLI entry point for the gateway."""
# Before any config-dependent startup (watchdog, DB opens, provider resolution).
_guard_corrupt_user_config()
# Advertise the harness to children (mirrors _advertise_agent_env in hermes_cli/main.py, inlined to
# avoid its startup side effects). Value must equal registry id ``hermes-agent`` exactly.
os.environ.setdefault("AI_AGENT", "hermes-agent")
os.environ.setdefault("HERMES_AGENT", "true")
def _register_identity() -> None:
# Ledger registration + Windows job-object attach so update-time reapers can identify this gateway.
from hermes_cli.process_identity import attach_self_to_kill_on_close_job, register_self
register_self("gateway")
attach_self_to_kill_on_close_job()
def _arm_watchdog() -> None:
# Armed before config load / DB opens so a pre-loop deadlock is respawned by the supervisor instead
# of wedging as a live-PID zombie. GatewayRunner disarms it.
from hermes_startup_watchdog import arm_startup_watchdog
arm_startup_watchdog()
def _utf8_stdio() -> None:
# Windows: gateway logs and banner would UnicodeEncodeError on cp1252 consoles. No-op on POSIX.
from hermes_cli.stdio import configure_windows_stdio
configure_windows_stdio()
for _step in (_register_identity, _arm_watchdog, _utf8_stdio):
_best_effort(_step)
import argparse
parser = argparse.ArgumentParser(description="Hermes Gateway - Multi-platform messaging")
parser.add_argument("--config", "-c", help="Path to gateway config file")
parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output")
args = parser.parse_args()
config = None
if args.config:
import yaml
with open(args.config, encoding="utf-8") as f:
config = GatewayConfig.from_dict(yaml.safe_load(f) or {})
# Same boot-time verdict the loaded config gets when the file leaves the flag unset.
from hermes_cli.gateway_multiplex_mode import log_multiplex_decision, resolve_multiplex_mode
log_multiplex_decision(resolve_multiplex_mode(config))
# start_gateway() completes teardown before returning/raising SystemExit; force-exit after so a
# wedged non-daemon worker can't block Py_FinalizeEx's join. SystemExit caught so EVERY path exits.
try:
# start_gateway() performs the full graceful teardown (adapters disconnected, sessions saved +
# flushed, SQLite closed, cron/MCP stopped, PID file + runtime lock released) before it returns OR
# raises SystemExit with an explicit code. Force-exit afterwards so a wedged non-daemon worker
# thread (e.g. a ThreadPoolExecutor tool/LLM call blocked with no timeout) cannot block interpreter
# finalization (Py_FinalizeEx joins all non-daemon threads, incl. concurrent.futures' _python_exit)
# and strand the gateway half-shut down with the supervisor unable to restart it (#53107).
# SystemExit is caught explicitly: start_gateway raises it on the clean-fatal-config (#51228),
# planned-restart, and service-restart paths, all of which complete teardown first. Routing those
# codes through the same os._exit backstop means EVERY exit path is wedge-proof, not just the
# boolean-return ones.
success = asyncio.run(start_gateway(config))
exit_code = 0 if success else 1
except SystemExit as e:
# e.code may be None (→ 0), an int, or a str (→ 1, like CPython).
exit_code = 0 if e.code is None else e.code if isinstance(e.code, int) else 1
_exit_after_graceful_shutdown(exit_code)
def _exit_after_graceful_shutdown(exit_code: int) -> None:
"""Flush stdio, release the PID file + runtime lock, then hard-exit.
``os._exit`` (not ``sys.exit``): SystemExit runs ``Py_FinalizeEx``, which joins every non-daemon
thread — exactly the hang a wedged worker causes. It bypasses ``atexit``, so PID/lock release and the
bounded log drain (file handlers sit behind a ``QueueListener`` thread) are done here explicitly.
Graceful teardown is already complete by the time this runs, so there is nothing left that needs a clean
interpreter shutdown. See #53107.
``os._exit`` bypasses ``atexit`` handlers, so we cannot rely on the ``atexit``-registered
``remove_pid_file`` / ``release_gateway_runtime_lock`` (registered in ``start_gateway``) to run. The
full-shutdown path releases both explicitly in ``_stop_impl``, but the EARLY exit paths —
clean-fatal-config (#51228) and startup-aborted-before-running — raise ``SystemExit`` right after
``runner.start()`` without going through ``_stop_impl``, so on those paths ``atexit`` was the only thing
releasing them. Now that those paths are routed through this backstop (#53107), release both here
explicitly. Both calls are idempotent — ``remove_pid_file`` only unlinks a PID file that belongs to this
process, and ``release_gateway_runtime_lock`` no-ops when the lock is already released — so this is a
no-op on the normal shutdown path and the actual cleanup on the early-exit paths.
"""
for stream in (sys.stdout, sys.stderr):
with suppress(Exception):
stream.flush()
def _release_locks() -> None:
# BEFORE the log drain (bounded, but could take its full timeout on a wedged disk); idempotent.
from gateway.status import remove_pid_file, release_gateway_runtime_lock
remove_pid_file()
release_gateway_runtime_lock()
def _mark_exited() -> None:
# Single funnel every graceful exit passes through, so the next boot's unclean-death detector
# fires only for genuine SIGKILL/OOM/VM deaths. Ownership-guarded against an old --replace life.
from gateway.lifecycle_ledger import mark_exited
mark_exited(exit_code, reason="graceful_shutdown")
def _drain_logs() -> None:
# os._exit bypasses the listener's atexit drain. Bounded, no restart — NOT flush_log_queue():
# a listener wedged on the rotation lock would re-freeze shutdown in an unbounded stop() join.
from hermes_logging import drain_log_queue
drain_log_queue(timeout=1.0)
for _step in (_release_locks, _mark_exited, _drain_logs):
_best_effort(_step)
os._exit(exit_code)
if __name__ == "__main__":
main()
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from typing import Awaitable # noqa: F401,E402
from contextvars import Context # noqa: F401,E402
from typing import Union # noqa: F401,E402
import faulthandler # noqa: F401,E402
import functools # noqa: F401,E402
import inspect # noqa: F401,E402
from dotenv import load_dotenv # noqa: F401,E402
import queue # noqa: F401,E402
from datetime import timedelta # noqa: F401,E402
from datetime import timezone # noqa: F401,E402
_PLUGIN_COMPAT_LAZY = {
'DEFAULT_GATEWAY_POST_INTERRUPT_GRACE_TIMEOUT': ('gateway.restart', 'DEFAULT_GATEWAY_POST_INTERRUPT_GRACE_TIMEOUT'),
'DEFAULT_HEARTBEAT_INTERVAL_S': ('gateway.shutdown_watchdog', 'DEFAULT_HEARTBEAT_INTERVAL_S'),
'DEFAULT_LEASE_WAIT': ('gateway.turn_lease', 'DEFAULT_LEASE_WAIT'),
'DEFAULT_LOOP_WATCHDOG_INTERVAL_S': ('gateway.shutdown_watchdog', 'DEFAULT_LOOP_WATCHDOG_INTERVAL_S'),
'DEFAULT_LOOP_WATCHDOG_MAX_STRIKES': ('gateway.shutdown_watchdog', 'DEFAULT_LOOP_WATCHDOG_MAX_STRIKES'),
'DEFAULT_LOOP_WATCHDOG_TIMEOUT_S': ('gateway.shutdown_watchdog', 'DEFAULT_LOOP_WATCHDOG_TIMEOUT_S'),
'EphemeralReply': ('gateway.platforms.base', 'EphemeralReply'),
'GATEWAY_FATAL_CONFIG_EXIT_CODE': ('gateway.restart', 'GATEWAY_FATAL_CONFIG_EXIT_CODE'),
'GATEWAY_SERVICE_RESTART_EXIT_CODE': ('gateway.restart', 'GATEWAY_SERVICE_RESTART_EXIT_CODE'),
'SessionEntry': ('gateway.session', 'SessionEntry'),
'TranscriptReadError': ('gateway.session_transcript', 'TranscriptReadError'),
'TurnContext': ('gateway.turn_context', 'TurnContext'),
'TurnLeaseTimeoutError': ('gateway.turn_lease', 'TurnLeaseTimeoutError'),
'TurnRunner': ('gateway.run_turn_runner', 'TurnRunner'),
'arm_shutdown_watchdog': ('gateway.shutdown_watchdog', 'arm_shutdown_watchdog'),
'atomic_json_write': ('utils', 'atomic_json_write'),
'base_url_hostname': ('utils', 'base_url_hostname'),
'build_auto_tts_output_path': ('gateway.platforms.base', 'build_auto_tts_output_path'),
'build_channel_continuity_note': ('gateway.session', 'build_channel_continuity_note'),
'build_session_context': ('gateway.session', 'build_session_context'),
'build_session_context_prompt': ('gateway.session', 'build_session_context_prompt'),
'consume_detached_task_result': ('agent.async_utils', 'consume_detached_task_result'),
'is_global_startup_conflict': ('gateway.restart', 'is_global_startup_conflict'),
'is_shared_multi_user_session': ('gateway.session', 'is_shared_multi_user_session'),
'is_truthy_value': ('utils', 'is_truthy_value'),
'looks_like_telegram_private_chat_id': ('gateway.delivery', 'looks_like_telegram_private_chat_id'),
'loop_heartbeat_forever': ('gateway.shutdown_watchdog', 'loop_heartbeat_forever'),
'merge_pending_message_event': ('gateway.platforms.base', 'merge_pending_message_event'),
'neutralize_untrusted_inline_text': ('gateway.session', 'neutralize_untrusted_inline_text'),
'parse_cron_drain_timeout': ('gateway.restart', 'parse_cron_drain_timeout'),
'parse_restart_after_turn_timeout': ('gateway.restart', 'parse_restart_after_turn_timeout'),
'parse_restart_drain_timeout': ('gateway.restart', 'parse_restart_drain_timeout'),
'parse_signal_interrupt_grace_timeout': ('gateway.restart', 'parse_signal_interrupt_grace_timeout'),
'project_compaction_message_for_display': ('agent.compaction_display', 'project_compaction_message_for_display'),
'repair_explicit_computer_use_media_paths': ('gateway.media_repair', 'repair_explicit_computer_use_media_paths'),
'resolve_cron_drain_budget': ('gateway.restart', 'resolve_cron_drain_budget'),
'resolve_delivery_transport': ('gateway.delivery', 'resolve_delivery_transport'),
'resolve_shutdown_watchdog_delay': ('gateway.shutdown_watchdog', 'resolve_shutdown_watchdog_delay'),
'start_loop_liveness_watchdog': ('gateway.shutdown_watchdog', 'start_loop_liveness_watchdog'),
't': ('agent.i18n', 't'),
'utf16_len': ('gateway.platforms.base', 'utf16_len'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----