Files
hermes-agent/gateway/config.py
T
Teknium 2776813df3 compat(plugins): temporary import-path shims for external plugins — ONE commit, revert on schedule
The Sep 2026 decomposition (PR #102117) makes internal import paths a non-API: names now live in
the focused modules that define them. This commit is the ONLY thing keeping the old paths alive,
so external plugins have time to update. It is deliberately a single, unsquashed commit:

    git revert <this sha>

removes every shim, stub and manifest at once on the announced date. Nothing in-tree may depend on
these pointers: scripts/check_compat_pointers.py (wired into lint.yml) fails CI if it does.

What it adds (see COMPAT_MANIFEST.md, compat_manifest.json):
- 332 facade modules get one delimited `PLUGIN-COMPAT` block appended at the end of the file
- 1,172 moved names resolved lazily via a module `__getattr__` (PEP 562) — never a top-level import,
  so no import cycles; facades that already had `__getattr__` get a chained one
- 592 third-party/stdlib names the old modules used to expose, with their original import statements
- 266 public definitions that had been deleted as unused, restored byte-for-byte from the pre-decomposition
  tree (+40 private helpers and 16 imports pulled in only because a restored definition needs them)
- 3 deleted modules recreated as re-export stubs (gateway/startup_watchdog, hermes_cli/observability/
  relay_runtime, tools/environments/modal_utils)
- private names (`_x`) get no pointer: they were never API (3,792 skipped)

Verified: all 335 touched modules import under a fresh HERMES_HOME and every manifest name resolves;
the lint reports zero in-tree uses; ruff clean; targeted suites unchanged.
2026-09-03 17:13:22 -07:00

874 lines
43 KiB
Python

"""Gateway configuration: connected platforms, home channels, session reset
policies and delivery preferences, loaded from config.yaml / gateway.json / env.
"""
import contextlib
import logging
import math
import os
from pathlib import Path
from dataclasses import asdict, dataclass, field, fields, is_dataclass
from typing import Dict, List, Optional, Any, Callable
from enum import Enum
from hermes_cli.config import get_hermes_home
from agent.secret_scope import current_secret_scope, get_secret as _get_secret
from gateway.shutdown_watchdog import (
DEFAULT_LOOP_WATCHDOG_INTERVAL_S,
DEFAULT_LOOP_WATCHDOG_MAX_STRIKES,
DEFAULT_LOOP_WATCHDOG_TIMEOUT_S,
)
from utils import is_truthy_value
logger = logging.getLogger(__name__)
_TRUTHY_STRINGS = frozenset({"1", "true", "yes", "on"})
_FALSY_STRINGS = frozenset({"0", "false", "no", "off"})
def _bool_token(value: Any) -> Optional[bool]:
"""True/False for a recognized truthy/falsy token, else None."""
token = str(value).strip().lower()
return True if token in _TRUTHY_STRINGS else False if token in _FALSY_STRINGS else None
def _coerce_bool(value: Any, default: bool = True) -> bool:
"""Coerce bool-ish config values, preserving a caller-provided default."""
if value is None:
return default
if isinstance(value, str):
parsed = _bool_token(value)
return default if parsed is None else parsed
return is_truthy_value(value, default=default)
def _normalize_multiplex_profile_allowlist(value: Any) -> Optional[List[str]]:
"""Normalize the optional named-profile allowlist: ``None`` = serve all; a malformed
outer value fails safe to ``[]`` (default profile only); bad entries are skipped."""
if value is None:
return None
if not isinstance(value, list):
logger.warning(
"Invalid gateway.multiplex_profile_allowlist (expected a list, got %s); "
"serving only the default profile",
type(value).__name__,
)
return []
from hermes_cli.profiles import normalize_profile_name, validate_profile_name
normalized: List[str] = []
for entry in value:
if not isinstance(entry, str):
logger.warning("Skipping invalid gateway.multiplex_profile_allowlist entry %r (expected a profile name)", entry)
continue
try:
name = normalize_profile_name(entry)
validate_profile_name(name)
except ValueError:
logger.warning("Skipping invalid gateway.multiplex_profile_allowlist entry %r", entry)
continue
if name != "default" and name not in normalized:
normalized.append(name)
return normalized
def _env_multiplex_profiles_override() -> "bool | None":
"""GATEWAY_MULTIPLEX_PROFILES operator override: True/False for a recognized token.
``None`` when unset, blank, or unrecognized so the caller keeps the config.yaml
value (env > config > default). Blank is deliberately ``None``, not ``False``:
a provisioned-but-unpopulated Fly secret arrives as ``""`` and must NOT shadow
a config.yaml opt-in.
"""
raw = os.getenv("GATEWAY_MULTIPLEX_PROFILES")
if not (raw or "").strip():
return None
parsed = _bool_token(raw)
if parsed is None:
logger.warning(
"Ignoring unrecognized GATEWAY_MULTIPLEX_PROFILES=%r "
"(expected one of %s or %s); falling back to config.yaml.",
raw, sorted(_TRUTHY_STRINGS), sorted(_FALSY_STRINGS),
)
return parsed
def _normalize_transport_token(value: Any) -> str:
"""Canonical streaming transport token. YAML 1.1 parses bare ``on``/``off`` as
booleans (``mode: off`` → ``False`` → ``"false"`` would ENABLE streaming), so
booleans map to ``"auto"``/``"off"``; anything else lower-cases, default ``"auto"``."""
if value is None:
return "auto"
if isinstance(value, bool):
return "auto" if value else "off"
return str(value).strip().lower() or "auto"
def _coerce_num(cast, value: Any, default):
# OverflowError: ``int(float("inf"))`` — non-finite YAML must degrade, not abort loading.
try:
return default if value is None else cast(value)
except (TypeError, ValueError, OverflowError):
return default
def _coerce_float(value: Any, default: float) -> float:
return _coerce_num(float, value, default)
def _coerce_int(value: Any, default: int) -> int:
return _coerce_num(int, value, default)
def _coerce_optional_positive_int(value: Any, key: str) -> Optional[int]:
"""``None``/0/negative disable; malformed values are ignored with a warning so a typo never blocks startup."""
if value is None:
return None
try:
if isinstance(value, bool) or (isinstance(value, float) and not value.is_integer()):
raise ValueError(value)
parsed = int(value.strip(), 10) if isinstance(value, str) else int(value)
except (TypeError, ValueError):
logger.warning("Ignoring invalid %s=%r (expected a positive integer; 0/null disables)", key, value)
return None
return parsed if parsed > 0 else None
_SYSTEMD_WATCHDOG_MAX_SECONDS = 2_147_483_647
def coerce_systemd_watchdog_seconds(
value: Any, key: str = "gateway.systemd_watchdog_seconds"
) -> int:
"""Bounded positive watchdog interval, or zero when disabled/invalid. Shared by runtime
and service generation so a value can never enable ``Type=notify`` without heartbeats."""
if value is None:
return 0
parsed: Optional[int] = None
if isinstance(value, int) and not isinstance(value, bool):
parsed = value
elif isinstance(value, str) and value.strip().isascii() and value.strip().isdecimal():
with contextlib.suppress(TypeError, ValueError, OverflowError): # int() digit limit
parsed = int(value.strip(), 10)
if parsed is None:
logger.warning("Ignoring invalid %s (expected a positive integer)", key)
return 0
if parsed and not 0 < parsed <= _SYSTEMD_WATCHDOG_MAX_SECONDS:
logger.warning("Ignoring invalid %s (expected an integer from 1 to %d)", key, _SYSTEMD_WATCHDOG_MAX_SECONDS)
return 0
return parsed
def _coerce_dict(value: Any) -> Dict[str, Any]:
return value if isinstance(value, dict) else {}
def _normalize_choice(value: Any, choices: set, default: str) -> str:
"""Lower-cased *value* when it is one of *choices*, else *default*."""
normalized = value.strip().lower() if isinstance(value, str) else None
return normalized if normalized in choices else default
def _dict_slot(container: dict, key: str) -> dict:
"""Get-or-create ``container[key]`` as a dict, replacing a non-dict value with ``{}``."""
value = container.setdefault(key, {})
if not isinstance(value, dict):
value = {}
container[key] = value
return value
def _getenv(name: str, default: Optional[str] = None) -> Optional[str]:
"""Env read through the active profile secret scope when present (multiplexed
per-profile secrets must win); otherwise legacy ``os.getenv``."""
if current_secret_scope() is not None:
scope_val = _get_secret(name, None)
return scope_val if scope_val is not None else default
return os.environ.get(name, default)
def _getenv_str(name: str, default: str = "") -> str:
return val if (val := _getenv(name, default)) is not None else default
_Platform__bundled_plugin_names: Optional[set] = None # cached outside the enum: never a member
class Platform(Enum):
"""Supported messaging platforms. Plugin platforms are dynamic members created on
demand by ``_missing_`` and cached so ``Platform("irc") is Platform("irc")`` holds."""
LOCAL = "local"
TELEGRAM = "telegram"
DISCORD = "discord"
WHATSAPP = "whatsapp"
WHATSAPP_CLOUD = "whatsapp_cloud"
SLACK = "slack"
SIGNAL = "signal"
MATTERMOST = "mattermost"
MATRIX = "matrix"
HOMEASSISTANT = "homeassistant"
EMAIL = "email"
SMS = "sms"
DINGTALK = "dingtalk"
API_SERVER = "api_server"
WEBHOOK = "webhook"
MSGRAPH_WEBHOOK = "msgraph_webhook"
FEISHU = "feishu"
WECOM = "wecom"
WECOM_CALLBACK = "wecom_callback"
WEIXIN = "weixin"
BLUEBUBBLES = "bluebubbles"
QQBOT = "qqbot"
YUANBAO = "yuanbao"
RELAY = "relay" # generic relay adapter fronted by the connector (EXPERIMENTAL)
@classmethod
def _missing_(cls, value):
"""Accept unknown names only for bundled or runtime-registered plugin adapters (no enum pollution)."""
if not isinstance(value, str) or not value.strip():
return None
value = value.strip().lower()
if value in cls._value2member_map_:
return cls._value2member_map_[value]
global _Platform__bundled_plugin_names
if _Platform__bundled_plugin_names is None:
_Platform__bundled_plugin_names = cls._scan_bundled_plugin_platforms()
registered = value in _Platform__bundled_plugin_names
if not registered:
with contextlib.suppress(Exception):
from gateway.platform_registry import platform_registry
registered = platform_registry.is_registered(value)
return cls._add_pseudo_member(value) if registered else None
@classmethod
def _add_pseudo_member(cls, value: str) -> "Platform":
pseudo = object.__new__(cls)
pseudo._value_ = value
pseudo._name_ = value.upper().replace("-", "_").replace(" ", "_")
cls._value2member_map_[value] = pseudo
cls._member_map_[pseudo._name_] = pseudo
return pseudo
@classmethod
def _scan_bundled_plugin_platforms(cls) -> set:
"""Names of bundled platform plugins under ``plugins/platforms/``."""
try:
platforms_dir = Path(__file__).parent.parent / "plugins" / "platforms"
return {
child.name.lower()
for child in (platforms_dir.iterdir() if platforms_dir.is_dir() else ())
if child.is_dir() and (child / "__init__.py").exists()
and ((child / "plugin.yaml").exists() or (child / "plugin.yml").exists())
}
except Exception:
return set()
# Built-in values snapshotted before any dynamic _missing_ lookup.
_BUILTIN_PLATFORM_VALUES = frozenset(m.value for m in Platform.__members__.values())
# Platforms that bind a host TCP port. In a multiplexer only the default profile owns the
# shared listener, so a SECONDARY profile enabling one is a misconfiguration (single source
# of truth for gateway/run.py and hermes_cli/web_server.py validation).
PORT_BINDING_PLATFORM_VALUES = frozenset({
"webhook", "api_server", "msgraph_webhook", "feishu", "wecom_callback",
"bluebubbles", "sms", "whatsapp_cloud", "line", "teams",
})
# Platforms that only bind in one connection mode (Feishu's default websocket mode is outbound).
PORT_BINDING_CONDITIONAL_MODES: dict[str, str] = {"feishu": "webhook"}
def platform_binds_port(platform_value: str, extra: Optional[dict] = None) -> bool:
"""True when *platform_value* actually binds a port for *extra* config."""
if platform_value not in PORT_BINDING_PLATFORM_VALUES:
return False
expected_mode = PORT_BINDING_CONDITIONAL_MODES.get(platform_value)
return expected_mode is None or str((extra or {}).get("connection_mode", "websocket")).strip().lower() == expected_mode
@dataclass
class HomeChannel:
"""Default destination for a platform (``deliver="telegram"`` without a chat ID);
``thread_id`` routes the bare target to the topic where /sethome was run."""
platform: Platform
chat_id: str
name: str
thread_id: Optional[str] = None
# Authenticated logical-target provenance (relay egress re-attaches; connector stays the authz boundary).
user_id: Optional[str] = None
scope_id: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
optional = {k: v for k in ("thread_id", "user_id", "scope_id") if (v := getattr(self, k))}
return {"platform": self.platform.value, "chat_id": self.chat_id, "name": self.name, **optional}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "HomeChannel":
optional = {k: str(data[k]) if data.get(k) else None for k in ("thread_id", "user_id", "scope_id")}
return cls(platform=Platform(data["platform"]), chat_id=str(data["chat_id"]), name=data.get("name", "Home"), **optional)
def persist_home_channel(home: HomeChannel, *, enabled_if_new: bool = False) -> None:
"""Persist a logical home without falsely enabling a Relay-fronted adapter."""
from hermes_cli.config import load_config, save_config
config = load_config()
platform_config = _dict_slot(_dict_slot(config, "platforms"), home.platform.value)
if enabled_if_new:
platform_config.setdefault("enabled", True)
platform_config["home_channel"] = home.to_dict()
save_config(config)
@dataclass
class SessionResetPolicy:
"""When sessions reset: "daily" (at ``at_hour``), "idle" (after ``idle_minutes``),
"both" (whichever first), "none" (default: only compression manages context)."""
mode: str = "none"
at_hour: int = 4 # 0-23, local time
idle_minutes: int = 1440
notify: bool = True # Notify the user when auto-reset occurs
notify_exclude_platforms: tuple = ("api_server", "webhook")
# A background process this old no longer blocks reset (not killed, only ignored by the guard).
# A forgotten preview server should not keep a session alive forever (#29177). Raise this if you run
# legitimate multi-day jobs whose liveness should pin the conversation open.
bg_process_max_age_hours: int = 24
def to_dict(self) -> Dict[str, Any]:
return {**asdict(self), "notify_exclude_platforms": list(self.notify_exclude_platforms)}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "SessionResetPolicy":
data = _coerce_dict(data)
exclude = data.get("notify_exclude_platforms")
# Missing keys and explicit YAML nulls both take the field default.
plain = {
f.name: f.default if data.get(f.name) is None else data[f.name]
for f in fields(cls) if f.name not in ("notify", "notify_exclude_platforms")
}
return cls(
notify=_coerce_bool(data.get("notify"), True),
notify_exclude_platforms=tuple(exclude) if exclude is not None else ("api_server", "webhook"),
**plain,
)
@dataclass
class ChannelOverride:
"""Per-channel model/provider/system_prompt override (``platforms.<name>.channel_overrides[channel_id]``)."""
model: Optional[str] = None
provider: Optional[str] = None
system_prompt: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
return {k: v for k, v in asdict(self).items() if v is not None}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "ChannelOverride":
return cls(**{f.name: data.get(f.name) for f in fields(cls)}) if data else cls()
# Platforms whose primary credential is ``PlatformConfig.token`` → its env var (empty-token
# warnings; multiplex primary-startup gate in ``gateway.run``). Platforms absent here
# authenticate another way and must never be skipped for a missing token.
# Platforms absent from this map authenticate some other way (session files, port-bound webhooks,
# api_key-only) and must never be skipped for a missing token. See #64674.
PLATFORM_TOKEN_ENV_NAMES: dict["Platform", str] = {
Platform.TELEGRAM: "TELEGRAM_BOT_TOKEN",
Platform.DISCORD: "DISCORD_BOT_TOKEN",
Platform.SLACK: "SLACK_BOT_TOKEN",
Platform.MATTERMOST: "MATTERMOST_TOKEN",
Platform.MATRIX: "MATRIX_ACCESS_TOKEN",
Platform.WEIXIN: "WEIXIN_TOKEN",
}
@dataclass
class PlatformConfig:
"""Configuration for a single messaging platform."""
enabled: bool = False
token: Optional[str] = None
api_key: Optional[str] = None # API key if different from token
home_channel: Optional[HomeChannel] = None
reply_to_mode: str = "first" # "off" never threads, "first" only the first chunk, "all" every chunk
gateway_restart_notification: bool = True # "♻️ Gateway online/restarted" pings; noise on end-user platforms
typing_indicator: bool = True # drives _keep_typing; False where unwanted (Slack setStatus blocks compose)
# Working-state text for text-rendering indicators (Slack status, Google Chat marker); None = platform default.
typing_status_text: Optional[str] = None
channel_overrides: Dict[str, ChannelOverride] = field(default_factory=dict)
extra: Dict[str, Any] = field(default_factory=dict) # Platform-specific settings
def to_dict(self) -> Dict[str, Any]:
result = {
"enabled": self.enabled, "extra": self.extra, "reply_to_mode": self.reply_to_mode,
"gateway_restart_notification": self.gateway_restart_notification,
"typing_indicator": self.typing_indicator,
**({"typing_status_text": self.typing_status_text} if self.typing_status_text is not None else {}),
**{k: v for k in ("token", "api_key") if (v := getattr(self, k))},
}
if self.home_channel:
result["home_channel"] = self.home_channel.to_dict()
if self.channel_overrides:
result["channel_overrides"] = {cid: ov.to_dict() for cid, ov in self.channel_overrides.items()}
return result
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "PlatformConfig":
data = _coerce_dict(data)
home = data.get("home_channel")
# The typing/restart-notification keys may be top-level or bridged into ``extra``; top-level wins.
extra = _coerce_dict(data.get("extra", {}))
def toplevel_or_extra(key: str) -> Any:
value = data.get(key)
return extra.get(key) if value is None else value
raw_overrides = data.get("channel_overrides") or {}
channel_overrides = {
str(cid): ChannelOverride.from_dict(ov_data)
for cid, ov_data in raw_overrides.items()
if isinstance(ov_data, dict)
} if isinstance(raw_overrides, dict) else {}
return cls(
enabled=_coerce_bool(data.get("enabled"), False),
token=data.get("token"),
api_key=data.get("api_key"),
home_channel=HomeChannel.from_dict(home) if isinstance(home, dict) else None,
reply_to_mode=data.get("reply_to_mode", "first"),
gateway_restart_notification=_coerce_bool(toplevel_or_extra("gateway_restart_notification"), True),
typing_indicator=_coerce_bool(toplevel_or_extra("typing_indicator"), True),
typing_status_text=toplevel_or_extra("typing_status_text"), # string passthrough, no coercion
channel_overrides=channel_overrides,
extra=extra,
)
# Shared by StreamingConfig and StreamConsumerConfig. Tuned for Telegram's ~1 edit/s
# flood envelope; the small buffer threshold makes short DM replies feel instant.
DEFAULT_STREAMING_EDIT_INTERVAL: float = 0.8
DEFAULT_STREAMING_BUFFER_THRESHOLD: int = 24
DEFAULT_STREAMING_CURSOR: str = " ▉"
@dataclass
class StreamingConfig:
"""Real-time token streaming to messaging platforms."""
enabled: bool = False
# "auto" prefers native drafts (Telegram sendMessageDraft) with edit fallback (adapters without
# draft support use the edit path unchanged); "draft" / "edit" force one; "off" disables.
transport: str = "auto"
edit_interval: float = DEFAULT_STREAMING_EDIT_INTERVAL
buffer_threshold: int = DEFAULT_STREAMING_BUFFER_THRESHOLD
cursor: str = DEFAULT_STREAMING_CURSOR
# >0: final edit becomes a fresh message once the preview was visible this long (Telegram only; 0 = off).
# Ported from openclaw/openclaw#72038. When >0, the final edit for a long-running streamed response is
# delivered as a fresh message if the original preview has been visible for at least this many seconds,
# so the platform's visible timestamp reflects completion time instead of the preview creation time.
# Currently applied to Telegram only (other platforms ignore the setting). Default 0 disables the
# fresh-message replacement path; set >0 to opt in.
fresh_final_after_seconds: float = 0.0
def to_dict(self) -> Dict[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "StreamingConfig":
if not isinstance(data, dict) or not data:
return cls()
# ``mode`` is a transport alias that ALSO implies ``enabled`` (``mode: off`` disables;
# explicit ``enabled`` wins). A bare ``transport`` does NOT imply enabled:
# ``streaming.enabled`` is the documented master switch.
raw_transport = data.get("transport")
raw_mode = data.get("mode")
if "enabled" in data:
enabled = _coerce_bool(data.get("enabled"), False)
else:
enabled = raw_mode is not None and _normalize_transport_token(raw_mode) != "off"
return cls(
enabled=enabled,
transport=_normalize_transport_token(raw_transport if raw_transport is not None else raw_mode),
edit_interval=_coerce_float(data.get("edit_interval"), DEFAULT_STREAMING_EDIT_INTERVAL),
buffer_threshold=_coerce_int(data.get("buffer_threshold"), DEFAULT_STREAMING_BUFFER_THRESHOLD),
cursor=data.get("cursor", DEFAULT_STREAMING_CURSOR),
fresh_final_after_seconds=_coerce_float(data.get("fresh_final_after_seconds"), 0.0),
)
def _has_usable_api_server_key(key: object) -> bool:
"""True when API_SERVER_KEY is strong enough for the adapter to start (mirrors the
``has_usable_secret(min_length=16)`` guard in ``gateway/platforms/api_server.py``)."""
if not key:
return False
try:
from hermes_cli.auth import has_usable_secret
return has_usable_secret(key, min_length=16)
except ImportError:
return len(str(key).strip()) >= 16
def _needs_extra(*keys: str) -> Callable[[PlatformConfig], bool]:
return lambda cfg: all(cfg.extra.get(k) for k in keys)
# Built-in "sufficiently configured?" checks; platforms covered by the generic
# ``token or api_key`` check (Telegram, Discord, Slack, Matrix, ...) need no entry.
_PLATFORM_CONNECTED_CHECKERS: dict[Platform, Callable[[PlatformConfig], bool]] = {
Platform.WEIXIN: lambda cfg: bool(cfg.extra.get("account_id") and (cfg.token or cfg.extra.get("token"))),
Platform.WHATSAPP_CLOUD: _needs_extra("phone_number_id", "access_token"),
Platform.SIGNAL: _needs_extra("http_url"),
Platform.API_SERVER: lambda cfg: _has_usable_api_server_key(cfg.extra.get("key") if cfg else None),
Platform.WEBHOOK: lambda cfg: True,
Platform.MSGRAPH_WEBHOOK: lambda cfg: bool(str(cfg.extra.get("client_state") or "").strip()),
Platform.BLUEBUBBLES: _needs_extra("server_url", "password"),
Platform.QQBOT: _needs_extra("app_id", "client_secret"),
Platform.YUANBAO: _needs_extra("app_id", "app_secret"),
# Relay dials OUT: "connected" once an endpoint URL is configured. EXPERIMENTAL.
Platform.RELAY: lambda cfg: bool(cfg.extra.get("relay_url") or cfg.extra.get("url")),
}
# Top-level bool-ish keys read verbatim (no nested ``gateway.`` fallback) with their defaults.
_TOPLEVEL_BOOL_DEFAULTS = {
"write_sessions_json": True, "always_log_local": True, "filter_silence_narration": True,
"group_sessions_per_user": True, "thread_sessions_per_user": False,
}
@dataclass
class GatewayConfig:
"""Main gateway configuration: platform connections, session policies, delivery settings."""
platforms: Dict[Platform, PlatformConfig] = field(default_factory=dict)
default_reset_policy: SessionResetPolicy = field(default_factory=SessionResetPolicy)
reset_by_type: Dict[str, SessionResetPolicy] = field(default_factory=dict)
reset_by_platform: Dict[Platform, SessionResetPolicy] = field(default_factory=dict)
reset_triggers: List[str] = field(default_factory=lambda: ["/new", "/reset"])
quick_commands: Dict[str, Any] = field(default_factory=dict) # slash commands that bypass the agent loop
sessions_dir: Path = field(default_factory=lambda: get_hermes_home() / "sessions")
# Legacy sessions.json mirror of the routing index (primary: state.db) for external tooling / downgrades.
# The primary copy lives in state.db (gateway_routing table, #9006). Default True for backward
# compatibility with external tooling and downgrade safety; set gateway.write_sessions_json: false in
# config.yaml to stop producing the file.
write_sessions_json: bool = True
always_log_local: bool = True # Always save cron outputs to local files
# Drop outbound "silence narration" (*(silent)*, 🔇, a bare ".") that ping-pongs in bot-to-bot
# channels; a substrate guard that survives prompt drift.
filter_silence_narration: bool = True
stt_enabled: bool = True # Auto-transcribe inbound voice messages
stt_echo_transcripts: bool = True # Echo raw STT transcripts back to the user
group_sessions_per_user: bool = True # Isolate group sessions per participant when user IDs exist
thread_sessions_per_user: bool = False # False = threads shared across participants
max_concurrent_sessions: Optional[int] = None # Positive int caps simultaneous active sessions
# Opt-in: the default profile's gateway serves every profile on the host (profiles stamped into
# session keys, per-profile adapters/credentials). Allowlist None = serve all; [] = default only.
multiplex_profiles: bool = False
multiplex_profile_allowlist: Optional[List[str]] = None
# Public HTTPS endpoint for scoped RoomLink calls (an API key alone must never advertise a
# route); HERMES_ROOM_LINK_URL overrides.
room_link_url: Optional[str] = None
systemd_watchdog_seconds: int = 0 # opt-in; zero keeps Type=simple and disables sd_notify
# In-process loop liveness watchdog: after consecutive missed probes it dumps all-thread stacks
# and hard-exits with the service-restart code. The knobs tolerate transient self-recovering
# stalls (adapter reconnect doing sync socket I/O) so a short block does not cause restart churn.
# max_strikes ~= 90-120s sustained block; the heartbeat-fsync false positive is fixed at the root
# (off-loop write + two-witness probe), so raising it would only delay recovery.
# On by default; set gateway.loop_watchdog: false in config.yaml to disable. Telegram/Discord reconnect
# doing synchronous socket I/O during a network blip — so a short block does not force exit code 75 and
# trigger a restart churn that stalls cron dispatch (recurring fleet incidents on 2026-08-17, kanban
# t_0f76430f/t_70483f23). A genuine wedge (event loop frozen for the full tolerance window) still
# escalates to a supervised restart. See #69089.
loop_watchdog: bool = True
loop_watchdog_probe_interval_s: float = DEFAULT_LOOP_WATCHDOG_INTERVAL_S
loop_watchdog_probe_timeout_s: float = DEFAULT_LOOP_WATCHDOG_TIMEOUT_S
loop_watchdog_max_strikes: int = DEFAULT_LOOP_WATCHDOG_MAX_STRIKES
unauthorized_dm_behavior: str = "pair" # "pair" or "ignore"
streaming: StreamingConfig = field(default_factory=StreamingConfig)
# Prune SessionEntry records older than this (a resumed chat gets a fresh session). 0 = off.
session_store_max_age_days: int = 90
profile_routes: list = field(default_factory=list) # gateway/profile_routing.py
# Scalar fields serialized verbatim by ``to_dict`` (in output order).
_SCALAR_DICT_FIELDS = (
"write_sessions_json", "always_log_local", "filter_silence_narration", "stt_enabled",
"stt_echo_transcripts", "group_sessions_per_user", "thread_sessions_per_user",
"max_concurrent_sessions", "multiplex_profiles", "multiplex_profile_allowlist",
"room_link_url", "systemd_watchdog_seconds", "loop_watchdog",
"loop_watchdog_probe_interval_s", "loop_watchdog_probe_timeout_s",
"loop_watchdog_max_strikes", "unauthorized_dm_behavior",
)
def __post_init__(self) -> None:
self.multiplex_profile_allowlist = _normalize_multiplex_profile_allowlist(self.multiplex_profile_allowlist)
self.systemd_watchdog_seconds = coerce_systemd_watchdog_seconds(self.systemd_watchdog_seconds)
def get_connected_platforms(self) -> List[Platform]:
"""Enabled + configured platforms, sorted by value so the rendered "Connected
Platforms" prompt block is byte-stable (a reorder busts the prompt cache)."""
connected = [p for p, c in self.platforms.items() if c.enabled and self._is_platform_connected(p, c)]
return sorted(connected, key=lambda p: str(p.value))
def _is_platform_connected(self, platform: Platform, config: PlatformConfig) -> bool:
checker = _PLATFORM_CONNECTED_CHECKERS.get(platform)
# Weixin needs token AND account_id, so it must bypass the generic token branch.
if platform == Platform.WEIXIN:
return checker(config)
if config.token or config.api_key:
return True
if checker is not None:
return checker(config)
# Plugin platforms; force (idempotent) discovery for directly-constructed configs.
try:
from gateway.platform_registry import platform_registry
with contextlib.suppress(Exception):
# Iterate built-in platforms plus any registered plugin platforms so plugin authors get the
# same shared-key bridging (#24836).
# Registry-driven enable for plugin platforms. Built-ins have explicit blocks above. A
# plugin platform is enabled when its credentials are configured (``is_connected``) and its
# dependencies are either present (passive ``check_fn``) or installable on demand
# (``ensure_deps_fn``, run later by ``create_adapter()`` — never here). Plugins that need to
# seed ``PlatformConfig.extra`` from env vars (e.g. Google Chat's project_id /
# subscription_name) can supply ``env_enablement_fn`` on their PlatformEntry — called here
# BEFORE adapter construction. Enablement gate (#31116): when a plugin registers
# ``is_connected`` (the "has the user actually configured credentials for this?" check), we
# MUST consult it before flipping ``enabled = True``. Otherwise ``check_fn`` alone — a
# passive "is the SDK importable?" probe — silently enables platforms the user never opted
# into, and the gateway then tries to connect to Discord / Teams / Google Chat with no token
# and emits noisy retry-forever errors. ``_platform_status`` was already fixed for the same
# bug class in commit 7849a3d73; this is the runtime counterpart.
from hermes_cli.plugins import discover_plugins
discover_plugins()
entry = platform_registry.get(platform.value)
if entry:
check = entry.is_connected if entry.is_connected is not None else entry.validate_config
return True if check is None else check(config)
except Exception:
pass # Registry not yet initialised during early import
return False
def get_home_channel(self, platform: Platform) -> Optional[HomeChannel]:
return self.platforms[platform].home_channel if self.platforms.get(platform) else None
def get_reset_policy(self, platform: Optional[Platform] = None, session_type: Optional[str] = None) -> SessionResetPolicy:
"""Priority: platform override > type override > default."""
if platform and platform in self.reset_by_platform:
return self.reset_by_platform[platform]
if session_type and session_type in self.reset_by_type:
return self.reset_by_type[session_type]
return self.default_reset_policy
def to_dict(self) -> Dict[str, Any]:
return {
"platforms": {p.value: c.to_dict() for p, c in self.platforms.items()},
"default_reset_policy": self.default_reset_policy.to_dict(),
"reset_by_type": {k: v.to_dict() for k, v in self.reset_by_type.items()},
"reset_by_platform": {p.value: v.to_dict() for p, v in self.reset_by_platform.items()},
"reset_triggers": self.reset_triggers,
"quick_commands": self.quick_commands,
"sessions_dir": str(self.sessions_dir),
**{name: getattr(self, name) for name in self._SCALAR_DICT_FIELDS},
"streaming": self.streaming.to_dict(),
"session_store_max_age_days": self.session_store_max_age_days,
"profile_routes": [
asdict(r) if is_dataclass(r) and not isinstance(r, type) else r for r in self.profile_routes
],
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "GatewayConfig":
data = _coerce_dict(data)
nested_gateway = _coerce_dict(data.get("gateway"))
def pick(key: str) -> Any:
"""Top-level key wins by presence; else the nested ``gateway.<key>`` form."""
return data[key] if key in data else nested_gateway.get(key)
def key_label(key: str) -> str:
"""Warning key prefix: "gateway." when the nested form was the one consulted."""
return key if key in data else f"gateway.{key}"
def by_platform(key: str, parse, *, dicts_only: bool = False) -> dict:
"""``{Platform(name): parse(block)}`` for a platform-keyed mapping; unknown platforms skipped."""
out = {}
for platform_name, block in _coerce_dict(data.get(key, {})).items():
if dicts_only and not isinstance(block, dict):
continue
try:
out[Platform(platform_name)] = parse(block)
except ValueError:
pass
return out
def stt_setting(flat_key: str, nested_key: str) -> Any:
value = data.get(flat_key)
return _coerce_dict(data.get("stt")).get(nested_key) if value is None else value
def bounded_float(key: str, default: float, lo: float, hi: float) -> float:
# Out-of-range / non-finite watchdog knobs fall back to the shipped defaults.
value = _coerce_float(pick(key), default)
return value if math.isfinite(value) and lo <= value <= hi else default
room_link_url = data.get("room_link_url")
max_strikes = _coerce_int(pick("loop_watchdog_max_strikes"), DEFAULT_LOOP_WATCHDOG_MAX_STRIKES)
if not 1 <= max_strikes <= 1000:
max_strikes = DEFAULT_LOOP_WATCHDOG_MAX_STRIKES
systemd_watchdog_seconds = coerce_systemd_watchdog_seconds(
pick("systemd_watchdog_seconds"), key_label("systemd_watchdog_seconds")
)
# env > config.yaml > False: a recognized GATEWAY_MULTIPLEX_PROFILES wins (hosted deployments
# stamp it on the container); blank/unrecognized falls through to the top-level VALUE when
# not None, else ``gateway.multiplex_profiles``.
multiplex_profiles = data.get("multiplex_profiles")
if multiplex_profiles is None:
multiplex_profiles = nested_gateway.get("multiplex_profiles")
env_multiplex = _env_multiplex_profiles_override()
if env_multiplex is not None:
multiplex_profiles = env_multiplex
max_concurrent_sessions = _coerce_optional_positive_int(
pick("max_concurrent_sessions"), key_label("max_concurrent_sessions")
)
try:
session_store_max_age_days = max(int(data.get("session_store_max_age_days", 90)), 0)
except (TypeError, ValueError):
session_store_max_age_days = 90
from gateway.profile_routing import parse_profile_routes
return cls(
platforms=by_platform("platforms", PlatformConfig.from_dict, dicts_only=True),
default_reset_policy=SessionResetPolicy.from_dict(data["default_reset_policy"])
if "default_reset_policy" in data
else SessionResetPolicy(),
reset_by_type={
type_name: SessionResetPolicy.from_dict(policy_data)
for type_name, policy_data in _coerce_dict(data.get("reset_by_type", {})).items()
},
reset_by_platform=by_platform("reset_by_platform", SessionResetPolicy.from_dict),
reset_triggers=data.get("reset_triggers", ["/new", "/reset"]),
quick_commands=_coerce_dict(data.get("quick_commands", {})),
sessions_dir=Path(data["sessions_dir"]) if "sessions_dir" in data else get_hermes_home() / "sessions",
**{name: _coerce_bool(data.get(name), default) for name, default in _TOPLEVEL_BOOL_DEFAULTS.items()},
stt_enabled=_coerce_bool(stt_setting("stt_enabled", "enabled"), True),
stt_echo_transcripts=_coerce_bool(stt_setting("stt_echo_transcripts", "echo_transcripts"), True),
multiplex_profiles=_coerce_bool(multiplex_profiles, False),
multiplex_profile_allowlist=pick("multiplex_profile_allowlist"),
room_link_url=room_link_url if isinstance(room_link_url, str) else None,
systemd_watchdog_seconds=systemd_watchdog_seconds,
loop_watchdog=_coerce_bool(pick("loop_watchdog"), True),
loop_watchdog_probe_interval_s=bounded_float("loop_watchdog_probe_interval_s", DEFAULT_LOOP_WATCHDOG_INTERVAL_S, 1.0, 3600.0),
loop_watchdog_probe_timeout_s=bounded_float("loop_watchdog_probe_timeout_s", DEFAULT_LOOP_WATCHDOG_TIMEOUT_S, 1.0, 600.0),
loop_watchdog_max_strikes=max_strikes,
max_concurrent_sessions=max_concurrent_sessions,
unauthorized_dm_behavior=_normalize_choice(data.get("unauthorized_dm_behavior"), {"pair", "ignore"}, "pair"),
streaming=StreamingConfig.from_dict(data.get("streaming", {})),
session_store_max_age_days=session_store_max_age_days,
profile_routes=parse_profile_routes(data.get("profile_routes") or []),
)
def _extra_choice(self, platform: Optional[Platform], key: str, choices: set, default: str) -> Optional[str]:
"""Normalized ``platforms[platform].extra[key]`` when the key is present, else None."""
platform_cfg = self.platforms.get(platform) if platform else None
if platform_cfg and key in platform_cfg.extra:
return _normalize_choice(platform_cfg.extra.get(key), choices, default)
return None
def get_unauthorized_dm_behavior(self, platform: Optional[Platform] = None) -> str:
"""Effective unauthorized-DM behavior. Email is inbox-shaped so it defaults to ``"ignore"``
unless its own ``unauthorized_dm_behavior`` opts in (a global default does not)."""
choice = self._extra_choice(platform, "unauthorized_dm_behavior", {"pair", "ignore"}, self.unauthorized_dm_behavior)
if choice is not None:
return choice
return "ignore" if platform == Platform.EMAIL else self.unauthorized_dm_behavior
def get_notice_delivery(self, platform: Optional[Platform] = None) -> str:
"""Effective notice-delivery mode ("public"/"private") for a platform."""
choice = self._extra_choice(platform, "notice_delivery", {"public", "private"}, "public")
return "public" if choice is None else choice
def load_gateway_config() -> GatewayConfig:
"""Load gateway configuration. Priority: env > ~/.hermes/config.yaml > legacy gateway.json > defaults."""
from gateway import config_loader
_home = get_hermes_home()
gw_data = config_loader.load_legacy_gateway_json(_home)
try:
config_loader.load_yaml_layer(_home, gw_data)
except Exception as e:
logger.warning(
# DingTalk settings → env vars: migrated to the dingtalk plugin's apply_yaml_config_fn hook
# (plugins/platforms/dingtalk/adapter.py). #41112 / #3823.
# Mattermost config bridge moved into plugins/platforms/mattermost/
# adapter.py::_apply_yaml_config — see #25443 (apply_yaml_config_fn).
# Matrix settings → env vars: migrated to the matrix plugin's apply_yaml_config_fn hook
# (plugins/platforms/matrix/adapter.py). #41112 / #3823.
# Feishu settings → env vars: migrated to the feishu plugin's apply_yaml_config_fn hook
# (plugins/platforms/feishu/adapter.py). #41112 / #3823.
"Failed to process config.yaml — falling back to .env / gateway.json values. "
"Check %s for syntax errors. Error: %s",
_home / "config.yaml", e,
)
config = GatewayConfig.from_dict(gw_data)
_apply_env_overrides(config)
_validate_gateway_config(config)
return config
def _validate_gateway_config(config: "GatewayConfig") -> None:
"""Validate and sanitize a loaded GatewayConfig in place (after all sources are merged)."""
policy = config.default_reset_policy
if not (0 <= policy.at_hour <= 23):
logger.warning("Invalid at_hour=%s (must be 0-23). Using default 4.", policy.at_hour)
policy.at_hour = 4
if policy.idle_minutes is None or policy.idle_minutes <= 0:
logger.warning("Invalid idle_minutes=%s (must be positive). Using default 1440.", policy.idle_minutes)
policy.idle_minutes = 1440
try:
# Reject known-weak placeholder tokens. Ported from openclaw/openclaw#64586: users who copy
# .env.example without changing placeholder values get a clear startup error instead of a confusing
# "auth failed" from the platform API.
from hermes_cli.auth import has_usable_secret
except ImportError:
has_usable_secret = None
token_platforms = [
(p, c, PLATFORM_TOKEN_ENV_NAMES[p]) for p, c in config.platforms.items()
if c.enabled and p in PLATFORM_TOKEN_ENV_NAMES and c.token is not None
]
for platform, pconfig, env_name in token_platforms: # an empty token won't connect; say so
if not pconfig.token.strip():
logger.warning("%s is enabled but %s is empty. The adapter will likely fail to connect.", platform.value, env_name)
if has_usable_secret is None:
return
for platform, pconfig, env_name in token_platforms: # reject placeholder tokens (copied .env.example)
token = pconfig.token
if token.strip() and not has_usable_secret(token, min_length=4):
logger.error(
"%s is enabled but %s is set to a placeholder value ('%s'). "
"Set a real bot token before starting the gateway. "
"The adapter will NOT be started.",
platform.value, env_name, token.strip()[:6] + "...",
)
pconfig.enabled = False
def _apply_env_overrides(config: GatewayConfig) -> None:
"""Apply environment variable overrides to config (see ``gateway.config_env``)."""
from gateway.config_env import _apply_env_overrides as _impl
_impl(config)
# ---- 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.
import json # noqa: F401,E402
# ---- END PLUGIN-COMPAT ----