| """ |
| Gateway runner - entry point for messaging platform integrations. |
| |
| This module provides: |
| - start_gateway(): Start all configured platform adapters |
| - GatewayRunner: Main class managing the gateway lifecycle |
| |
| Usage: |
| # Start the gateway |
| python -m gateway.run |
| |
| # Or from CLI |
| python cli.py --gateway |
| """ |
|
|
| import asyncio |
| import json |
| import logging |
| import os |
| import re |
| import shlex |
| import sys |
| import signal |
| import tempfile |
| import threading |
| import time |
| from pathlib import Path |
| from datetime import datetime |
| from typing import Dict, Optional, Any, List |
|
|
| |
| |
| |
| |
| def _ensure_ssl_certs() -> None: |
| """Set SSL_CERT_FILE if the system doesn't expose CA certs to Python.""" |
| if "SSL_CERT_FILE" in os.environ: |
| return |
|
|
| import ssl |
|
|
| |
| 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 |
|
|
| |
| try: |
| import certifi |
| os.environ["SSL_CERT_FILE"] = certifi.where() |
| return |
| except ImportError: |
| pass |
|
|
| |
| for candidate in ( |
| "/etc/ssl/certs/ca-certificates.crt", |
| "/etc/pki/tls/certs/ca-bundle.crt", |
| "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", |
| "/etc/ssl/ca-bundle.pem", |
| "/etc/ssl/cert.pem", |
| "/etc/pki/tls/cert.pem", |
| "/usr/local/etc/openssl@1.1/cert.pem", |
| "/opt/homebrew/etc/openssl@1.1/cert.pem", |
| ): |
| if os.path.exists(candidate): |
| os.environ["SSL_CERT_FILE"] = candidate |
| return |
|
|
| _ensure_ssl_certs() |
|
|
| |
| sys.path.insert(0, str(Path(__file__).parent.parent)) |
|
|
| |
| from hermes_constants import get_hermes_home |
| from utils import atomic_yaml_write, is_truthy_value |
| _hermes_home = get_hermes_home() |
|
|
| |
| |
| from dotenv import load_dotenv |
| 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') |
|
|
| |
| |
| _config_path = _hermes_home / 'config.yaml' |
| if _config_path.exists(): |
| try: |
| import yaml as _yaml |
| with open(_config_path, encoding="utf-8") as _f: |
| _cfg = _yaml.safe_load(_f) or {} |
| |
| from hermes_cli.config import _expand_env_vars |
| _cfg = _expand_env_vars(_cfg) |
| |
| for _key, _val in _cfg.items(): |
| 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): |
| _terminal_env_map = { |
| "backend": "TERMINAL_ENV", |
| "cwd": "TERMINAL_CWD", |
| "timeout": "TERMINAL_TIMEOUT", |
| "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", |
| "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", |
| "sandbox_dir": "TERMINAL_SANDBOX_DIR", |
| "persistent_shell": "TERMINAL_PERSISTENT_SHELL", |
| } |
| for _cfg_key, _env_var in _terminal_env_map.items(): |
| if _cfg_key in _terminal_cfg: |
| _val = _terminal_cfg[_cfg_key] |
| if isinstance(_val, list): |
| os.environ[_env_var] = json.dumps(_val) |
| else: |
| os.environ[_env_var] = str(_val) |
| |
| |
| |
| |
| _auxiliary_cfg = _cfg.get("auxiliary", {}) |
| if _auxiliary_cfg and isinstance(_auxiliary_cfg, dict): |
| _aux_task_env = { |
| "vision": { |
| "provider": "AUXILIARY_VISION_PROVIDER", |
| "model": "AUXILIARY_VISION_MODEL", |
| "base_url": "AUXILIARY_VISION_BASE_URL", |
| "api_key": "AUXILIARY_VISION_API_KEY", |
| }, |
| "web_extract": { |
| "provider": "AUXILIARY_WEB_EXTRACT_PROVIDER", |
| "model": "AUXILIARY_WEB_EXTRACT_MODEL", |
| "base_url": "AUXILIARY_WEB_EXTRACT_BASE_URL", |
| "api_key": "AUXILIARY_WEB_EXTRACT_API_KEY", |
| }, |
| "approval": { |
| "provider": "AUXILIARY_APPROVAL_PROVIDER", |
| "model": "AUXILIARY_APPROVAL_MODEL", |
| "base_url": "AUXILIARY_APPROVAL_BASE_URL", |
| "api_key": "AUXILIARY_APPROVAL_API_KEY", |
| }, |
| } |
| for _task_key, _env_map in _aux_task_env.items(): |
| _task_cfg = _auxiliary_cfg.get(_task_key, {}) |
| if not isinstance(_task_cfg, dict): |
| continue |
| _prov = str(_task_cfg.get("provider", "")).strip() |
| _model = str(_task_cfg.get("model", "")).strip() |
| _base_url = str(_task_cfg.get("base_url", "")).strip() |
| _api_key = str(_task_cfg.get("api_key", "")).strip() |
| if _prov and _prov != "auto": |
| os.environ[_env_map["provider"]] = _prov |
| if _model: |
| os.environ[_env_map["model"]] = _model |
| if _base_url: |
| os.environ[_env_map["base_url"]] = _base_url |
| if _api_key: |
| os.environ[_env_map["api_key"]] = _api_key |
| _agent_cfg = _cfg.get("agent", {}) |
| if _agent_cfg and isinstance(_agent_cfg, dict): |
| if "max_turns" in _agent_cfg: |
| os.environ["HERMES_MAX_ITERATIONS"] = str(_agent_cfg["max_turns"]) |
| |
| |
| if "gateway_timeout" in _agent_cfg and "HERMES_AGENT_TIMEOUT" not in os.environ: |
| os.environ["HERMES_AGENT_TIMEOUT"] = str(_agent_cfg["gateway_timeout"]) |
| if "gateway_timeout_warning" in _agent_cfg and "HERMES_AGENT_TIMEOUT_WARNING" not in os.environ: |
| os.environ["HERMES_AGENT_TIMEOUT_WARNING"] = str(_agent_cfg["gateway_timeout_warning"]) |
| if "gateway_notify_interval" in _agent_cfg and "HERMES_AGENT_NOTIFY_INTERVAL" not in os.environ: |
| os.environ["HERMES_AGENT_NOTIFY_INTERVAL"] = str(_agent_cfg["gateway_notify_interval"]) |
| if "restart_drain_timeout" in _agent_cfg and "HERMES_RESTART_DRAIN_TIMEOUT" not in os.environ: |
| os.environ["HERMES_RESTART_DRAIN_TIMEOUT"] = str(_agent_cfg["restart_drain_timeout"]) |
| _display_cfg = _cfg.get("display", {}) |
| if _display_cfg and isinstance(_display_cfg, dict): |
| if "busy_input_mode" in _display_cfg and "HERMES_GATEWAY_BUSY_INPUT_MODE" not in os.environ: |
| os.environ["HERMES_GATEWAY_BUSY_INPUT_MODE"] = str(_display_cfg["busy_input_mode"]) |
| |
| |
| _tz_cfg = _cfg.get("timezone", "") |
| if _tz_cfg and isinstance(_tz_cfg, str) and "HERMES_TIMEZONE" not in os.environ: |
| os.environ["HERMES_TIMEZONE"] = _tz_cfg.strip() |
| |
| _security_cfg = _cfg.get("security", {}) |
| if isinstance(_security_cfg, dict): |
| _redact = _security_cfg.get("redact_secrets") |
| if _redact is not None: |
| os.environ["HERMES_REDACT_SECRETS"] = str(_redact).lower() |
| except Exception: |
| pass |
|
|
| |
| try: |
| from hermes_constants import apply_ipv4_preference |
| _network_cfg = (_cfg if '_cfg' in dir() else {}).get("network", {}) |
| if isinstance(_network_cfg, dict) and _network_cfg.get("force_ipv4"): |
| apply_ipv4_preference(force=True) |
| except Exception: |
| pass |
|
|
| |
| try: |
| from hermes_cli.config import print_config_warnings |
| print_config_warnings() |
| except Exception: |
| pass |
|
|
| |
| os.environ["HERMES_QUIET"] = "1" |
|
|
| |
| os.environ["HERMES_EXEC_ASK"] = "1" |
|
|
| |
| |
| |
| _configured_cwd = os.environ.get("TERMINAL_CWD", "") |
| if not _configured_cwd or _configured_cwd in (".", "auto", "cwd"): |
| messaging_cwd = os.getenv("MESSAGING_CWD") or str(Path.home()) |
| os.environ["TERMINAL_CWD"] = messaging_cwd |
|
|
| from gateway.config import ( |
| Platform, |
| GatewayConfig, |
| load_gateway_config, |
| ) |
| from gateway.session import ( |
| SessionStore, |
| SessionSource, |
| SessionContext, |
| build_session_context, |
| build_session_context_prompt, |
| build_session_key, |
| ) |
| from gateway.delivery import DeliveryRouter |
| from gateway.platforms.base import ( |
| BasePlatformAdapter, |
| MessageEvent, |
| MessageType, |
| merge_pending_message_event, |
| ) |
| from gateway.restart import ( |
| DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT, |
| GATEWAY_SERVICE_RESTART_EXIT_CODE, |
| parse_restart_drain_timeout, |
| ) |
|
|
|
|
| def _normalize_whatsapp_identifier(value: str) -> str: |
| """Strip WhatsApp JID/LID syntax down to its stable numeric identifier.""" |
| return ( |
| str(value or "") |
| .strip() |
| .replace("+", "", 1) |
| .split(":", 1)[0] |
| .split("@", 1)[0] |
| ) |
|
|
|
|
| def _expand_whatsapp_auth_aliases(identifier: str) -> set: |
| """Resolve WhatsApp phone/LID aliases using bridge session mapping files.""" |
| normalized = _normalize_whatsapp_identifier(identifier) |
| if not normalized: |
| return set() |
|
|
| session_dir = _hermes_home / "whatsapp" / "session" |
| resolved = set() |
| queue = [normalized] |
|
|
| while queue: |
| current = queue.pop(0) |
| if not current or current in resolved: |
| continue |
|
|
| resolved.add(current) |
| for suffix in ("", "_reverse"): |
| mapping_path = session_dir / f"lid-mapping-{current}{suffix}.json" |
| if not mapping_path.exists(): |
| continue |
| try: |
| mapped = _normalize_whatsapp_identifier( |
| json.loads(mapping_path.read_text(encoding="utf-8")) |
| ) |
| except Exception: |
| continue |
| if mapped and mapped not in resolved: |
| queue.append(mapped) |
|
|
| return resolved |
|
|
| logger = logging.getLogger(__name__) |
|
|
| |
| |
| |
| |
| _AGENT_PENDING_SENTINEL = object() |
|
|
|
|
| def _resolve_runtime_agent_kwargs() -> dict: |
| """Resolve provider credentials for gateway-created AIAgent instances.""" |
| from hermes_cli.runtime_provider import ( |
| resolve_runtime_provider, |
| format_runtime_provider_error, |
| ) |
|
|
| try: |
| runtime = resolve_runtime_provider( |
| requested=os.getenv("HERMES_INFERENCE_PROVIDER"), |
| ) |
| except Exception as exc: |
| raise RuntimeError(format_runtime_provider_error(exc)) from exc |
|
|
| return { |
| "api_key": runtime.get("api_key"), |
| "base_url": runtime.get("base_url"), |
| "provider": runtime.get("provider"), |
| "api_mode": runtime.get("api_mode"), |
| "command": runtime.get("command"), |
| "args": list(runtime.get("args") or []), |
| "credential_pool": runtime.get("credential_pool"), |
| } |
|
|
|
|
| def _build_media_placeholder(event) -> str: |
| """Build a text placeholder for media-only events so they aren't dropped. |
| |
| When a photo/document is queued during active processing and later |
| dequeued, only .text is extracted. If the event has no caption, |
| the media would be silently lost. This builds a placeholder that |
| the vision enrichment pipeline will replace with a real description. |
| """ |
| parts = [] |
| media_urls = getattr(event, "media_urls", None) or [] |
| media_types = getattr(event, "media_types", None) or [] |
| for i, url in enumerate(media_urls): |
| mtype = media_types[i] if i < len(media_types) else "" |
| if mtype.startswith("image/") or getattr(event, "message_type", None) == MessageType.PHOTO: |
| parts.append(f"[User sent an image: {url}]") |
| elif mtype.startswith("audio/"): |
| parts.append(f"[User sent audio: {url}]") |
| else: |
| parts.append(f"[User sent a file: {url}]") |
| return "\n".join(parts) |
|
|
|
|
| def _dequeue_pending_event(adapter, session_key: str) -> MessageEvent | None: |
| """Consume and return the full pending event for a session. |
| |
| Queued follow-ups must preserve their media metadata so they can re-enter |
| the normal image/STT/document preprocessing path instead of being reduced |
| to a placeholder string. |
| """ |
| return adapter.get_pending_message(session_key) |
|
|
|
|
| def _check_unavailable_skill(command_name: str) -> str | None: |
| """Check if a command matches a known-but-inactive skill. |
| |
| Returns a helpful message if the skill exists but is disabled or only |
| available as an optional install. Returns None if no match found. |
| """ |
| |
| normalized = command_name.lower().replace("_", "-") |
| try: |
| from tools.skills_tool import _get_disabled_skill_names |
| from agent.skill_utils import get_all_skills_dirs |
| 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 any(part in ('.git', '.github', '.hub') for part in skill_md.parts): |
| continue |
| name = skill_md.parent.name.lower().replace("_", "-") |
| if name == normalized and name in disabled: |
| return ( |
| f"The **{command_name}** skill is installed but disabled.\n" |
| f"Enable it with: `hermes skills config`" |
| ) |
|
|
| |
| 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"): |
| name = skill_md.parent.name.lower().replace("_", "-") |
| if name == normalized: |
| |
| rel = skill_md.parent.relative_to(optional_dir) |
| parts = list(rel.parts) |
| install_path = f"official/{'/'.join(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 _load_gateway_config() -> dict: |
| """Load and parse ~/.hermes/config.yaml, returning {} on any error.""" |
| try: |
| config_path = _hermes_home / 'config.yaml' |
| if config_path.exists(): |
| import yaml |
| with open(config_path, 'r', encoding='utf-8') as f: |
| return yaml.safe_load(f) or {} |
| except Exception: |
| logger.debug("Could not load gateway config from %s", _hermes_home / 'config.yaml') |
| return {} |
|
|
|
|
| def _resolve_gateway_model(config: dict | None = None) -> str: |
| """Read model from config.yaml — single source of truth. |
| |
| Without this, temporary AIAgent instances (memory flush, /compress) fall |
| back to the hardcoded default which fails when the active provider is |
| 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 _resolve_hermes_bin() -> Optional[list[str]]: |
| """Resolve the Hermes update command as argv parts. |
| |
| Tries in order: |
| 1. ``shutil.which("hermes")`` — standard PATH lookup |
| 2. ``sys.executable -m hermes_cli.main`` — fallback when Hermes is running |
| from a venv/module invocation and the ``hermes`` shim is not on PATH |
| |
| Returns argv parts ready for quoting/joining, or ``None`` if neither works. |
| """ |
| import shutil |
|
|
| hermes_bin = shutil.which("hermes") |
| if hermes_bin: |
| return [hermes_bin] |
|
|
| try: |
| import importlib.util |
|
|
| if importlib.util.find_spec("hermes_cli") is not None: |
| return [sys.executable, "-m", "hermes_cli.main"] |
| except Exception: |
| pass |
|
|
| return None |
|
|
|
|
| def _format_gateway_process_notification(evt: dict) -> "str | None": |
| """Format a watch pattern event from completion_queue into a [SYSTEM:] message.""" |
| evt_type = evt.get("type", "completion") |
| _sid = evt.get("session_id", "unknown") |
| _cmd = evt.get("command", "unknown") |
|
|
| if evt_type == "watch_disabled": |
| return f"[SYSTEM: {evt.get('message', '')}]" |
|
|
| if evt_type == "watch_match": |
| _pat = evt.get("pattern", "?") |
| _out = evt.get("output", "") |
| _sup = evt.get("suppressed", 0) |
| text = ( |
| f"[SYSTEM: Background process {_sid} matched " |
| f"watch pattern \"{_pat}\".\n" |
| f"Command: {_cmd}\n" |
| f"Matched output:\n{_out}" |
| ) |
| if _sup: |
| text += f"\n({_sup} earlier matches were suppressed by rate limit)" |
| text += "]" |
| return text |
|
|
| return None |
|
|
|
|
| class GatewayRunner: |
| """ |
| Main gateway controller. |
| |
| Manages the lifecycle of all platform adapters and routes |
| messages to/from the agent. |
| """ |
|
|
| |
| |
| _running_agents_ts: Dict[str, float] = {} |
| _busy_input_mode: str = "interrupt" |
| _restart_drain_timeout: float = DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT |
| _exit_code: Optional[int] = None |
| _draining: bool = False |
| _restart_requested: bool = False |
| _restart_task_started: bool = False |
| _restart_detached: bool = False |
| _restart_via_service: bool = False |
| _stop_task: Optional[asyncio.Task] = None |
| _session_model_overrides: Dict[str, Dict[str, str]] = {} |
| |
| def __init__(self, config: Optional[GatewayConfig] = None): |
| self.config = config or load_gateway_config() |
| self.adapters: Dict[Platform, BasePlatformAdapter] = {} |
|
|
| |
| |
| self._prefill_messages = self._load_prefill_messages() |
| self._ephemeral_system_prompt = self._load_ephemeral_system_prompt() |
| 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._restart_drain_timeout = self._load_restart_drain_timeout() |
| self._provider_routing = self._load_provider_routing() |
| self._fallback_model = self._load_fallback_model() |
| self._smart_model_routing = self._load_smart_model_routing() |
|
|
| |
| 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), |
| ) |
| self.delivery_router = DeliveryRouter(self.config) |
| self._running = False |
| self._shutdown_event = asyncio.Event() |
| self._exit_cleanly = False |
| self._exit_with_failure = False |
| self._exit_reason: Optional[str] = None |
| self._exit_code: Optional[int] = None |
| self._draining = False |
| self._restart_requested = False |
| self._restart_task_started = False |
| self._restart_detached = False |
| self._restart_via_service = False |
| self._stop_task: Optional[asyncio.Task] = None |
| |
| |
| |
| self._running_agents: Dict[str, Any] = {} |
| self._running_agents_ts: Dict[str, float] = {} |
| self._pending_messages: Dict[str, str] = {} |
|
|
| |
| |
| |
| |
| |
| import threading as _threading |
| self._agent_cache: Dict[str, tuple] = {} |
| self._agent_cache_lock = _threading.Lock() |
|
|
| |
| |
| self._session_model_overrides: Dict[str, Dict[str, str]] = {} |
| |
| |
| self._pending_approvals: Dict[str, Dict[str, Any]] = {} |
|
|
| |
| |
| self._failed_platforms: Dict[Platform, Dict[str, Any]] = {} |
|
|
| |
| |
| self._update_prompt_pending: Dict[str, bool] = {} |
|
|
| |
| |
| |
|
|
|
|
|
|
| |
| try: |
| from tools.tirith_security import ensure_installed |
| ensure_installed(log_failures=False) |
| except Exception: |
| pass |
| |
| |
| self._session_db = None |
| try: |
| from hermes_state import SessionDB |
| self._session_db = SessionDB() |
| except Exception as e: |
| logger.debug("SQLite session store not available: %s", e) |
| |
| |
| from gateway.pairing import PairingStore |
| self.pairing_store = PairingStore() |
| |
| |
| from gateway.hooks import HookRegistry |
| self.hooks = HookRegistry() |
|
|
| |
| self._voice_mode: Dict[str, str] = self._load_voice_modes() |
|
|
| |
| self._background_tasks: set = set() |
|
|
|
|
|
|
|
|
| |
|
|
| def _has_setup_skill(self) -> bool: |
| """Check if the hermes-agent-setup skill is installed.""" |
| try: |
| from tools.skill_manager_tool import _find_skill |
| return _find_skill("hermes-agent-setup") is not None |
| except Exception: |
| return False |
|
|
| |
|
|
| _VOICE_MODE_PATH = _hermes_home / "gateway_voice_mode.json" |
|
|
| def _load_voice_modes(self) -> Dict[str, str]: |
| try: |
| data = json.loads(self._VOICE_MODE_PATH.read_text()) |
| except (FileNotFoundError, json.JSONDecodeError, OSError): |
| return {} |
|
|
| if not isinstance(data, dict): |
| return {} |
|
|
| valid_modes = {"off", "voice_only", "all"} |
| return { |
| str(chat_id): mode |
| for chat_id, mode in data.items() |
| if mode in valid_modes |
| } |
|
|
| def _save_voice_modes(self) -> None: |
| try: |
| self._VOICE_MODE_PATH.parent.mkdir(parents=True, exist_ok=True) |
| self._VOICE_MODE_PATH.write_text( |
| json.dumps(self._voice_mode, indent=2) |
| ) |
| except OSError as e: |
| logger.warning("Failed to save voice modes: %s", e) |
|
|
| def _set_adapter_auto_tts_disabled(self, adapter, chat_id: str, disabled: bool) -> None: |
| """Update an adapter's in-memory auto-TTS suppression set if present.""" |
| disabled_chats = getattr(adapter, "_auto_tts_disabled_chats", None) |
| if not isinstance(disabled_chats, set): |
| return |
| if disabled: |
| disabled_chats.add(chat_id) |
| else: |
| disabled_chats.discard(chat_id) |
|
|
| def _sync_voice_mode_state_to_adapter(self, adapter) -> None: |
| """Restore persisted /voice off state into a live platform adapter.""" |
| disabled_chats = getattr(adapter, "_auto_tts_disabled_chats", None) |
| if not isinstance(disabled_chats, set): |
| return |
| disabled_chats.clear() |
| disabled_chats.update( |
| chat_id for chat_id, mode in self._voice_mode.items() if mode == "off" |
| ) |
|
|
| |
|
|
| def _flush_memories_for_session( |
| self, |
| old_session_id: str, |
| session_key: Optional[str] = None, |
| ): |
| """Prompt the agent to save memories/skills before context is lost. |
| |
| Synchronous worker — meant to be called via run_in_executor from |
| an async context so it doesn't block the event loop. |
| """ |
| |
| |
| if old_session_id and old_session_id.startswith("cron_"): |
| logger.debug("Skipping memory flush for cron session: %s", old_session_id) |
| return |
|
|
| try: |
| history = self.session_store.load_transcript(old_session_id) |
| if not history or len(history) < 4: |
| return |
|
|
| from run_agent import AIAgent |
| model, runtime_kwargs = self._resolve_session_agent_runtime( |
| session_key=session_key, |
| ) |
| if not runtime_kwargs.get("api_key"): |
| return |
|
|
| tmp_agent = AIAgent( |
| **runtime_kwargs, |
| model=model, |
| max_iterations=8, |
| quiet_mode=True, |
| skip_memory=True, |
| enabled_toolsets=["memory", "skills"], |
| session_id=old_session_id, |
| ) |
| |
| |
| |
| tmp_agent._print_fn = lambda *a, **kw: None |
|
|
| |
| msgs = [ |
| {"role": m.get("role"), "content": m.get("content")} |
| for m in history |
| if m.get("role") in ("user", "assistant") and m.get("content") |
| ] |
|
|
| |
| |
| _current_memory = "" |
| try: |
| from tools.memory_tool import get_memory_dir |
| _mem_dir = get_memory_dir() |
| for fname, label in [ |
| ("MEMORY.md", "MEMORY (your personal notes)"), |
| ("USER.md", "USER PROFILE (who the user is)"), |
| ]: |
| fpath = _mem_dir / fname |
| if fpath.exists(): |
| content = fpath.read_text(encoding="utf-8").strip() |
| if content: |
| _current_memory += f"\n\n## Current {label}:\n{content}" |
| except Exception: |
| pass |
|
|
| |
| flush_prompt = ( |
| "[System: This session is about to be automatically reset due to " |
| "inactivity or a scheduled daily reset. The conversation context " |
| "will be cleared after this turn.\n\n" |
| "Review the conversation above and:\n" |
| "1. Save any important facts, preferences, or decisions to memory " |
| "(user profile or your notes) that would be useful in future sessions.\n" |
| "2. If you discovered a reusable workflow or solved a non-trivial " |
| "problem, consider saving it as a skill.\n" |
| "3. If nothing is worth saving, that's fine — just skip.\n\n" |
| ) |
|
|
| if _current_memory: |
| flush_prompt += ( |
| "IMPORTANT — here is the current live state of memory. Other " |
| "sessions, cron jobs, or the user may have updated it since this " |
| "conversation ended. Do NOT overwrite or remove entries unless " |
| "the conversation above reveals something that genuinely " |
| "supersedes them. Only add new information that is not already " |
| "captured below." |
| f"{_current_memory}\n\n" |
| ) |
|
|
| flush_prompt += ( |
| "Do NOT respond to the user. Just use the memory and skill_manage " |
| "tools if needed, then stop.]" |
| ) |
|
|
| tmp_agent.run_conversation( |
| user_message=flush_prompt, |
| conversation_history=msgs, |
| ) |
| logger.info("Pre-reset memory flush completed for session %s", old_session_id) |
| except Exception as e: |
| logger.debug("Pre-reset memory flush failed for session %s: %s", old_session_id, e) |
|
|
| async def _async_flush_memories( |
| self, |
| old_session_id: str, |
| session_key: Optional[str] = None, |
| ): |
| """Run the sync memory flush in a thread pool so it won't block the event loop.""" |
| loop = asyncio.get_event_loop() |
| await loop.run_in_executor( |
| None, |
| self._flush_memories_for_session, |
| old_session_id, |
| session_key, |
| ) |
|
|
| @property |
| def should_exit_cleanly(self) -> bool: |
| return self._exit_cleanly |
|
|
| @property |
| def should_exit_with_failure(self) -> bool: |
| return self._exit_with_failure |
|
|
| @property |
| def exit_reason(self) -> Optional[str]: |
| return self._exit_reason |
|
|
| @property |
| def exit_code(self) -> Optional[int]: |
| return 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) |
| 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), |
| ) |
|
|
| def _resolve_session_agent_runtime( |
| self, |
| *, |
| source: Optional[SessionSource] = None, |
| session_key: Optional[str] = None, |
| user_config: Optional[dict] = None, |
| ) -> tuple[str, dict]: |
| """Resolve model/runtime for a session, honoring session-scoped /model overrides. |
| |
| If the session override already contains a complete provider bundle |
| (provider/api_key/base_url/api_mode), prefer it directly instead of |
| resolving fresh global runtime state first. |
| """ |
| resolved_session_key = session_key |
| if not resolved_session_key and source is not None: |
| try: |
| resolved_session_key = self._session_key_for_source(source) |
| except Exception: |
| resolved_session_key = None |
|
|
| model = _resolve_gateway_model(user_config) |
| override = self._session_model_overrides.get(resolved_session_key) if resolved_session_key else None |
| if override: |
| override_model = override.get("model", model) |
| override_runtime = { |
| "provider": override.get("provider"), |
| "api_key": override.get("api_key"), |
| "base_url": override.get("base_url"), |
| "api_mode": override.get("api_mode"), |
| } |
| if override_runtime.get("api_key"): |
| logger.debug( |
| "Session model override (fast): session=%s config_model=%s -> override_model=%s provider=%s", |
| (resolved_session_key or "")[:30], model, override_model, |
| override_runtime.get("provider"), |
| ) |
| return override_model, override_runtime |
| |
| |
| logger.debug( |
| "Session model override (no api_key, fallback): session=%s config_model=%s override_model=%s", |
| (resolved_session_key or "")[:30], model, override_model, |
| ) |
| else: |
| logger.debug( |
| "No session model override: session=%s config_model=%s override_keys=%s", |
| (resolved_session_key or "")[:30], model, |
| list(self._session_model_overrides.keys())[:5] if self._session_model_overrides else "[]", |
| ) |
|
|
| runtime_kwargs = _resolve_runtime_agent_kwargs() |
| if override and resolved_session_key: |
| model, runtime_kwargs = self._apply_session_model_override( |
| resolved_session_key, model, runtime_kwargs |
| ) |
|
|
| |
| |
| |
| |
| if not model and runtime_kwargs.get("provider"): |
| try: |
| from hermes_cli.models import get_default_model_for_provider |
| model = get_default_model_for_provider(runtime_kwargs["provider"]) |
| if model: |
| logger.info( |
| "No model configured — defaulting to %s for provider %s", |
| model, runtime_kwargs["provider"], |
| ) |
| except Exception: |
| pass |
|
|
| return model, runtime_kwargs |
|
|
| def _resolve_turn_agent_config(self, user_message: str, model: str, runtime_kwargs: dict) -> dict: |
| from agent.smart_model_routing import resolve_turn_route |
| from hermes_cli.models import resolve_fast_mode_overrides |
|
|
| primary = { |
| "model": model, |
| "api_key": runtime_kwargs.get("api_key"), |
| "base_url": runtime_kwargs.get("base_url"), |
| "provider": runtime_kwargs.get("provider"), |
| "api_mode": runtime_kwargs.get("api_mode"), |
| "command": runtime_kwargs.get("command"), |
| "args": list(runtime_kwargs.get("args") or []), |
| "credential_pool": runtime_kwargs.get("credential_pool"), |
| } |
| route = resolve_turn_route(user_message, getattr(self, "_smart_model_routing", {}), primary) |
|
|
| service_tier = getattr(self, "_service_tier", None) |
| if not service_tier: |
| route["request_overrides"] = None |
| return route |
|
|
| try: |
| overrides = resolve_fast_mode_overrides(route.get("model")) |
| except Exception: |
| overrides = None |
| route["request_overrides"] = overrides |
| return route |
|
|
| async def _handle_adapter_fatal_error(self, adapter: BasePlatformAdapter) -> None: |
| """React to an adapter failure after startup. |
| |
| If the error is retryable (e.g. network blip, DNS failure), queue the |
| platform for background reconnection instead of giving up permanently. |
| """ |
| logger.error( |
| "Fatal %s adapter error (%s): %s", |
| adapter.platform.value, |
| adapter.fatal_error_code or "unknown", |
| adapter.fatal_error_message or "unknown error", |
| ) |
| self._update_platform_runtime_status( |
| adapter.platform.value, |
| platform_state="retrying" if adapter.fatal_error_retryable else "fatal", |
| error_code=adapter.fatal_error_code, |
| error_message=adapter.fatal_error_message, |
| ) |
|
|
| existing = self.adapters.get(adapter.platform) |
| if existing is adapter: |
| try: |
| await adapter.disconnect() |
| finally: |
| self.adapters.pop(adapter.platform, None) |
| self.delivery_router.adapters = self.adapters |
|
|
| |
| if adapter.fatal_error_retryable: |
| platform_config = self.config.platforms.get(adapter.platform) |
| if platform_config and adapter.platform not in self._failed_platforms: |
| self._failed_platforms[adapter.platform] = { |
| "config": platform_config, |
| "attempts": 0, |
| "next_retry": time.monotonic() + 30, |
| } |
| logger.info( |
| "%s queued for background reconnection", |
| adapter.platform.value, |
| ) |
|
|
| if not self.adapters and not self._failed_platforms: |
| self._exit_reason = adapter.fatal_error_message or "All messaging adapters disconnected" |
| if adapter.fatal_error_retryable: |
| self._exit_with_failure = True |
| logger.error("No connected messaging platforms remain. Shutting down gateway for service restart.") |
| else: |
| logger.error("No connected messaging platforms remain. Shutting down gateway cleanly.") |
| await self.stop() |
| elif not self.adapters and self._failed_platforms: |
| |
| |
| |
| if adapter.fatal_error_retryable: |
| self._exit_reason = adapter.fatal_error_message or "All messaging platforms failed with retryable errors" |
| self._exit_with_failure = True |
| logger.error( |
| "All messaging platforms failed with retryable errors. " |
| "Shutting down gateway for service restart (systemd will retry)." |
| ) |
| await self.stop() |
| else: |
| logger.warning( |
| "No connected messaging platforms remain, but %d platform(s) queued for reconnection", |
| len(self._failed_platforms), |
| ) |
|
|
| def _request_clean_exit(self, reason: str) -> None: |
| self._exit_cleanly = True |
| self._exit_reason = reason |
| self._shutdown_event.set() |
|
|
| 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 _queue_during_drain_enabled(self) -> bool: |
| return self._restart_requested and self._busy_input_mode == "queue" |
|
|
| def _update_runtime_status(self, gateway_state: Optional[str] = None, exit_reason: Optional[str] = None) -> None: |
| try: |
| from gateway.status import write_runtime_status |
| write_runtime_status( |
| gateway_state=gateway_state, |
| exit_reason=exit_reason, |
| restart_requested=self._restart_requested, |
| active_agents=self._running_agent_count(), |
| ) |
| except Exception: |
| pass |
|
|
| def _update_platform_runtime_status( |
| self, |
| platform: str, |
| *, |
| platform_state: Optional[str] = None, |
| error_code: Optional[str] = None, |
| error_message: Optional[str] = None, |
| ) -> None: |
| try: |
| from gateway.status import write_runtime_status |
| write_runtime_status( |
| platform=platform, |
| platform_state=platform_state, |
| error_code=error_code, |
| error_message=error_message, |
| ) |
| except Exception: |
| pass |
| |
| @staticmethod |
| def _load_prefill_messages() -> List[Dict[str, Any]]: |
| """Load ephemeral prefill messages from config or env var. |
| |
| Checks HERMES_PREFILL_MESSAGES_FILE env var first, then falls back to |
| the prefill_messages_file key in ~/.hermes/config.yaml. |
| Relative paths are resolved from ~/.hermes/. |
| """ |
| import json as _json |
| file_path = os.getenv("HERMES_PREFILL_MESSAGES_FILE", "") |
| if not file_path: |
| try: |
| import yaml as _y |
| cfg_path = _hermes_home / "config.yaml" |
| if cfg_path.exists(): |
| with open(cfg_path, encoding="utf-8") as _f: |
| cfg = _y.safe_load(_f) or {} |
| file_path = cfg.get("prefill_messages_file", "") |
| except Exception: |
| pass |
| if not file_path: |
| return [] |
| path = Path(file_path).expanduser() |
| if not path.is_absolute(): |
| path = _hermes_home / path |
| if not path.exists(): |
| logger.warning("Prefill messages file not found: %s", path) |
| return [] |
| try: |
| with open(path, "r", encoding="utf-8") as f: |
| data = _json.load(f) |
| if not isinstance(data, list): |
| logger.warning("Prefill messages file must contain a JSON array: %s", path) |
| return [] |
| return data |
| except Exception as e: |
| logger.warning("Failed to load prefill messages from %s: %s", path, e) |
| return [] |
|
|
| @staticmethod |
| def _load_ephemeral_system_prompt() -> str: |
| """Load ephemeral system prompt from config or env var. |
| |
| Checks HERMES_EPHEMERAL_SYSTEM_PROMPT env var first, then falls back to |
| agent.system_prompt in ~/.hermes/config.yaml. |
| """ |
| prompt = os.getenv("HERMES_EPHEMERAL_SYSTEM_PROMPT", "") |
| if prompt: |
| return prompt |
| try: |
| import yaml as _y |
| cfg_path = _hermes_home / "config.yaml" |
| if cfg_path.exists(): |
| with open(cfg_path, encoding="utf-8") as _f: |
| cfg = _y.safe_load(_f) or {} |
| return (cfg.get("agent", {}).get("system_prompt", "") or "").strip() |
| except Exception: |
| pass |
| return "" |
|
|
| @staticmethod |
| def _load_reasoning_config() -> dict | None: |
| """Load reasoning effort from config.yaml. |
| |
| Reads agent.reasoning_effort from config.yaml. Valid: "none", |
| "minimal", "low", "medium", "high", "xhigh". Returns None to use |
| default (medium). |
| """ |
| from hermes_constants import parse_reasoning_effort |
| effort = "" |
| try: |
| import yaml as _y |
| cfg_path = _hermes_home / "config.yaml" |
| if cfg_path.exists(): |
| with open(cfg_path, encoding="utf-8") as _f: |
| cfg = _y.safe_load(_f) or {} |
| effort = str(cfg.get("agent", {}).get("reasoning_effort", "") or "").strip() |
| except Exception: |
| pass |
| result = parse_reasoning_effort(effort) |
| if effort and effort.strip() and result is None: |
| logger.warning("Unknown reasoning_effort '%s', using default (medium)", effort) |
| return result |
|
|
| @staticmethod |
| def _load_service_tier() -> str | None: |
| """Load Priority Processing setting from config.yaml. |
| |
| Reads agent.service_tier from config.yaml. Accepted values mirror the CLI: |
| "fast"/"priority"/"on" => "priority", while "normal"/"off" disables it. |
| Returns None when unset or unsupported. |
| """ |
| raw = "" |
| try: |
| import yaml as _y |
| cfg_path = _hermes_home / "config.yaml" |
| if cfg_path.exists(): |
| with open(cfg_path, encoding="utf-8") as _f: |
| cfg = _y.safe_load(_f) or {} |
| raw = str(cfg.get("agent", {}).get("service_tier", "") or "").strip() |
| except Exception: |
| pass |
|
|
| value = raw.lower() |
| if not value or value in {"normal", "default", "standard", "off", "none"}: |
| return None |
| if value in {"fast", "priority", "on"}: |
| return "priority" |
| logger.warning("Unknown service_tier '%s', ignoring", raw) |
| return None |
|
|
| @staticmethod |
| def _load_show_reasoning() -> bool: |
| """Load show_reasoning toggle from config.yaml display section.""" |
| try: |
| import yaml as _y |
| cfg_path = _hermes_home / "config.yaml" |
| if cfg_path.exists(): |
| with open(cfg_path, encoding="utf-8") as _f: |
| cfg = _y.safe_load(_f) or {} |
| return bool(cfg.get("display", {}).get("show_reasoning", False)) |
| except Exception: |
| pass |
| return False |
|
|
| @staticmethod |
| def _load_busy_input_mode() -> str: |
| """Load gateway drain-time busy-input behavior from config/env.""" |
| mode = os.getenv("HERMES_GATEWAY_BUSY_INPUT_MODE", "").strip().lower() |
| if not mode: |
| try: |
| import yaml as _y |
| cfg_path = _hermes_home / "config.yaml" |
| if cfg_path.exists(): |
| with open(cfg_path, encoding="utf-8") as _f: |
| cfg = _y.safe_load(_f) or {} |
| mode = str(cfg.get("display", {}).get("busy_input_mode", "") or "").strip().lower() |
| except Exception: |
| pass |
| return "queue" if mode == "queue" else "interrupt" |
|
|
| @staticmethod |
| def _load_restart_drain_timeout() -> float: |
| """Load graceful gateway restart/stop drain timeout in seconds.""" |
| raw = os.getenv("HERMES_RESTART_DRAIN_TIMEOUT", "").strip() |
| if not raw: |
| try: |
| import yaml as _y |
| cfg_path = _hermes_home / "config.yaml" |
| if cfg_path.exists(): |
| with open(cfg_path, encoding="utf-8") as _f: |
| cfg = _y.safe_load(_f) or {} |
| raw = str(cfg.get("agent", {}).get("restart_drain_timeout", "") or "").strip() |
| except Exception: |
| pass |
| value = parse_restart_drain_timeout(raw) |
| if raw and value == DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT: |
| try: |
| float(raw) |
| except (TypeError, ValueError): |
| logger.warning( |
| "Invalid restart_drain_timeout '%s', using default %.0fs", |
| raw, |
| DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT, |
| ) |
| return value |
|
|
| @staticmethod |
| def _load_background_notifications_mode() -> str: |
| """Load background process notification mode from config or env var. |
| |
| Modes: |
| - ``all`` — push running-output updates *and* the final message (default) |
| - ``result`` — only the final completion message (regardless of exit code) |
| - ``error`` — only the final message when exit code is non-zero |
| - ``off`` — no watcher messages at all |
| """ |
| mode = os.getenv("HERMES_BACKGROUND_NOTIFICATIONS", "") |
| if not mode: |
| try: |
| import yaml as _y |
| cfg_path = _hermes_home / "config.yaml" |
| if cfg_path.exists(): |
| with open(cfg_path, encoding="utf-8") as _f: |
| cfg = _y.safe_load(_f) or {} |
| raw = cfg.get("display", {}).get("background_process_notifications") |
| if raw is False: |
| mode = "off" |
| elif raw not in (None, ""): |
| mode = str(raw) |
| except Exception: |
| pass |
| mode = (mode or "all").strip().lower() |
| valid = {"all", "result", "error", "off"} |
| if mode not in valid: |
| logger.warning( |
| "Unknown background_process_notifications '%s', defaulting to 'all'", |
| mode, |
| ) |
| return "all" |
| return mode |
|
|
| @staticmethod |
| def _load_provider_routing() -> dict: |
| """Load OpenRouter provider routing preferences from config.yaml.""" |
| try: |
| import yaml as _y |
| cfg_path = _hermes_home / "config.yaml" |
| if cfg_path.exists(): |
| with open(cfg_path, encoding="utf-8") as _f: |
| cfg = _y.safe_load(_f) or {} |
| return cfg.get("provider_routing", {}) or {} |
| except Exception: |
| pass |
| return {} |
|
|
| @staticmethod |
| def _load_fallback_model() -> list | dict | None: |
| """Load fallback provider chain from config.yaml. |
| |
| Returns a list of provider dicts (``fallback_providers``), a single |
| dict (legacy ``fallback_model``), or None if not configured. |
| AIAgent.__init__ normalizes both formats into a chain. |
| """ |
| try: |
| import yaml as _y |
| cfg_path = _hermes_home / "config.yaml" |
| if cfg_path.exists(): |
| with open(cfg_path, encoding="utf-8") as _f: |
| cfg = _y.safe_load(_f) or {} |
| fb = cfg.get("fallback_providers") or cfg.get("fallback_model") or None |
| if fb: |
| return fb |
| except Exception: |
| pass |
| return None |
|
|
| @staticmethod |
| def _load_smart_model_routing() -> dict: |
| """Load optional smart cheap-vs-strong model routing config.""" |
| try: |
| import yaml as _y |
| cfg_path = _hermes_home / "config.yaml" |
| if cfg_path.exists(): |
| with open(cfg_path, encoding="utf-8") as _f: |
| cfg = _y.safe_load(_f) or {} |
| return cfg.get("smart_model_routing", {}) or {} |
| except Exception: |
| pass |
| return {} |
|
|
| def _snapshot_running_agents(self) -> Dict[str, Any]: |
| return { |
| session_key: agent |
| for session_key, agent in self._running_agents.items() |
| if agent is not _AGENT_PENDING_SENTINEL |
| } |
|
|
| def _queue_or_replace_pending_event(self, session_key: str, event: MessageEvent) -> None: |
| adapter = self.adapters.get(event.source.platform) |
| if not adapter: |
| return |
| merge_pending_message_event(adapter._pending_messages, session_key, event) |
|
|
| async def _handle_active_session_busy_message(self, event: MessageEvent, session_key: str) -> bool: |
| if not self._draining: |
| return False |
|
|
| adapter = self.adapters.get(event.source.platform) |
| if not adapter: |
| return True |
|
|
| thread_meta = {"thread_id": event.source.thread_id} if event.source.thread_id else None |
| if self._queue_during_drain_enabled(): |
| self._queue_or_replace_pending_event(session_key, event) |
| message = f"⏳ Gateway {self._status_action_gerund()} — queued for the next turn after it comes back." |
| else: |
| message = f"⏳ Gateway is {self._status_action_gerund()} and is not accepting another turn right now." |
|
|
| await adapter._send_with_retry( |
| chat_id=event.source.chat_id, |
| content=message, |
| reply_to=event.message_id, |
| metadata=thread_meta, |
| ) |
| return True |
|
|
| async def _drain_active_agents(self, timeout: float) -> tuple[Dict[str, Any], bool]: |
| snapshot = self._snapshot_running_agents() |
| last_active_count = self._running_agent_count() |
| last_status_at = 0.0 |
|
|
| def _maybe_update_status(force: bool = False) -> None: |
| nonlocal last_active_count, last_status_at |
| now = asyncio.get_running_loop().time() |
| active_count = self._running_agent_count() |
| if force or active_count != last_active_count or (now - last_status_at) >= 1.0: |
| self._update_runtime_status("draining") |
| last_active_count = active_count |
| last_status_at = now |
|
|
| if not self._running_agents: |
| _maybe_update_status(force=True) |
| return snapshot, False |
|
|
| _maybe_update_status(force=True) |
| if timeout <= 0: |
| return snapshot, True |
|
|
| deadline = asyncio.get_running_loop().time() + timeout |
| while self._running_agents and asyncio.get_running_loop().time() < deadline: |
| _maybe_update_status() |
| await asyncio.sleep(0.1) |
| timed_out = bool(self._running_agents) |
| _maybe_update_status(force=True) |
| return snapshot, timed_out |
|
|
| def _interrupt_running_agents(self, reason: str) -> None: |
| for session_key, agent in list(self._running_agents.items()): |
| if agent is _AGENT_PENDING_SENTINEL: |
| continue |
| try: |
| agent.interrupt(reason) |
| logger.debug("Interrupted running agent for session %s during shutdown", session_key[:20]) |
| except Exception as e: |
| logger.debug("Failed interrupting agent during shutdown: %s", e) |
|
|
| def _finalize_shutdown_agents(self, active_agents: Dict[str, Any]) -> None: |
| for agent in active_agents.values(): |
| try: |
| from hermes_cli.plugins import invoke_hook as _invoke_hook |
| _invoke_hook( |
| "on_session_finalize", |
| session_id=getattr(agent, "session_id", None), |
| platform="gateway", |
| ) |
| except Exception: |
| pass |
| try: |
| if hasattr(agent, "shutdown_memory_provider"): |
| agent.shutdown_memory_provider() |
| except Exception: |
| pass |
| |
| |
| |
| try: |
| if hasattr(agent, 'close'): |
| agent.close() |
| except Exception: |
| pass |
|
|
| async def _launch_detached_restart_command(self) -> None: |
| import shutil |
| import subprocess |
|
|
| hermes_cmd = _resolve_hermes_bin() |
| if not hermes_cmd: |
| logger.error("Could not locate hermes binary for detached /restart") |
| return |
|
|
| current_pid = os.getpid() |
| cmd = " ".join(shlex.quote(part) for part in hermes_cmd) |
| shell_cmd = ( |
| f"while kill -0 {current_pid} 2>/dev/null; do sleep 0.2; done; " |
| f"{cmd} gateway restart" |
| ) |
| setsid_bin = shutil.which("setsid") |
| if setsid_bin: |
| subprocess.Popen( |
| [setsid_bin, "bash", "-lc", shell_cmd], |
| stdout=subprocess.DEVNULL, |
| stderr=subprocess.DEVNULL, |
| start_new_session=True, |
| ) |
| else: |
| subprocess.Popen( |
| ["bash", "-lc", shell_cmd], |
| stdout=subprocess.DEVNULL, |
| stderr=subprocess.DEVNULL, |
| start_new_session=True, |
| ) |
|
|
| def request_restart(self, *, detached: bool = False, via_service: bool = False) -> bool: |
| if self._restart_task_started: |
| return False |
| self._restart_requested = True |
| self._restart_detached = detached |
| self._restart_via_service = via_service |
| self._restart_task_started = True |
|
|
| async def _run_restart() -> None: |
| await asyncio.sleep(0.05) |
| await self.stop(restart=True, detached_restart=detached, service_restart=via_service) |
|
|
| task = asyncio.create_task(_run_restart()) |
| self._background_tasks.add(task) |
| task.add_done_callback(self._background_tasks.discard) |
| return True |
|
|
| async def start(self) -> bool: |
| """ |
| Start the gateway and all configured platform adapters. |
| |
| Returns True if at least one adapter connected successfully. |
| """ |
| logger.info("Starting Hermes Gateway...") |
| logger.info("Session storage: %s", self.config.sessions_dir) |
| try: |
| from hermes_cli.profiles import get_active_profile_name |
| _profile = get_active_profile_name() |
| if _profile and _profile != "default": |
| logger.info("Active profile: %s", _profile) |
| except Exception: |
| pass |
| try: |
| from gateway.status import write_runtime_status |
| write_runtime_status(gateway_state="starting", exit_reason=None) |
| except Exception: |
| pass |
| |
| |
| _any_allowlist = any( |
| os.getenv(v) |
| for v in ("TELEGRAM_ALLOWED_USERS", "DISCORD_ALLOWED_USERS", |
| "WHATSAPP_ALLOWED_USERS", "SLACK_ALLOWED_USERS", |
| "SIGNAL_ALLOWED_USERS", "SIGNAL_GROUP_ALLOWED_USERS", |
| "EMAIL_ALLOWED_USERS", |
| "SMS_ALLOWED_USERS", "MATTERMOST_ALLOWED_USERS", |
| "MATRIX_ALLOWED_USERS", "DINGTALK_ALLOWED_USERS", |
| "FEISHU_ALLOWED_USERS", |
| "WECOM_ALLOWED_USERS", |
| "WECOM_CALLBACK_ALLOWED_USERS", |
| "WEIXIN_ALLOWED_USERS", |
| "BLUEBUBBLES_ALLOWED_USERS", |
| "GATEWAY_ALLOWED_USERS") |
| ) |
| _allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in ("true", "1", "yes") or any( |
| os.getenv(v, "").lower() in ("true", "1", "yes") |
| for v in ("TELEGRAM_ALLOW_ALL_USERS", "DISCORD_ALLOW_ALL_USERS", |
| "WHATSAPP_ALLOW_ALL_USERS", "SLACK_ALLOW_ALL_USERS", |
| "SIGNAL_ALLOW_ALL_USERS", "EMAIL_ALLOW_ALL_USERS", |
| "SMS_ALLOW_ALL_USERS", "MATTERMOST_ALLOW_ALL_USERS", |
| "MATRIX_ALLOW_ALL_USERS", "DINGTALK_ALLOW_ALL_USERS", |
| "FEISHU_ALLOW_ALL_USERS", |
| "WECOM_ALLOW_ALL_USERS", |
| "WECOM_CALLBACK_ALLOW_ALL_USERS", |
| "WEIXIN_ALLOW_ALL_USERS", |
| "BLUEBUBBLES_ALLOW_ALL_USERS") |
| ) |
| if not _any_allowlist and not _allow_all: |
| logger.warning( |
| "No user allowlists configured. All unauthorized users will be denied. " |
| "Set GATEWAY_ALLOW_ALL_USERS=true in ~/.hermes/.env to allow open access, " |
| "or configure platform allowlists (e.g., TELEGRAM_ALLOWED_USERS=your_id)." |
| ) |
| |
| |
| self.hooks.discover_and_load() |
| |
| |
| try: |
| from tools.process_registry import process_registry |
| recovered = process_registry.recover_from_checkpoint() |
| if recovered: |
| logger.info("Recovered %s background process(es) from previous run", recovered) |
| except Exception as e: |
| logger.warning("Process checkpoint recovery: %s", e) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _clean_marker = _hermes_home / ".clean_shutdown" |
| if _clean_marker.exists(): |
| logger.info("Previous gateway exited cleanly — skipping session suspension") |
| try: |
| _clean_marker.unlink() |
| except Exception: |
| pass |
| else: |
| try: |
| suspended = self.session_store.suspend_recently_active() |
| if suspended: |
| logger.info("Suspended %d in-flight session(s) from previous run", suspended) |
| except Exception as e: |
| logger.warning("Session suspension on startup failed: %s", e) |
|
|
| connected_count = 0 |
| enabled_platform_count = 0 |
| startup_nonretryable_errors: list[str] = [] |
| startup_retryable_errors: list[str] = [] |
| |
| |
| for platform, platform_config in self.config.platforms.items(): |
| if not platform_config.enabled: |
| continue |
| enabled_platform_count += 1 |
| |
| adapter = self._create_adapter(platform, platform_config) |
| if not adapter: |
| logger.warning("No adapter available for %s", platform.value) |
| continue |
| |
| |
| adapter.set_message_handler(self._handle_message) |
| adapter.set_fatal_error_handler(self._handle_adapter_fatal_error) |
| adapter.set_session_store(self.session_store) |
| adapter.set_busy_session_handler(self._handle_active_session_busy_message) |
| |
| |
| logger.info("Connecting to %s...", platform.value) |
| self._update_platform_runtime_status( |
| platform.value, |
| platform_state="connecting", |
| error_code=None, |
| error_message=None, |
| ) |
| try: |
| success = await adapter.connect() |
| if success: |
| self.adapters[platform] = adapter |
| self._sync_voice_mode_state_to_adapter(adapter) |
| connected_count += 1 |
| self._update_platform_runtime_status( |
| platform.value, |
| platform_state="connected", |
| error_code=None, |
| error_message=None, |
| ) |
| logger.info("✓ %s connected", platform.value) |
| else: |
| logger.warning("✗ %s failed to connect", platform.value) |
| if adapter.has_fatal_error: |
| self._update_platform_runtime_status( |
| platform.value, |
| platform_state="retrying" if adapter.fatal_error_retryable else "fatal", |
| error_code=adapter.fatal_error_code, |
| error_message=adapter.fatal_error_message, |
| ) |
| target = ( |
| startup_retryable_errors |
| if adapter.fatal_error_retryable |
| else startup_nonretryable_errors |
| ) |
| target.append( |
| f"{platform.value}: {adapter.fatal_error_message}" |
| ) |
| |
| if adapter.fatal_error_retryable: |
| self._failed_platforms[platform] = { |
| "config": platform_config, |
| "attempts": 1, |
| "next_retry": time.monotonic() + 30, |
| } |
| else: |
| self._update_platform_runtime_status( |
| platform.value, |
| platform_state="retrying", |
| error_code=None, |
| error_message="failed to connect", |
| ) |
| startup_retryable_errors.append( |
| f"{platform.value}: failed to connect" |
| ) |
| |
| self._failed_platforms[platform] = { |
| "config": platform_config, |
| "attempts": 1, |
| "next_retry": time.monotonic() + 30, |
| } |
| except Exception as e: |
| logger.error("✗ %s error: %s", platform.value, e) |
| self._update_platform_runtime_status( |
| platform.value, |
| platform_state="retrying", |
| error_code=None, |
| error_message=str(e), |
| ) |
| startup_retryable_errors.append(f"{platform.value}: {e}") |
| |
| self._failed_platforms[platform] = { |
| "config": platform_config, |
| "attempts": 1, |
| "next_retry": time.monotonic() + 30, |
| } |
| |
| if connected_count == 0: |
| if startup_nonretryable_errors: |
| reason = "; ".join(startup_nonretryable_errors) |
| logger.error("Gateway hit a non-retryable startup conflict: %s", reason) |
| try: |
| from gateway.status import write_runtime_status |
| write_runtime_status(gateway_state="startup_failed", exit_reason=reason) |
| except Exception: |
| pass |
| self._request_clean_exit(reason) |
| return True |
| if enabled_platform_count > 0: |
| reason = "; ".join(startup_retryable_errors) or "all configured messaging platforms failed to connect" |
| logger.error("Gateway failed to connect any configured messaging platform: %s", reason) |
| try: |
| from gateway.status import write_runtime_status |
| write_runtime_status(gateway_state="startup_failed", exit_reason=reason) |
| except Exception: |
| pass |
| return False |
| logger.warning("No messaging platforms enabled.") |
| logger.info("Gateway will continue running for cron job execution.") |
| |
| |
| self.delivery_router.adapters = self.adapters |
| |
| self._running = True |
| self._update_runtime_status("running") |
| |
| |
| hook_count = len(self.hooks.loaded_hooks) |
| if hook_count: |
| logger.info("%s hook(s) loaded", hook_count) |
| await self.hooks.emit("gateway:startup", { |
| "platforms": [p.value for p in self.adapters.keys()], |
| }) |
| |
| if connected_count > 0: |
| logger.info("Gateway running with %s platform(s)", connected_count) |
| |
| |
| try: |
| from gateway.channel_directory import build_channel_directory |
| directory = build_channel_directory(self.adapters) |
| ch_count = sum(len(chs) for chs in directory.get("platforms", {}).values()) |
| logger.info("Channel directory built: %d target(s)", ch_count) |
| except Exception as e: |
| logger.warning("Channel directory build failed: %s", e) |
| |
| |
| |
| notified = await self._send_update_notification() |
| if not notified and any( |
| path.exists() |
| for path in ( |
| _hermes_home / ".update_pending.json", |
| _hermes_home / ".update_pending.claimed.json", |
| ) |
| ): |
| self._schedule_update_notification_watch() |
|
|
| |
| await self._send_restart_notification() |
|
|
| |
| try: |
| from tools.process_registry import process_registry |
| while process_registry.pending_watchers: |
| watcher = process_registry.pending_watchers.pop(0) |
| asyncio.create_task(self._run_process_watcher(watcher)) |
| logger.info("Resumed watcher for recovered process %s", watcher.get("session_id")) |
| except Exception as e: |
| logger.error("Recovered watcher setup error: %s", e) |
|
|
| |
| asyncio.create_task(self._session_expiry_watcher()) |
|
|
| |
| if self._failed_platforms: |
| logger.info( |
| "Starting reconnection watcher for %d failed platform(s): %s", |
| len(self._failed_platforms), |
| ", ".join(p.value for p in self._failed_platforms), |
| ) |
| asyncio.create_task(self._platform_reconnect_watcher()) |
|
|
| logger.info("Press Ctrl+C to stop") |
| |
| return True |
| |
| async def _session_expiry_watcher(self, interval: int = 300): |
| """Background task that proactively flushes memories for expired sessions. |
| |
| Runs every `interval` seconds (default 5 min). For each session that |
| has expired according to its reset policy, flushes memories in a thread |
| pool and marks the session so it won't be flushed again. |
| |
| This means memories are already saved by the time the user sends their |
| next message, so there's no blocking delay. |
| """ |
| await asyncio.sleep(60) |
| _flush_failures: dict[str, int] = {} |
| _MAX_FLUSH_RETRIES = 3 |
| while self._running: |
| try: |
| self.session_store._ensure_loaded() |
| |
| _expired_entries = [] |
| for key, entry in list(self.session_store._entries.items()): |
| if entry.memory_flushed: |
| continue |
| if not self.session_store._is_session_expired(entry): |
| continue |
| _expired_entries.append((key, entry)) |
|
|
| if _expired_entries: |
| |
| |
| _platforms: dict[str, int] = {} |
| for _k, _e in _expired_entries: |
| _parts = _k.split(":") |
| _plat = _parts[2] if len(_parts) > 2 else "unknown" |
| _platforms[_plat] = _platforms.get(_plat, 0) + 1 |
| _plat_summary = ", ".join( |
| f"{p}:{c}" for p, c in sorted(_platforms.items()) |
| ) |
| logger.info( |
| "Session expiry: %d sessions to flush (%s)", |
| len(_expired_entries), _plat_summary, |
| ) |
|
|
| for key, entry in _expired_entries: |
| try: |
| await self._async_flush_memories(entry.session_id, key) |
| |
| |
| |
| _cached_agent = None |
| _cache_lock = getattr(self, "_agent_cache_lock", None) |
| if _cache_lock is not None: |
| with _cache_lock: |
| _cached = self._agent_cache.get(key) |
| _cached_agent = _cached[0] if isinstance(_cached, tuple) else _cached if _cached else None |
| |
| |
| if _cached_agent is None: |
| _cached_agent = self._running_agents.get(key) |
| if _cached_agent and _cached_agent is not _AGENT_PENDING_SENTINEL: |
| try: |
| if hasattr(_cached_agent, 'shutdown_memory_provider'): |
| _cached_agent.shutdown_memory_provider() |
| except Exception: |
| pass |
| try: |
| if hasattr(_cached_agent, 'close'): |
| _cached_agent.close() |
| except Exception: |
| pass |
| |
| |
| with self.session_store._lock: |
| entry.memory_flushed = True |
| self.session_store._save() |
| logger.debug( |
| "Memory flush completed for session %s", |
| entry.session_id, |
| ) |
| _flush_failures.pop(entry.session_id, None) |
| except Exception as e: |
| failures = _flush_failures.get(entry.session_id, 0) + 1 |
| _flush_failures[entry.session_id] = failures |
| if failures >= _MAX_FLUSH_RETRIES: |
| logger.warning( |
| "Memory flush gave up after %d attempts for %s: %s. " |
| "Marking as flushed to prevent infinite retry loop.", |
| failures, entry.session_id, e, |
| ) |
| with self.session_store._lock: |
| entry.memory_flushed = True |
| self.session_store._save() |
| _flush_failures.pop(entry.session_id, None) |
| else: |
| logger.debug( |
| "Memory flush failed (%d/%d) for %s: %s", |
| failures, _MAX_FLUSH_RETRIES, entry.session_id, e, |
| ) |
|
|
| if _expired_entries: |
| _flushed = sum( |
| 1 for _, e in _expired_entries if e.memory_flushed |
| ) |
| _failed = len(_expired_entries) - _flushed |
| if _failed: |
| logger.info( |
| "Session expiry done: %d flushed, %d pending retry", |
| _flushed, _failed, |
| ) |
| else: |
| logger.info( |
| "Session expiry done: %d flushed", _flushed, |
| ) |
| except Exception as e: |
| logger.debug("Session expiry watcher error: %s", e) |
| |
| for _ in range(interval): |
| if not self._running: |
| break |
| await asyncio.sleep(1) |
|
|
| async def _platform_reconnect_watcher(self) -> None: |
| """Background task that periodically retries connecting failed platforms. |
| |
| Uses exponential backoff: 30s → 60s → 120s → 240s → 300s (cap). |
| Stops retrying a platform after 20 failed attempts or if the error |
| is non-retryable (e.g. bad auth token). |
| """ |
| _MAX_ATTEMPTS = 20 |
| _BACKOFF_CAP = 300 |
|
|
| await asyncio.sleep(10) |
| while self._running: |
| if not self._failed_platforms: |
| |
| for _ in range(30): |
| if not self._running: |
| return |
| await asyncio.sleep(1) |
| continue |
|
|
| now = time.monotonic() |
| for platform in list(self._failed_platforms.keys()): |
| if not self._running: |
| return |
| info = self._failed_platforms[platform] |
| if now < info["next_retry"]: |
| continue |
|
|
| if info["attempts"] >= _MAX_ATTEMPTS: |
| logger.warning( |
| "Giving up reconnecting %s after %d attempts", |
| platform.value, info["attempts"], |
| ) |
| del self._failed_platforms[platform] |
| continue |
|
|
| platform_config = info["config"] |
| attempt = info["attempts"] + 1 |
| logger.info( |
| "Reconnecting %s (attempt %d/%d)...", |
| platform.value, attempt, _MAX_ATTEMPTS, |
| ) |
|
|
| try: |
| adapter = self._create_adapter(platform, platform_config) |
| if not adapter: |
| logger.warning( |
| "Reconnect %s: adapter creation returned None, removing from retry queue", |
| platform.value, |
| ) |
| del self._failed_platforms[platform] |
| continue |
|
|
| adapter.set_message_handler(self._handle_message) |
| adapter.set_fatal_error_handler(self._handle_adapter_fatal_error) |
| adapter.set_session_store(self.session_store) |
| adapter.set_busy_session_handler(self._handle_active_session_busy_message) |
|
|
| success = await adapter.connect() |
| if success: |
| self.adapters[platform] = adapter |
| self._sync_voice_mode_state_to_adapter(adapter) |
| self.delivery_router.adapters = self.adapters |
| del self._failed_platforms[platform] |
| self._update_platform_runtime_status( |
| platform.value, |
| platform_state="connected", |
| error_code=None, |
| error_message=None, |
| ) |
| logger.info("✓ %s reconnected successfully", platform.value) |
|
|
| |
| try: |
| from gateway.channel_directory import build_channel_directory |
| build_channel_directory(self.adapters) |
| except Exception: |
| pass |
| else: |
| |
| if adapter.has_fatal_error and not adapter.fatal_error_retryable: |
| self._update_platform_runtime_status( |
| platform.value, |
| platform_state="fatal", |
| error_code=adapter.fatal_error_code, |
| error_message=adapter.fatal_error_message, |
| ) |
| logger.warning( |
| "Reconnect %s: non-retryable error (%s), removing from retry queue", |
| platform.value, adapter.fatal_error_message, |
| ) |
| del self._failed_platforms[platform] |
| else: |
| self._update_platform_runtime_status( |
| platform.value, |
| platform_state="retrying", |
| error_code=adapter.fatal_error_code, |
| error_message=adapter.fatal_error_message or "failed to reconnect", |
| ) |
| backoff = min(30 * (2 ** (attempt - 1)), _BACKOFF_CAP) |
| info["attempts"] = attempt |
| info["next_retry"] = time.monotonic() + backoff |
| logger.info( |
| "Reconnect %s failed, next retry in %ds", |
| platform.value, backoff, |
| ) |
| except Exception as e: |
| self._update_platform_runtime_status( |
| platform.value, |
| platform_state="retrying", |
| error_code=None, |
| error_message=str(e), |
| ) |
| backoff = min(30 * (2 ** (attempt - 1)), _BACKOFF_CAP) |
| info["attempts"] = attempt |
| info["next_retry"] = time.monotonic() + backoff |
| logger.warning( |
| "Reconnect %s error: %s, next retry in %ds", |
| platform.value, e, backoff, |
| ) |
|
|
| |
| for _ in range(10): |
| if not self._running: |
| return |
| await asyncio.sleep(1) |
|
|
| async def stop( |
| self, |
| *, |
| restart: bool = False, |
| detached_restart: bool = False, |
| service_restart: bool = False, |
| ) -> None: |
| """Stop the gateway and disconnect all adapters.""" |
| if restart: |
| self._restart_requested = True |
| self._restart_detached = detached_restart |
| self._restart_via_service = service_restart |
| if self._stop_task is not None: |
| await self._stop_task |
| return |
|
|
| async def _stop_impl() -> None: |
| logger.info( |
| "Stopping gateway%s...", |
| " for restart" if self._restart_requested else "", |
| ) |
| self._running = False |
| self._draining = True |
|
|
| timeout = self._restart_drain_timeout |
| active_agents, timed_out = await self._drain_active_agents(timeout) |
| if timed_out: |
| logger.warning( |
| "Gateway drain timed out after %.1fs with %d active agent(s); interrupting remaining work.", |
| timeout, |
| self._running_agent_count(), |
| ) |
| self._interrupt_running_agents( |
| "Gateway restarting" if self._restart_requested else "Gateway shutting down" |
| ) |
| interrupt_deadline = asyncio.get_running_loop().time() + 5.0 |
| while self._running_agents and asyncio.get_running_loop().time() < interrupt_deadline: |
| self._update_runtime_status("draining") |
| await asyncio.sleep(0.1) |
|
|
| if self._restart_requested and self._restart_detached: |
| try: |
| await self._launch_detached_restart_command() |
| except Exception as e: |
| logger.error("Failed to launch detached gateway restart: %s", e) |
|
|
| self._finalize_shutdown_agents(active_agents) |
|
|
| for platform, adapter in list(self.adapters.items()): |
| try: |
| await adapter.cancel_background_tasks() |
| except Exception as e: |
| logger.debug("✗ %s background-task cancel error: %s", platform.value, e) |
| try: |
| await adapter.disconnect() |
| logger.info("✓ %s disconnected", platform.value) |
| except Exception as e: |
| logger.error("✗ %s disconnect error: %s", platform.value, e) |
|
|
| for _task in list(self._background_tasks): |
| if _task is self._stop_task: |
| continue |
| _task.cancel() |
| self._background_tasks.clear() |
|
|
| self.adapters.clear() |
| self._running_agents.clear() |
| self._pending_messages.clear() |
| self._pending_approvals.clear() |
| self._shutdown_event.set() |
|
|
| |
| |
| try: |
| from tools.process_registry import process_registry |
| process_registry.kill_all() |
| except Exception: |
| pass |
| try: |
| from tools.terminal_tool import cleanup_all_environments |
| cleanup_all_environments() |
| except Exception: |
| pass |
| try: |
| from tools.browser_tool import cleanup_all_browsers |
| cleanup_all_browsers() |
| except Exception: |
| pass |
|
|
| from gateway.status import remove_pid_file |
| remove_pid_file() |
|
|
| |
| |
| |
| |
| try: |
| (_hermes_home / ".clean_shutdown").touch() |
| except Exception: |
| pass |
|
|
| if self._restart_requested and self._restart_via_service: |
| self._exit_code = GATEWAY_SERVICE_RESTART_EXIT_CODE |
| self._exit_reason = self._exit_reason or "Gateway restart requested" |
|
|
| self._draining = False |
| self._update_runtime_status("stopped", self._exit_reason) |
| logger.info("Gateway stopped") |
|
|
| self._stop_task = asyncio.create_task(_stop_impl()) |
| await self._stop_task |
| |
| async def wait_for_shutdown(self) -> None: |
| """Wait for shutdown signal.""" |
| await self._shutdown_event.wait() |
| |
| def _create_adapter( |
| self, |
| platform: Platform, |
| config: Any |
| ) -> Optional[BasePlatformAdapter]: |
| """Create the appropriate adapter for a platform.""" |
| if hasattr(config, "extra") and isinstance(config.extra, dict): |
| config.extra.setdefault( |
| "group_sessions_per_user", |
| self.config.group_sessions_per_user, |
| ) |
| config.extra.setdefault( |
| "thread_sessions_per_user", |
| getattr(self.config, "thread_sessions_per_user", False), |
| ) |
|
|
| if platform == Platform.TELEGRAM: |
| from gateway.platforms.telegram import TelegramAdapter, check_telegram_requirements |
| if not check_telegram_requirements(): |
| logger.warning("Telegram: python-telegram-bot not installed") |
| return None |
| return TelegramAdapter(config) |
| |
| elif platform == Platform.DISCORD: |
| from gateway.platforms.discord import DiscordAdapter, check_discord_requirements |
| if not check_discord_requirements(): |
| logger.warning("Discord: discord.py not installed") |
| return None |
| return DiscordAdapter(config) |
| |
| elif platform == Platform.WHATSAPP: |
| from gateway.platforms.whatsapp import WhatsAppAdapter, check_whatsapp_requirements |
| if not check_whatsapp_requirements(): |
| logger.warning("WhatsApp: Node.js not installed or bridge not configured") |
| return None |
| return WhatsAppAdapter(config) |
| |
| elif platform == Platform.SLACK: |
| from gateway.platforms.slack import SlackAdapter, check_slack_requirements |
| if not check_slack_requirements(): |
| logger.warning("Slack: slack-bolt not installed. Run: pip install 'hermes-agent[slack]'") |
| return None |
| return SlackAdapter(config) |
|
|
| elif platform == Platform.SIGNAL: |
| from gateway.platforms.signal import SignalAdapter, check_signal_requirements |
| if not check_signal_requirements(): |
| logger.warning("Signal: SIGNAL_HTTP_URL or SIGNAL_ACCOUNT not configured") |
| return None |
| return SignalAdapter(config) |
|
|
| elif platform == Platform.HOMEASSISTANT: |
| from gateway.platforms.homeassistant import HomeAssistantAdapter, check_ha_requirements |
| if not check_ha_requirements(): |
| logger.warning("HomeAssistant: aiohttp not installed or HASS_TOKEN not set") |
| return None |
| return HomeAssistantAdapter(config) |
|
|
| elif platform == Platform.EMAIL: |
| from gateway.platforms.email import EmailAdapter, check_email_requirements |
| if not check_email_requirements(): |
| logger.warning("Email: EMAIL_ADDRESS, EMAIL_PASSWORD, EMAIL_IMAP_HOST, or EMAIL_SMTP_HOST not set") |
| return None |
| return EmailAdapter(config) |
|
|
| elif platform == Platform.SMS: |
| from gateway.platforms.sms import SmsAdapter, check_sms_requirements |
| if not check_sms_requirements(): |
| logger.warning("SMS: aiohttp not installed or TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN not set") |
| return None |
| return SmsAdapter(config) |
|
|
| elif platform == Platform.DINGTALK: |
| from gateway.platforms.dingtalk import DingTalkAdapter, check_dingtalk_requirements |
| if not check_dingtalk_requirements(): |
| logger.warning("DingTalk: dingtalk-stream not installed or DINGTALK_CLIENT_ID/SECRET not set") |
| return None |
| return DingTalkAdapter(config) |
|
|
| elif platform == Platform.FEISHU: |
| from gateway.platforms.feishu import FeishuAdapter, check_feishu_requirements |
| if not check_feishu_requirements(): |
| logger.warning("Feishu: lark-oapi not installed or FEISHU_APP_ID/SECRET not set") |
| return None |
| return FeishuAdapter(config) |
|
|
| elif platform == Platform.WECOM_CALLBACK: |
| from gateway.platforms.wecom_callback import ( |
| WecomCallbackAdapter, |
| check_wecom_callback_requirements, |
| ) |
| if not check_wecom_callback_requirements(): |
| logger.warning("WeComCallback: aiohttp/httpx not installed") |
| return None |
| return WecomCallbackAdapter(config) |
|
|
| elif platform == Platform.WECOM: |
| from gateway.platforms.wecom import WeComAdapter, check_wecom_requirements |
| if not check_wecom_requirements(): |
| logger.warning("WeCom: aiohttp not installed or WECOM_BOT_ID/SECRET not set") |
| return None |
| return WeComAdapter(config) |
|
|
| elif platform == Platform.WEIXIN: |
| from gateway.platforms.weixin import WeixinAdapter, check_weixin_requirements |
| if not check_weixin_requirements(): |
| logger.warning("Weixin: aiohttp/cryptography not installed") |
| return None |
| return WeixinAdapter(config) |
|
|
| elif platform == Platform.MATTERMOST: |
| from gateway.platforms.mattermost import MattermostAdapter, check_mattermost_requirements |
| if not check_mattermost_requirements(): |
| logger.warning("Mattermost: MATTERMOST_TOKEN or MATTERMOST_URL not set, or aiohttp missing") |
| return None |
| return MattermostAdapter(config) |
|
|
| elif platform == Platform.MATRIX: |
| from gateway.platforms.matrix import MatrixAdapter, check_matrix_requirements |
| if not check_matrix_requirements(): |
| logger.warning("Matrix: mautrix not installed or credentials not set. Run: pip install 'mautrix[encryption]'") |
| return None |
| return MatrixAdapter(config) |
|
|
| elif platform == Platform.API_SERVER: |
| from gateway.platforms.api_server import APIServerAdapter, check_api_server_requirements |
| if not check_api_server_requirements(): |
| logger.warning("API Server: aiohttp not installed") |
| return None |
| return APIServerAdapter(config) |
|
|
| elif platform == Platform.WEBHOOK: |
| from gateway.platforms.webhook import WebhookAdapter, check_webhook_requirements |
| if not check_webhook_requirements(): |
| logger.warning("Webhook: aiohttp not installed") |
| return None |
| adapter = WebhookAdapter(config) |
| adapter.gateway_runner = self |
| return adapter |
|
|
| elif platform == Platform.BLUEBUBBLES: |
| from gateway.platforms.bluebubbles import BlueBubblesAdapter, check_bluebubbles_requirements |
| if not check_bluebubbles_requirements(): |
| logger.warning("BlueBubbles: aiohttp/httpx missing or BLUEBUBBLES_SERVER_URL/BLUEBUBBLES_PASSWORD not configured") |
| return None |
| return BlueBubblesAdapter(config) |
|
|
| return None |
| |
| def _is_user_authorized(self, source: SessionSource) -> bool: |
| """ |
| Check if a user is authorized to use the bot. |
| |
| Checks in order: |
| 1. Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true) |
| 2. Environment variable allowlists (TELEGRAM_ALLOWED_USERS, etc.) |
| 3. DM pairing approved list |
| 4. Global allow-all (GATEWAY_ALLOW_ALL_USERS=true) |
| 5. Default: deny |
| """ |
| |
| |
| |
| |
| |
| if source.platform in (Platform.HOMEASSISTANT, Platform.WEBHOOK): |
| return True |
|
|
| user_id = source.user_id |
| if not user_id: |
| return False |
|
|
| platform_env_map = { |
| Platform.TELEGRAM: "TELEGRAM_ALLOWED_USERS", |
| Platform.DISCORD: "DISCORD_ALLOWED_USERS", |
| Platform.WHATSAPP: "WHATSAPP_ALLOWED_USERS", |
| Platform.SLACK: "SLACK_ALLOWED_USERS", |
| Platform.SIGNAL: "SIGNAL_ALLOWED_USERS", |
| Platform.EMAIL: "EMAIL_ALLOWED_USERS", |
| Platform.SMS: "SMS_ALLOWED_USERS", |
| Platform.MATTERMOST: "MATTERMOST_ALLOWED_USERS", |
| Platform.MATRIX: "MATRIX_ALLOWED_USERS", |
| Platform.DINGTALK: "DINGTALK_ALLOWED_USERS", |
| Platform.FEISHU: "FEISHU_ALLOWED_USERS", |
| Platform.WECOM: "WECOM_ALLOWED_USERS", |
| Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOWED_USERS", |
| Platform.WEIXIN: "WEIXIN_ALLOWED_USERS", |
| Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOWED_USERS", |
| } |
| platform_allow_all_map = { |
| Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS", |
| Platform.DISCORD: "DISCORD_ALLOW_ALL_USERS", |
| Platform.WHATSAPP: "WHATSAPP_ALLOW_ALL_USERS", |
| Platform.SLACK: "SLACK_ALLOW_ALL_USERS", |
| Platform.SIGNAL: "SIGNAL_ALLOW_ALL_USERS", |
| Platform.EMAIL: "EMAIL_ALLOW_ALL_USERS", |
| Platform.SMS: "SMS_ALLOW_ALL_USERS", |
| Platform.MATTERMOST: "MATTERMOST_ALLOW_ALL_USERS", |
| Platform.MATRIX: "MATRIX_ALLOW_ALL_USERS", |
| Platform.DINGTALK: "DINGTALK_ALLOW_ALL_USERS", |
| Platform.FEISHU: "FEISHU_ALLOW_ALL_USERS", |
| Platform.WECOM: "WECOM_ALLOW_ALL_USERS", |
| Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOW_ALL_USERS", |
| Platform.WEIXIN: "WEIXIN_ALLOW_ALL_USERS", |
| Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOW_ALL_USERS", |
| } |
|
|
| |
| platform_allow_all_var = platform_allow_all_map.get(source.platform, "") |
| if platform_allow_all_var and os.getenv(platform_allow_all_var, "").lower() in ("true", "1", "yes"): |
| return True |
|
|
| |
| platform_name = source.platform.value if source.platform else "" |
| if self.pairing_store.is_approved(platform_name, user_id): |
| return True |
|
|
| |
| platform_allowlist = os.getenv(platform_env_map.get(source.platform, ""), "").strip() |
| global_allowlist = os.getenv("GATEWAY_ALLOWED_USERS", "").strip() |
|
|
| if not platform_allowlist and not global_allowlist: |
| |
| return os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in ("true", "1", "yes") |
|
|
| |
| allowed_ids = set() |
| if platform_allowlist: |
| allowed_ids.update(uid.strip() for uid in platform_allowlist.split(",") if uid.strip()) |
| if global_allowlist: |
| allowed_ids.update(uid.strip() for uid in global_allowlist.split(",") if uid.strip()) |
|
|
| |
| |
| if "*" in allowed_ids: |
| return True |
|
|
| check_ids = {user_id} |
| if "@" in user_id: |
| check_ids.add(user_id.split("@")[0]) |
|
|
| |
| if source.platform == Platform.WHATSAPP: |
| normalized_allowed_ids = set() |
| for allowed_id in allowed_ids: |
| normalized_allowed_ids.update(_expand_whatsapp_auth_aliases(allowed_id)) |
| if normalized_allowed_ids: |
| allowed_ids = normalized_allowed_ids |
|
|
| check_ids.update(_expand_whatsapp_auth_aliases(user_id)) |
| normalized_user_id = _normalize_whatsapp_identifier(user_id) |
| if normalized_user_id: |
| check_ids.add(normalized_user_id) |
|
|
| return bool(check_ids & allowed_ids) |
|
|
| def _get_unauthorized_dm_behavior(self, platform: Optional[Platform]) -> str: |
| """Return how unauthorized DMs should be handled for a platform.""" |
| config = getattr(self, "config", None) |
| if config and hasattr(config, "get_unauthorized_dm_behavior"): |
| return config.get_unauthorized_dm_behavior(platform) |
| return "pair" |
| |
| async def _handle_message(self, event: MessageEvent) -> Optional[str]: |
| """ |
| Handle an incoming message from any platform. |
| |
| This is the core message processing pipeline: |
| 1. Check user authorization |
| 2. Check for commands (/new, /reset, etc.) |
| 3. Check for running agent and interrupt if needed |
| 4. Get or create session |
| 5. Build context for agent |
| 6. Run agent conversation |
| 7. Return response |
| """ |
| source = event.source |
|
|
| |
| |
| if getattr(event, "internal", False): |
| pass |
| elif source.user_id is None: |
| |
| |
| |
| |
| logger.debug("Ignoring message with no user_id from %s", source.platform.value) |
| return None |
| elif not self._is_user_authorized(source): |
| logger.warning("Unauthorized user: %s (%s) on %s", source.user_id, source.user_name, source.platform.value) |
| |
| if source.chat_type == "dm" and self._get_unauthorized_dm_behavior(source.platform) == "pair": |
| platform_name = source.platform.value if source.platform else "unknown" |
| |
| |
| |
| if self.pairing_store._is_rate_limited(platform_name, source.user_id): |
| return None |
| code = self.pairing_store.generate_code( |
| platform_name, source.user_id, source.user_name or "" |
| ) |
| if code: |
| adapter = self.adapters.get(source.platform) |
| if adapter: |
| await adapter.send( |
| source.chat_id, |
| f"Hi~ I don't recognize you yet!\n\n" |
| f"Here's your pairing code: `{code}`\n\n" |
| f"Ask the bot owner to run:\n" |
| f"`hermes pairing approve {platform_name} {code}`" |
| ) |
| else: |
| adapter = self.adapters.get(source.platform) |
| if adapter: |
| await adapter.send( |
| source.chat_id, |
| "Too many pairing requests right now~ " |
| "Please try again later!" |
| ) |
| |
| self.pairing_store._record_rate_limit(platform_name, source.user_id) |
| return None |
| |
| |
| |
| |
| |
| _quick_key = self._session_key_for_source(source) |
| _update_prompts = getattr(self, "_update_prompt_pending", {}) |
| if _update_prompts.get(_quick_key): |
| raw = (event.text or "").strip() |
| |
| cmd = event.get_command() |
| if cmd in ("approve", "yes"): |
| response_text = "y" |
| elif cmd in ("deny", "no"): |
| response_text = "n" |
| else: |
| response_text = raw |
| if response_text: |
| response_path = _hermes_home / ".update_response" |
| try: |
| tmp = response_path.with_suffix(".tmp") |
| tmp.write_text(response_text) |
| tmp.replace(response_path) |
| except OSError as e: |
| logger.warning("Failed to write update response: %s", e) |
| return f"✗ Failed to send response to update process: {e}" |
| _update_prompts.pop(_quick_key, None) |
| label = response_text if len(response_text) <= 20 else response_text[:20] + "…" |
| return f"✓ Sent `{label}` to the update process." |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| _raw_stale_timeout = float(os.getenv("HERMES_AGENT_TIMEOUT", 1800)) |
| _stale_ts = self._running_agents_ts.get(_quick_key, 0) |
| if _quick_key in self._running_agents and _stale_ts: |
| _stale_age = time.time() - _stale_ts |
| _stale_agent = self._running_agents.get(_quick_key) |
| |
| |
| |
| |
| |
| _stale_idle = float("inf") |
| _stale_detail = "" |
| if _stale_agent and hasattr(_stale_agent, "get_activity_summary"): |
| try: |
| _sa = _stale_agent.get_activity_summary() |
| _stale_idle = _sa.get("seconds_since_activity", float("inf")) |
| _stale_detail = ( |
| f" | last_activity={_sa.get('last_activity_desc', 'unknown')} " |
| f"({_stale_idle:.0f}s ago) " |
| f"| iteration={_sa.get('api_call_count', 0)}/{_sa.get('max_iterations', 0)}" |
| ) |
| except Exception: |
| pass |
| |
| |
| |
| _wall_ttl = max(_raw_stale_timeout * 10, 7200) if _raw_stale_timeout > 0 else float("inf") |
| _should_evict = ( |
| _stale_agent is not _AGENT_PENDING_SENTINEL |
| and ( |
| (_raw_stale_timeout > 0 and _stale_idle >= _raw_stale_timeout) |
| or _stale_age > _wall_ttl |
| ) |
| ) |
| if _should_evict: |
| logger.warning( |
| "Evicting stale _running_agents entry for %s " |
| "(age: %.0fs, idle: %.0fs, timeout: %.0fs)%s", |
| _quick_key[:30], _stale_age, _stale_idle, |
| _raw_stale_timeout, _stale_detail, |
| ) |
| del self._running_agents[_quick_key] |
| self._running_agents_ts.pop(_quick_key, None) |
|
|
| if _quick_key in self._running_agents: |
| if event.get_command() == "status": |
| return await self._handle_status_command(event) |
|
|
| |
| from hermes_cli.commands import resolve_command as _resolve_cmd_inner |
| _evt_cmd = event.get_command() |
| _cmd_def_inner = _resolve_cmd_inner(_evt_cmd) if _evt_cmd else None |
|
|
| if _cmd_def_inner and _cmd_def_inner.name == "restart": |
| return await self._handle_restart_command(event) |
|
|
| |
| |
| |
| |
| |
| if _cmd_def_inner and _cmd_def_inner.name == "stop": |
| running_agent = self._running_agents.get(_quick_key) |
| if running_agent and running_agent is not _AGENT_PENDING_SENTINEL: |
| running_agent.interrupt("Stop requested") |
| |
| adapter = self.adapters.get(source.platform) |
| if adapter and hasattr(adapter, 'get_pending_message'): |
| adapter.get_pending_message(_quick_key) |
| self._pending_messages.pop(_quick_key, None) |
| if _quick_key in self._running_agents: |
| del self._running_agents[_quick_key] |
| |
| |
| self.session_store.suspend_session(_quick_key) |
| logger.info("HARD STOP for session %s — suspended, session lock released", _quick_key[:20]) |
| return "⚡ Force-stopped. The session is suspended — your next message will start fresh." |
|
|
| |
| |
| |
| |
| |
| |
| |
| if _cmd_def_inner and _cmd_def_inner.name == "new": |
| running_agent = self._running_agents.get(_quick_key) |
| if running_agent and running_agent is not _AGENT_PENDING_SENTINEL: |
| running_agent.interrupt("Session reset requested") |
| |
| adapter = self.adapters.get(source.platform) |
| if adapter and hasattr(adapter, 'get_pending_message'): |
| adapter.get_pending_message(_quick_key) |
| self._pending_messages.pop(_quick_key, None) |
| |
| |
| if _quick_key in self._running_agents: |
| del self._running_agents[_quick_key] |
| return await self._handle_reset_command(event) |
|
|
| |
| if event.get_command() in ("queue", "q"): |
| queued_text = event.get_command_args().strip() |
| if not queued_text: |
| return "Usage: /queue <prompt>" |
| adapter = self.adapters.get(source.platform) |
| if adapter: |
| from gateway.platforms.base import MessageEvent as _ME, MessageType as _MT |
| queued_event = _ME( |
| text=queued_text, |
| message_type=_MT.TEXT, |
| source=event.source, |
| message_id=event.message_id, |
| ) |
| adapter._pending_messages[_quick_key] = queued_event |
| return "Queued for the next turn." |
|
|
| |
| if _cmd_def_inner and _cmd_def_inner.name == "model": |
| return "Agent is running — wait or /stop first, then switch models." |
|
|
| |
| |
| |
| |
| if _cmd_def_inner and _cmd_def_inner.name in ("approve", "deny"): |
| if _cmd_def_inner.name == "approve": |
| return await self._handle_approve_command(event) |
| return await self._handle_deny_command(event) |
|
|
| |
| |
| if _cmd_def_inner and _cmd_def_inner.name == "background": |
| return await self._handle_background_command(event) |
|
|
| if event.message_type == MessageType.PHOTO: |
| logger.debug("PRIORITY photo follow-up for session %s — queueing without interrupt", _quick_key[:20]) |
| adapter = self.adapters.get(source.platform) |
| if adapter: |
| merge_pending_message_event(adapter._pending_messages, _quick_key, event) |
| return None |
|
|
| running_agent = self._running_agents.get(_quick_key) |
| if running_agent is _AGENT_PENDING_SENTINEL: |
| |
| if event.get_command() == "stop": |
| |
| if _quick_key in self._running_agents: |
| del self._running_agents[_quick_key] |
| logger.info("HARD STOP (pending) for session %s — sentinel cleared", _quick_key[:20]) |
| return "⚡ Force-stopped. The agent was still starting — session unlocked." |
| |
| |
| adapter = self.adapters.get(source.platform) |
| if adapter: |
| adapter._pending_messages[_quick_key] = event |
| return None |
| if self._draining: |
| if self._queue_during_drain_enabled(): |
| self._queue_or_replace_pending_event(_quick_key, event) |
| return ( |
| f"⏳ Gateway {self._status_action_gerund()} — queued for the next turn after it comes back." |
| if self._queue_during_drain_enabled() |
| else f"⏳ Gateway is {self._status_action_gerund()} and is not accepting another turn right now." |
| ) |
| logger.debug("PRIORITY interrupt for session %s", _quick_key[:20]) |
| running_agent.interrupt(event.text) |
| if _quick_key in self._pending_messages: |
| self._pending_messages[_quick_key] += "\n" + event.text |
| else: |
| self._pending_messages[_quick_key] = event.text |
| return None |
|
|
| |
| command = event.get_command() |
| |
| |
| |
| |
| from hermes_cli.commands import GATEWAY_KNOWN_COMMANDS, resolve_command as _resolve_cmd |
| if command and command in GATEWAY_KNOWN_COMMANDS: |
| await self.hooks.emit(f"command:{command}", { |
| "platform": source.platform.value if source.platform else "", |
| "user_id": source.user_id, |
| "command": command, |
| "args": event.get_command_args().strip(), |
| }) |
|
|
| |
| _cmd_def = _resolve_cmd(command) if command else None |
| canonical = _cmd_def.name if _cmd_def else command |
|
|
| if canonical == "new": |
| return await self._handle_reset_command(event) |
| |
| if canonical == "help": |
| return await self._handle_help_command(event) |
|
|
| if canonical == "commands": |
| return await self._handle_commands_command(event) |
| |
| if canonical == "profile": |
| return await self._handle_profile_command(event) |
|
|
| if canonical == "status": |
| return await self._handle_status_command(event) |
|
|
| if canonical == "restart": |
| return await self._handle_restart_command(event) |
| |
| if canonical == "stop": |
| return await self._handle_stop_command(event) |
| |
| if canonical == "reasoning": |
| return await self._handle_reasoning_command(event) |
|
|
| if canonical == "fast": |
| return await self._handle_fast_command(event) |
|
|
| if canonical == "verbose": |
| return await self._handle_verbose_command(event) |
|
|
| if canonical == "yolo": |
| return await self._handle_yolo_command(event) |
|
|
| if canonical == "model": |
| return await self._handle_model_command(event) |
|
|
| if canonical == "provider": |
| return await self._handle_provider_command(event) |
| |
| if canonical == "personality": |
| return await self._handle_personality_command(event) |
|
|
| if canonical == "plan": |
| try: |
| from agent.skill_commands import build_plan_path, build_skill_invocation_message |
|
|
| user_instruction = event.get_command_args().strip() |
| plan_path = build_plan_path(user_instruction) |
| event.text = build_skill_invocation_message( |
| "/plan", |
| user_instruction, |
| task_id=_quick_key, |
| runtime_note=( |
| "Save the markdown plan with write_file to this exact relative path " |
| f"inside the active workspace/backend cwd: {plan_path}" |
| ), |
| ) |
| if not event.text: |
| return "Failed to load the bundled /plan skill." |
| canonical = None |
| except Exception as e: |
| logger.exception("Failed to prepare /plan command") |
| return f"Failed to enter plan mode: {e}" |
| |
| if canonical == "retry": |
| return await self._handle_retry_command(event) |
| |
| if canonical == "undo": |
| return await self._handle_undo_command(event) |
| |
| if canonical == "sethome": |
| return await self._handle_set_home_command(event) |
|
|
| if canonical == "compress": |
| return await self._handle_compress_command(event) |
|
|
| if canonical == "usage": |
| return await self._handle_usage_command(event) |
|
|
| if canonical == "insights": |
| return await self._handle_insights_command(event) |
|
|
| if canonical == "reload-mcp": |
| return await self._handle_reload_mcp_command(event) |
|
|
| if canonical == "approve": |
| return await self._handle_approve_command(event) |
|
|
| if canonical == "deny": |
| return await self._handle_deny_command(event) |
|
|
| if canonical == "update": |
| return await self._handle_update_command(event) |
|
|
| if canonical == "debug": |
| return await self._handle_debug_command(event) |
|
|
| if canonical == "title": |
| return await self._handle_title_command(event) |
|
|
| if canonical == "resume": |
| return await self._handle_resume_command(event) |
|
|
| if canonical == "branch": |
| return await self._handle_branch_command(event) |
|
|
| if canonical == "rollback": |
| return await self._handle_rollback_command(event) |
|
|
| if canonical == "background": |
| return await self._handle_background_command(event) |
|
|
| if canonical == "btw": |
| return await self._handle_btw_command(event) |
|
|
| if canonical == "voice": |
| return await self._handle_voice_command(event) |
|
|
| if self._draining: |
| return f"⏳ Gateway is {self._status_action_gerund()} and is not accepting new work right now." |
|
|
| |
| if command: |
| if isinstance(self.config, dict): |
| quick_commands = self.config.get("quick_commands", {}) or {} |
| else: |
| quick_commands = getattr(self.config, "quick_commands", {}) or {} |
| if not isinstance(quick_commands, dict): |
| quick_commands = {} |
| if command in quick_commands: |
| qcmd = quick_commands[command] |
| if qcmd.get("type") == "exec": |
| exec_cmd = qcmd.get("command", "") |
| if exec_cmd: |
| try: |
| proc = await asyncio.create_subprocess_shell( |
| exec_cmd, |
| stdout=asyncio.subprocess.PIPE, |
| stderr=asyncio.subprocess.PIPE, |
| ) |
| stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30) |
| output = (stdout or stderr).decode().strip() |
| return output if output else "Command returned no output." |
| except asyncio.TimeoutError: |
| return "Quick command timed out (30s)." |
| except Exception as e: |
| return f"Quick command error: {e}" |
| else: |
| return f"Quick command '/{command}' has no command defined." |
| elif qcmd.get("type") == "alias": |
| target = qcmd.get("target", "").strip() |
| if target: |
| target = target if target.startswith("/") else f"/{target}" |
| target_command = target.lstrip("/") |
| user_args = event.get_command_args().strip() |
| event.text = f"{target} {user_args}".strip() |
| command = target_command |
| |
| else: |
| return f"Quick command '/{command}' has no target defined." |
| else: |
| return f"Quick command '/{command}' has unsupported type (supported: 'exec', 'alias')." |
|
|
| |
| if command: |
| try: |
| from hermes_cli.plugins import get_plugin_command_handler |
| |
| |
| |
| plugin_handler = get_plugin_command_handler(command.replace("_", "-")) |
| if plugin_handler: |
| user_args = event.get_command_args().strip() |
| import asyncio as _aio |
| result = plugin_handler(user_args) |
| if _aio.iscoroutine(result): |
| result = await result |
| return str(result) if result else None |
| except Exception as e: |
| logger.debug("Plugin command dispatch failed (non-fatal): %s", e) |
|
|
| |
| |
| |
| |
| if command: |
| try: |
| from agent.skill_commands import ( |
| get_skill_commands, |
| build_skill_invocation_message, |
| resolve_skill_command_key, |
| ) |
| skill_cmds = get_skill_commands() |
| cmd_key = resolve_skill_command_key(command) |
| if cmd_key is not None: |
| |
| |
| |
| |
| _skill_name = skill_cmds[cmd_key].get("name", "") |
| _plat = source.platform.value if source.platform else None |
| if _plat and _skill_name: |
| from agent.skill_utils import get_disabled_skill_names as _get_plat_disabled |
| if _skill_name in _get_plat_disabled(platform=_plat): |
| return ( |
| f"The **{_skill_name}** skill is disabled for {_plat}.\n" |
| f"Enable it with: `hermes skills config`" |
| ) |
| user_instruction = event.get_command_args().strip() |
| msg = build_skill_invocation_message( |
| cmd_key, user_instruction, task_id=_quick_key |
| ) |
| if msg: |
| event.text = msg |
| |
| else: |
| |
| |
| _unavail_msg = _check_unavailable_skill(command) |
| if _unavail_msg: |
| return _unavail_msg |
| |
| |
| |
| |
| |
| |
| |
| |
| if command.replace("_", "-") not in GATEWAY_KNOWN_COMMANDS: |
| logger.warning( |
| "Unrecognized slash command /%s from %s — " |
| "replying with unknown-command notice", |
| command, |
| source.platform.value if source.platform else "?", |
| ) |
| return ( |
| f"Unknown command `/{command}`. " |
| f"Type /commands to see what's available, " |
| f"or resend without the leading slash to send " |
| f"as a regular message." |
| ) |
| except Exception as e: |
| logger.debug("Skill command check failed (non-fatal): %s", e) |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| self._running_agents[_quick_key] = _AGENT_PENDING_SENTINEL |
| self._running_agents_ts[_quick_key] = time.time() |
|
|
| try: |
| return await self._handle_message_with_agent(event, source, _quick_key) |
| finally: |
| |
| |
| |
| |
| if self._running_agents.get(_quick_key) is _AGENT_PENDING_SENTINEL: |
| del self._running_agents[_quick_key] |
| self._running_agents_ts.pop(_quick_key, None) |
|
|
| async def _prepare_inbound_message_text( |
| self, |
| *, |
| event: MessageEvent, |
| source: SessionSource, |
| history: List[Dict[str, Any]], |
| ) -> Optional[str]: |
| """Prepare inbound event text for the agent. |
| |
| Keep the normal inbound path and the queued follow-up path on the same |
| preprocessing pipeline so sender attribution, image enrichment, STT, |
| document notes, reply context, and @ references all behave the same. |
| """ |
| history = history or [] |
| message_text = event.text or "" |
|
|
| _is_shared_thread = ( |
| source.chat_type != "dm" |
| and source.thread_id |
| and not getattr(self.config, "thread_sessions_per_user", False) |
| ) |
| if _is_shared_thread and source.user_name: |
| message_text = f"[{source.user_name}] {message_text}" |
|
|
| if event.media_urls: |
| image_paths = [] |
| audio_paths = [] |
| for i, path in enumerate(event.media_urls): |
| mtype = event.media_types[i] if i < len(event.media_types) else "" |
| if mtype.startswith("image/") or event.message_type == MessageType.PHOTO: |
| image_paths.append(path) |
| if mtype.startswith("audio/") or event.message_type in (MessageType.VOICE, MessageType.AUDIO): |
| audio_paths.append(path) |
|
|
| if image_paths: |
| message_text = await self._enrich_message_with_vision( |
| message_text, |
| image_paths, |
| ) |
|
|
| if audio_paths: |
| message_text = await self._enrich_message_with_transcription( |
| message_text, |
| audio_paths, |
| ) |
| _stt_fail_markers = ( |
| "No STT provider", |
| "STT is disabled", |
| "can't listen", |
| "VOICE_TOOLS_OPENAI_KEY", |
| ) |
| if any(marker in message_text for marker in _stt_fail_markers): |
| _stt_adapter = self.adapters.get(source.platform) |
| _stt_meta = {"thread_id": source.thread_id} if source.thread_id else None |
| if _stt_adapter: |
| try: |
| _stt_msg = ( |
| "🎤 I received your voice message but can't transcribe it — " |
| "no speech-to-text provider is configured.\n\n" |
| "To enable voice: install faster-whisper " |
| "(`pip install faster-whisper` in the Hermes venv) " |
| "and set `stt.enabled: true` in config.yaml, " |
| "then /restart the gateway." |
| ) |
| if self._has_setup_skill(): |
| _stt_msg += "\n\nFor full setup instructions, type: `/skill hermes-agent-setup`" |
| await _stt_adapter.send( |
| source.chat_id, |
| _stt_msg, |
| metadata=_stt_meta, |
| ) |
| except Exception: |
| pass |
|
|
| if event.media_urls and event.message_type == MessageType.DOCUMENT: |
| import mimetypes as _mimetypes |
|
|
| _TEXT_EXTENSIONS = {".txt", ".md", ".csv", ".log", ".json", ".xml", ".yaml", ".yml", ".toml", ".ini", ".cfg"} |
| for i, path in enumerate(event.media_urls): |
| mtype = event.media_types[i] if i < len(event.media_types) else "" |
| if mtype in ("", "application/octet-stream"): |
| import os as _os2 |
|
|
| _ext = _os2.path.splitext(path)[1].lower() |
| if _ext in _TEXT_EXTENSIONS: |
| mtype = "text/plain" |
| else: |
| guessed, _ = _mimetypes.guess_type(path) |
| if guessed: |
| mtype = guessed |
| if not mtype.startswith(("application/", "text/")): |
| continue |
|
|
| import os as _os |
| import re as _re |
|
|
| basename = _os.path.basename(path) |
| parts = basename.split("_", 2) |
| display_name = parts[2] if len(parts) >= 3 else basename |
| display_name = _re.sub(r'[^\w.\- ]', '_', display_name) |
|
|
| if mtype.startswith("text/"): |
| context_note = ( |
| f"[The user sent a text document: '{display_name}'. " |
| f"Its content has been included below. " |
| f"The file is also saved at: {path}]" |
| ) |
| else: |
| context_note = ( |
| f"[The user sent a document: '{display_name}'. " |
| f"The file is saved at: {path}. " |
| f"Ask the user what they'd like you to do with it.]" |
| ) |
| message_text = f"{context_note}\n\n{message_text}" |
|
|
| if getattr(event, "reply_to_text", None) and event.reply_to_message_id: |
| reply_snippet = event.reply_to_text[:500] |
| found_in_history = any( |
| reply_snippet[:200] in (msg.get("content") or "") |
| for msg in history |
| if msg.get("role") in ("assistant", "user", "tool") |
| ) |
| if not found_in_history: |
| message_text = f'[Replying to: "{reply_snippet}"]\n\n{message_text}' |
|
|
| if "@" in message_text: |
| try: |
| from agent.context_references import preprocess_context_references_async |
| from agent.model_metadata import get_model_context_length |
|
|
| _msg_cwd = os.environ.get("MESSAGING_CWD", os.path.expanduser("~")) |
| _msg_ctx_len = get_model_context_length( |
| self._model, |
| base_url=self._base_url or "", |
| ) |
| _ctx_result = await preprocess_context_references_async( |
| message_text, |
| cwd=_msg_cwd, |
| context_length=_msg_ctx_len, |
| allowed_root=_msg_cwd, |
| ) |
| if _ctx_result.blocked: |
| _adapter = self.adapters.get(source.platform) |
| if _adapter: |
| await _adapter.send( |
| source.chat_id, |
| "\n".join(_ctx_result.warnings) or "Context injection refused.", |
| ) |
| return None |
| if _ctx_result.expanded: |
| message_text = _ctx_result.message |
| except Exception as exc: |
| logger.debug("@ context reference expansion failed: %s", exc) |
|
|
| return message_text |
|
|
| async def _handle_message_with_agent(self, event, source, _quick_key: str): |
| """Inner handler that runs under the _running_agents sentinel guard.""" |
| _msg_start_time = time.time() |
| _platform_name = source.platform.value if hasattr(source.platform, "value") else str(source.platform) |
| _msg_preview = (event.text or "")[:80].replace("\n", " ") |
| logger.info( |
| "inbound message: platform=%s user=%s chat=%s msg=%r", |
| _platform_name, source.user_name or source.user_id or "unknown", |
| source.chat_id or "unknown", _msg_preview, |
| ) |
|
|
| |
| session_entry = self.session_store.get_or_create_session(source) |
| session_key = session_entry.session_key |
| |
| |
| _is_new_session = ( |
| session_entry.created_at == session_entry.updated_at |
| or getattr(session_entry, "was_auto_reset", False) |
| ) |
| if _is_new_session: |
| await self.hooks.emit("session:start", { |
| "platform": source.platform.value if source.platform else "", |
| "user_id": source.user_id, |
| "session_id": session_entry.session_id, |
| "session_key": session_key, |
| }) |
| |
| |
| context = build_session_context(source, self.config, session_entry) |
| |
| |
| _session_env_tokens = self._set_session_env(context) |
| |
| |
| _redact_pii = False |
| try: |
| import yaml as _pii_yaml |
| with open(_config_path, encoding="utf-8") as _pf: |
| _pcfg = _pii_yaml.safe_load(_pf) or {} |
| _redact_pii = bool((_pcfg.get("privacy") or {}).get("redact_pii", False)) |
| except Exception: |
| pass |
|
|
| |
| context_prompt = build_session_context_prompt(context, redact_pii=_redact_pii) |
| |
| |
| |
| if getattr(session_entry, 'was_auto_reset', False): |
| reset_reason = getattr(session_entry, 'auto_reset_reason', None) or 'idle' |
| if reset_reason == "suspended": |
| context_note = "[System note: The user's previous session was stopped and suspended. This is a fresh conversation with no prior context.]" |
| elif reset_reason == "daily": |
| context_note = "[System note: The user's session was automatically reset by the daily schedule. This is a fresh conversation with no prior context.]" |
| else: |
| context_note = "[System note: The user's previous session expired due to inactivity. This is a fresh conversation with no prior context.]" |
| context_prompt = context_note + "\n\n" + context_prompt |
|
|
| |
| |
| |
| |
| try: |
| policy = self.session_store.config.get_reset_policy( |
| platform=source.platform, |
| session_type=getattr(source, 'chat_type', 'dm'), |
| ) |
| platform_name = source.platform.value if source.platform else "" |
| had_activity = getattr(session_entry, 'reset_had_activity', False) |
| |
| |
| should_notify = reset_reason == "suspended" or ( |
| policy.notify |
| and had_activity |
| and platform_name not in policy.notify_exclude_platforms |
| ) |
| if should_notify: |
| adapter = self.adapters.get(source.platform) |
| if adapter: |
| if reset_reason == "suspended": |
| reason_text = "previous session was stopped or interrupted" |
| elif reset_reason == "daily": |
| reason_text = f"daily schedule at {policy.at_hour}:00" |
| else: |
| hours = policy.idle_minutes // 60 |
| mins = policy.idle_minutes % 60 |
| duration = f"{hours}h" if not mins else f"{hours}h {mins}m" if hours else f"{mins}m" |
| reason_text = f"inactive for {duration}" |
| notice = ( |
| f"◐ Session automatically reset ({reason_text}). " |
| f"Conversation history cleared.\n" |
| f"Use /resume to browse and restore a previous session.\n" |
| f"Adjust reset timing in config.yaml under session_reset." |
| ) |
| try: |
| session_info = self._format_session_info() |
| if session_info: |
| notice = f"{notice}\n\n{session_info}" |
| except Exception: |
| pass |
| await adapter.send( |
| source.chat_id, notice, |
| metadata=getattr(event, 'metadata', None), |
| ) |
| except Exception as e: |
| logger.debug("Auto-reset notification failed (non-fatal): %s", e) |
|
|
| session_entry.was_auto_reset = False |
| session_entry.auto_reset_reason = None |
|
|
| |
| |
| |
| |
| _auto = getattr(event, "auto_skill", None) |
| if _is_new_session and _auto: |
| _skill_names = [_auto] if isinstance(_auto, str) else list(_auto) |
| try: |
| from agent.skill_commands import _load_skill_payload, _build_skill_message |
| _combined_parts: list[str] = [] |
| _loaded_names: list[str] = [] |
| for _sname in _skill_names: |
| _loaded = _load_skill_payload(_sname, task_id=_quick_key) |
| if _loaded: |
| _loaded_skill, _skill_dir, _display_name = _loaded |
| _note = ( |
| f'[SYSTEM: The "{_display_name}" skill is auto-loaded. ' |
| f"Follow its instructions for this session.]" |
| ) |
| _part = _build_skill_message(_loaded_skill, _skill_dir, _note) |
| if _part: |
| _combined_parts.append(_part) |
| _loaded_names.append(_sname) |
| else: |
| logger.warning("[Gateway] Auto-skill '%s' not found", _sname) |
| if _combined_parts: |
| |
| _combined_parts.append(event.text) |
| event.text = "\n\n".join(_combined_parts) |
| logger.info( |
| "[Gateway] Auto-loaded skill(s) %s for session %s", |
| _loaded_names, session_key, |
| ) |
| except Exception as e: |
| logger.warning("[Gateway] Failed to auto-load skill(s) %s: %s", _skill_names, e) |
|
|
| |
| history = self.session_store.load_transcript(session_entry.session_id) |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if history and len(history) >= 4: |
| from agent.model_metadata import ( |
| estimate_messages_tokens_rough, |
| get_model_context_length, |
| ) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| _hyg_model = "anthropic/claude-sonnet-4.6" |
| _hyg_threshold_pct = 0.85 |
| _hyg_compression_enabled = True |
| _hyg_config_context_length = None |
| _hyg_provider = None |
| _hyg_base_url = None |
| _hyg_api_key = None |
| _hyg_data = {} |
| try: |
| _hyg_cfg_path = _hermes_home / "config.yaml" |
| if _hyg_cfg_path.exists(): |
| import yaml as _hyg_yaml |
| with open(_hyg_cfg_path, encoding="utf-8") as _hyg_f: |
| _hyg_data = _hyg_yaml.safe_load(_hyg_f) or {} |
|
|
| |
| _model_cfg = _hyg_data.get("model", {}) |
| if isinstance(_model_cfg, str): |
| _hyg_model = _model_cfg |
| elif isinstance(_model_cfg, dict): |
| _hyg_model = _model_cfg.get("default") or _model_cfg.get("model") or _hyg_model |
| |
| |
| _raw_ctx = _model_cfg.get("context_length") |
| if _raw_ctx is not None: |
| try: |
| _hyg_config_context_length = int(_raw_ctx) |
| except (TypeError, ValueError): |
| pass |
| |
| _hyg_provider = _model_cfg.get("provider") or None |
| _hyg_base_url = _model_cfg.get("base_url") or None |
|
|
| |
| |
| |
| _comp_cfg = _hyg_data.get("compression", {}) |
| if isinstance(_comp_cfg, dict): |
| _hyg_compression_enabled = str( |
| _comp_cfg.get("enabled", True) |
| ).lower() in ("true", "1", "yes") |
|
|
| try: |
| _hyg_model, _hyg_runtime = self._resolve_session_agent_runtime( |
| source=source, |
| session_key=session_key, |
| user_config=_hyg_data if isinstance(_hyg_data, dict) else None, |
| ) |
| _hyg_provider = _hyg_runtime.get("provider") or _hyg_provider |
| _hyg_base_url = _hyg_runtime.get("base_url") or _hyg_base_url |
| _hyg_api_key = _hyg_runtime.get("api_key") or _hyg_api_key |
| except Exception: |
| pass |
|
|
| |
| |
| |
| if _hyg_config_context_length is None and _hyg_base_url: |
| try: |
| try: |
| from hermes_cli.config import get_compatible_custom_providers as _gw_gcp |
| _hyg_custom_providers = _gw_gcp(_hyg_data) |
| except Exception: |
| _hyg_custom_providers = _hyg_data.get("custom_providers") |
| if not isinstance(_hyg_custom_providers, list): |
| _hyg_custom_providers = [] |
| for _cp in _hyg_custom_providers: |
| if not isinstance(_cp, dict): |
| continue |
| _cp_url = (_cp.get("base_url") or "").rstrip("/") |
| if _cp_url and _cp_url == _hyg_base_url.rstrip("/"): |
| _cp_models = _cp.get("models", {}) |
| if isinstance(_cp_models, dict): |
| _cp_model_cfg = _cp_models.get(_hyg_model, {}) |
| if isinstance(_cp_model_cfg, dict): |
| _cp_ctx = _cp_model_cfg.get("context_length") |
| if _cp_ctx is not None: |
| _hyg_config_context_length = int(_cp_ctx) |
| break |
| except (TypeError, ValueError): |
| pass |
| except Exception: |
| pass |
|
|
| if _hyg_compression_enabled: |
| _hyg_context_length = get_model_context_length( |
| _hyg_model, |
| base_url=_hyg_base_url or "", |
| api_key=_hyg_api_key or "", |
| config_context_length=_hyg_config_context_length, |
| provider=_hyg_provider or "", |
| ) |
| _compress_token_threshold = int( |
| _hyg_context_length * _hyg_threshold_pct |
| ) |
| _warn_token_threshold = int(_hyg_context_length * 0.95) |
|
|
| _msg_count = len(history) |
|
|
| |
| |
| _stored_tokens = session_entry.last_prompt_tokens |
| if _stored_tokens > 0: |
| _approx_tokens = _stored_tokens |
| _token_source = "actual" |
| else: |
| _approx_tokens = estimate_messages_tokens_rough(history) |
| _token_source = "estimated" |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| _HARD_MSG_LIMIT = 400 |
| _needs_compress = ( |
| _approx_tokens >= _compress_token_threshold |
| or _msg_count >= _HARD_MSG_LIMIT |
| ) |
|
|
| if _needs_compress: |
| logger.info( |
| "Session hygiene: %s messages, ~%s tokens (%s) — auto-compressing " |
| "(threshold: %s%% of %s = %s tokens)", |
| _msg_count, f"{_approx_tokens:,}", _token_source, |
| int(_hyg_threshold_pct * 100), |
| f"{_hyg_context_length:,}", |
| f"{_compress_token_threshold:,}", |
| ) |
|
|
| _hyg_meta = {"thread_id": source.thread_id} if source.thread_id else None |
|
|
| try: |
| from run_agent import AIAgent |
|
|
| _hyg_model, _hyg_runtime = self._resolve_session_agent_runtime( |
| source=source, |
| session_key=session_key, |
| user_config=_hyg_data if isinstance(_hyg_data, dict) else None, |
| ) |
| if _hyg_runtime.get("api_key"): |
| _hyg_msgs = [ |
| {"role": m.get("role"), "content": m.get("content")} |
| for m in history |
| if m.get("role") in ("user", "assistant") |
| and m.get("content") |
| ] |
|
|
| if len(_hyg_msgs) >= 4: |
| _hyg_agent = AIAgent( |
| **_hyg_runtime, |
| model=_hyg_model, |
| max_iterations=4, |
| quiet_mode=True, |
| enabled_toolsets=["memory"], |
| session_id=session_entry.session_id, |
| ) |
| _hyg_agent._print_fn = lambda *a, **kw: None |
|
|
| loop = asyncio.get_event_loop() |
| _compressed, _ = await loop.run_in_executor( |
| None, |
| lambda: _hyg_agent._compress_context( |
| _hyg_msgs, "", |
| approx_tokens=_approx_tokens, |
| ), |
| ) |
|
|
| |
| |
| |
| |
| _hyg_new_sid = _hyg_agent.session_id |
| if _hyg_new_sid != session_entry.session_id: |
| session_entry.session_id = _hyg_new_sid |
| self.session_store._save() |
|
|
| self.session_store.rewrite_transcript( |
| session_entry.session_id, _compressed |
| ) |
| |
| session_entry.last_prompt_tokens = 0 |
| history = _compressed |
| _new_count = len(_compressed) |
| _new_tokens = estimate_messages_tokens_rough( |
| _compressed |
| ) |
|
|
| logger.info( |
| "Session hygiene: compressed %s → %s msgs, " |
| "~%s → ~%s tokens", |
| _msg_count, _new_count, |
| f"{_approx_tokens:,}", f"{_new_tokens:,}", |
| ) |
|
|
| if _new_tokens >= _warn_token_threshold: |
| logger.warning( |
| "Session hygiene: still ~%s tokens after " |
| "compression", |
| f"{_new_tokens:,}", |
| ) |
|
|
| except Exception as e: |
| logger.warning( |
| "Session hygiene auto-compress failed: %s", e |
| ) |
|
|
| |
| if not history and not self.session_store.has_any_sessions(): |
| context_prompt += ( |
| "\n\n[System note: This is the user's very first message ever. " |
| "Briefly introduce yourself and mention that /help shows available commands. " |
| "Keep the introduction concise -- one or two sentences max.]" |
| ) |
| |
| |
| |
| if not history and source.platform and source.platform != Platform.LOCAL and source.platform != Platform.WEBHOOK: |
| platform_name = source.platform.value |
| env_key = f"{platform_name.upper()}_HOME_CHANNEL" |
| if not os.getenv(env_key): |
| adapter = self.adapters.get(source.platform) |
| if adapter: |
| await adapter.send( |
| source.chat_id, |
| f"📬 No home channel is set for {platform_name.title()}. " |
| f"A home channel is where Hermes delivers cron job results " |
| f"and cross-platform messages.\n\n" |
| f"Type /sethome to make this chat your home channel, " |
| f"or ignore to skip." |
| ) |
| |
| |
| |
| |
| |
| |
| if source.platform == Platform.DISCORD: |
| adapter = self.adapters.get(Platform.DISCORD) |
| guild_id = self._get_guild_id(event) |
| if guild_id and adapter and hasattr(adapter, "get_voice_channel_context"): |
| vc_context = adapter.get_voice_channel_context(guild_id) |
| if vc_context: |
| context_prompt += f"\n\n{vc_context}" |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| message_text = await self._prepare_inbound_message_text( |
| event=event, |
| source=source, |
| history=history, |
| ) |
| if message_text is None: |
| return |
|
|
| try: |
| |
| hook_ctx = { |
| "platform": source.platform.value if source.platform else "", |
| "user_id": source.user_id, |
| "session_id": session_entry.session_id, |
| "message": message_text[:500], |
| } |
| await self.hooks.emit("agent:start", hook_ctx) |
|
|
| |
| agent_result = await self._run_agent( |
| message=message_text, |
| context_prompt=context_prompt, |
| history=history, |
| source=source, |
| session_id=session_entry.session_id, |
| session_key=session_key, |
| event_message_id=event.message_id, |
| ) |
|
|
| |
| try: |
| _typing_adapter = self.adapters.get(source.platform) |
| if _typing_adapter and hasattr(_typing_adapter, "stop_typing"): |
| await _typing_adapter.stop_typing(source.chat_id) |
| except Exception: |
| pass |
|
|
| response = agent_result.get("final_response") or "" |
| agent_messages = agent_result.get("messages", []) |
| _response_time = time.time() - _msg_start_time |
| _api_calls = agent_result.get("api_calls", 0) |
| _resp_len = len(response) |
| logger.info( |
| "response ready: platform=%s chat=%s time=%.1fs api_calls=%d response=%d chars", |
| _platform_name, source.chat_id or "unknown", |
| _response_time, _api_calls, _resp_len, |
| ) |
|
|
| |
| if not response and agent_result.get("failed"): |
| error_detail = agent_result.get("error", "unknown error") |
| error_str = str(error_detail).lower() |
|
|
| |
| |
| |
| _is_ctx_fail = any(p in error_str for p in ( |
| "context", "token", "too large", "too long", |
| "exceed", "payload", |
| )) or ( |
| "400" in error_str |
| and len(history) > 50 |
| ) |
|
|
| if _is_ctx_fail: |
| response = ( |
| "⚠️ Session too large for the model's context window.\n" |
| "Use /compact to compress the conversation, or " |
| "/reset to start fresh." |
| ) |
| else: |
| response = ( |
| f"The request failed: {str(error_detail)[:300]}\n" |
| "Try again or use /reset to start a fresh session." |
| ) |
|
|
| |
| |
| if agent_result.get("session_id") and agent_result["session_id"] != session_entry.session_id: |
| session_entry.session_id = agent_result["session_id"] |
|
|
| |
| try: |
| from gateway.display_config import resolve_display_setting as _rds |
| _show_reasoning_effective = _rds( |
| _load_gateway_config(), |
| _platform_config_key(source.platform), |
| "show_reasoning", |
| getattr(self, "_show_reasoning", False), |
| ) |
| except Exception: |
| _show_reasoning_effective = getattr(self, "_show_reasoning", False) |
| if _show_reasoning_effective and response: |
| last_reasoning = agent_result.get("last_reasoning") |
| if last_reasoning: |
| |
| lines = last_reasoning.strip().splitlines() |
| if len(lines) > 15: |
| display_reasoning = "\n".join(lines[:15]) |
| display_reasoning += f"\n_... ({len(lines) - 15} more lines)_" |
| else: |
| display_reasoning = last_reasoning.strip() |
| response = f"💭 **Reasoning:**\n```\n{display_reasoning}\n```\n\n{response}" |
|
|
| |
| await self.hooks.emit("agent:end", { |
| **hook_ctx, |
| "response": (response or "")[:500], |
| }) |
| |
| |
| try: |
| from tools.process_registry import process_registry |
| while process_registry.pending_watchers: |
| watcher = process_registry.pending_watchers.pop(0) |
| asyncio.create_task(self._run_process_watcher(watcher)) |
| except Exception as e: |
| logger.error("Process watcher setup error: %s", e) |
|
|
| |
| |
| |
| |
| try: |
| from tools.process_registry import process_registry as _pr |
| _watch_events = [] |
| while not _pr.completion_queue.empty(): |
| evt = _pr.completion_queue.get_nowait() |
| evt_type = evt.get("type", "completion") |
| if evt_type in ("watch_match", "watch_disabled"): |
| _watch_events.append(evt) |
| |
| for evt in _watch_events: |
| synth_text = _format_gateway_process_notification(evt) |
| if synth_text: |
| try: |
| await self._inject_watch_notification(synth_text, event) |
| except Exception as e2: |
| logger.error("Watch notification injection error: %s", e2) |
| except Exception as e: |
| logger.debug("Watch queue drain error: %s", e) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| agent_failed_early = ( |
| agent_result.get("failed") |
| and not agent_result.get("final_response") |
| ) |
| if agent_failed_early: |
| logger.info( |
| "Skipping transcript persistence for failed request in " |
| "session %s to prevent session growth loop.", |
| session_entry.session_id, |
| ) |
|
|
| ts = datetime.now().isoformat() |
| |
| |
| |
| |
| if agent_failed_early: |
| pass |
| elif not history: |
| tool_defs = agent_result.get("tools", []) |
| self.session_store.append_to_transcript( |
| session_entry.session_id, |
| { |
| "role": "session_meta", |
| "tools": tool_defs or [], |
| "model": _resolve_gateway_model(), |
| "platform": source.platform.value if source.platform else "", |
| "timestamp": ts, |
| } |
| ) |
| |
| |
| |
| |
| |
| if not agent_failed_early: |
| history_len = agent_result.get("history_offset", len(history)) |
| new_messages = agent_messages[history_len:] if len(agent_messages) > history_len else [] |
| |
| |
| if not new_messages: |
| self.session_store.append_to_transcript( |
| session_entry.session_id, |
| {"role": "user", "content": message_text, "timestamp": ts} |
| ) |
| if response: |
| self.session_store.append_to_transcript( |
| session_entry.session_id, |
| {"role": "assistant", "content": response, "timestamp": ts} |
| ) |
| else: |
| |
| |
| |
| |
| agent_persisted = self._session_db is not None |
| for msg in new_messages: |
| |
| if msg.get("role") == "system": |
| continue |
| |
| entry = {**msg, "timestamp": ts} |
| self.session_store.append_to_transcript( |
| session_entry.session_id, entry, |
| skip_db=agent_persisted, |
| ) |
| |
| |
| |
| |
| self.session_store.update_session( |
| session_entry.session_key, |
| last_prompt_tokens=agent_result.get("last_prompt_tokens", 0), |
| ) |
|
|
| |
| _already_sent = bool(agent_result.get("already_sent")) |
| if self._should_send_voice_reply(event, response, agent_messages, already_sent=_already_sent): |
| await self._send_voice_reply(event, response) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if agent_result.get("already_sent") and not agent_result.get("failed"): |
| if response: |
| _media_adapter = self.adapters.get(source.platform) |
| if _media_adapter: |
| await self._deliver_media_from_response( |
| response, event, _media_adapter, |
| ) |
| return None |
|
|
| return response |
| |
| except Exception as e: |
| |
| try: |
| _err_adapter = self.adapters.get(source.platform) |
| if _err_adapter and hasattr(_err_adapter, "stop_typing"): |
| await _err_adapter.stop_typing(source.chat_id) |
| except Exception: |
| pass |
| logger.exception("Agent error in session %s", session_key) |
| error_type = type(e).__name__ |
| error_detail = str(e)[:300] if str(e) else "no details available" |
| status_hint = "" |
| status_code = getattr(e, "status_code", None) |
| _hist_len = len(history) if 'history' in locals() else 0 |
| if status_code == 401: |
| status_hint = " Check your API key or run `claude /login` to refresh OAuth credentials." |
| elif status_code == 429: |
| |
| _err_body = getattr(e, "response", None) |
| _err_json = {} |
| try: |
| if _err_body is not None: |
| _err_json = _err_body.json().get("error", {}) |
| except Exception: |
| pass |
| if _err_json.get("type") == "usage_limit_reached": |
| _resets_in = _err_json.get("resets_in_seconds") |
| if _resets_in and _resets_in > 0: |
| import math |
| _hours = math.ceil(_resets_in / 3600) |
| status_hint = f" Your plan's usage limit has been reached. It resets in ~{_hours}h." |
| else: |
| status_hint = " Your plan's usage limit has been reached. Please wait until it resets." |
| else: |
| status_hint = " You are being rate-limited. Please wait a moment and try again." |
| elif status_code == 529: |
| status_hint = " The API is temporarily overloaded. Please try again shortly." |
| elif status_code in (400, 500): |
| |
| |
| |
| if _hist_len > 50: |
| return ( |
| "⚠️ Session too large for the model's context window.\n" |
| "Use /compact to compress the conversation, or " |
| "/reset to start fresh." |
| ) |
| elif status_code == 400: |
| status_hint = " The request was rejected by the API." |
| return ( |
| f"Sorry, I encountered an error ({error_type}).\n" |
| f"{error_detail}\n" |
| f"{status_hint}" |
| "Try again or use /reset to start a fresh session." |
| ) |
| finally: |
| |
| self._clear_session_env(_session_env_tokens) |
| |
| def _format_session_info(self) -> str: |
| """Resolve current model config and return a formatted info block. |
| |
| Surfaces model, provider, context length, and endpoint so gateway |
| users can immediately see if context detection went wrong (e.g. |
| local models falling to the 128K default). |
| """ |
| from agent.model_metadata import get_model_context_length, DEFAULT_FALLBACK_CONTEXT |
|
|
| model = _resolve_gateway_model() |
| config_context_length = None |
| provider = None |
| base_url = None |
| api_key = None |
|
|
| try: |
| cfg_path = _hermes_home / "config.yaml" |
| if cfg_path.exists(): |
| import yaml as _info_yaml |
| with open(cfg_path, encoding="utf-8") as f: |
| data = _info_yaml.safe_load(f) or {} |
| model_cfg = data.get("model", {}) |
| if isinstance(model_cfg, dict): |
| raw_ctx = model_cfg.get("context_length") |
| if raw_ctx is not None: |
| try: |
| config_context_length = int(raw_ctx) |
| except (TypeError, ValueError): |
| pass |
| provider = model_cfg.get("provider") or None |
| base_url = model_cfg.get("base_url") or None |
| except Exception: |
| pass |
|
|
| |
| try: |
| runtime = _resolve_runtime_agent_kwargs() |
| provider = provider or runtime.get("provider") |
| base_url = base_url or runtime.get("base_url") |
| api_key = runtime.get("api_key") |
| except Exception: |
| pass |
|
|
| context_length = get_model_context_length( |
| model, |
| base_url=base_url or "", |
| api_key=api_key or "", |
| config_context_length=config_context_length, |
| provider=provider or "", |
| ) |
|
|
| |
| if config_context_length is not None: |
| ctx_source = "config" |
| elif context_length == DEFAULT_FALLBACK_CONTEXT: |
| ctx_source = "default — set model.context_length in config to override" |
| else: |
| ctx_source = "detected" |
|
|
| |
| if context_length >= 1_000_000: |
| ctx_display = f"{context_length / 1_000_000:.1f}M" |
| elif context_length >= 1_000: |
| ctx_display = f"{context_length // 1_000}K" |
| else: |
| ctx_display = str(context_length) |
|
|
| lines = [ |
| f"◆ Model: `{model}`", |
| f"◆ Provider: {provider or 'openrouter'}", |
| f"◆ Context: {ctx_display} tokens ({ctx_source})", |
| ] |
|
|
| |
| if base_url and ("localhost" in base_url or "127.0.0.1" in base_url or "0.0.0.0" in base_url): |
| lines.append(f"◆ Endpoint: {base_url}") |
|
|
| return "\n".join(lines) |
|
|
| async def _handle_reset_command(self, event: MessageEvent) -> str: |
| """Handle /new or /reset command.""" |
| source = event.source |
| |
| |
| session_key = self._session_key_for_source(source) |
| |
| |
| |
| try: |
| old_entry = self.session_store._entries.get(session_key) |
| if old_entry: |
| _flush_task = asyncio.create_task( |
| self._async_flush_memories(old_entry.session_id, session_key) |
| ) |
| self._background_tasks.add(_flush_task) |
| _flush_task.add_done_callback(self._background_tasks.discard) |
| except Exception as e: |
| logger.debug("Gateway memory flush on reset failed: %s", e) |
| |
| |
| |
| _cache_lock = getattr(self, "_agent_cache_lock", None) |
| if _cache_lock is not None: |
| with _cache_lock: |
| _cached = self._agent_cache.get(session_key) |
| _old_agent = _cached[0] if isinstance(_cached, tuple) else _cached if _cached else None |
| if _old_agent is not None: |
| try: |
| if hasattr(_old_agent, "close"): |
| _old_agent.close() |
| except Exception: |
| pass |
| self._evict_cached_agent(session_key) |
|
|
| try: |
| from tools.env_passthrough import clear_env_passthrough |
| clear_env_passthrough() |
| except Exception: |
| pass |
|
|
| try: |
| from tools.credential_files import clear_credential_files |
| clear_credential_files() |
| except Exception: |
| pass |
|
|
| |
| new_entry = self.session_store.reset_session(session_key) |
|
|
| |
| |
| self._session_model_overrides.pop(session_key, None) |
|
|
| |
| try: |
| from hermes_cli.plugins import invoke_hook as _invoke_hook |
| _old_sid = old_entry.session_id if old_entry else None |
| _invoke_hook("on_session_finalize", session_id=_old_sid, |
| platform=source.platform.value if source.platform else "") |
| except Exception: |
| pass |
|
|
| |
| await self.hooks.emit("session:end", { |
| "platform": source.platform.value if source.platform else "", |
| "user_id": source.user_id, |
| "session_key": session_key, |
| }) |
|
|
| |
| await self.hooks.emit("session:reset", { |
| "platform": source.platform.value if source.platform else "", |
| "user_id": source.user_id, |
| "session_key": session_key, |
| }) |
|
|
| |
| try: |
| session_info = self._format_session_info() |
| except Exception: |
| session_info = "" |
|
|
| if new_entry: |
| header = "✨ Session reset! Starting fresh." |
| else: |
| |
| new_entry = self.session_store.get_or_create_session(source, force_new=True) |
| header = "✨ New session started!" |
|
|
| |
| try: |
| from hermes_cli.plugins import invoke_hook as _invoke_hook |
| _new_sid = new_entry.session_id if new_entry else None |
| _invoke_hook("on_session_reset", session_id=_new_sid, |
| platform=source.platform.value if source.platform else "") |
| except Exception: |
| pass |
|
|
| |
| try: |
| from hermes_cli.tips import get_random_tip |
| _tip_line = f"\n✦ Tip: {get_random_tip()}" |
| except Exception: |
| _tip_line = "" |
|
|
| if session_info: |
| return f"{header}\n\n{session_info}{_tip_line}" |
| return f"{header}{_tip_line}" |
| |
| async def _handle_profile_command(self, event: MessageEvent) -> str: |
| """Handle /profile — show active profile name and home directory.""" |
| from hermes_constants import get_hermes_home, display_hermes_home |
| from pathlib import Path |
|
|
| home = get_hermes_home() |
| display = display_hermes_home() |
|
|
| |
| |
| profiles_parent = Path.home() / ".hermes" / "profiles" |
| try: |
| rel = home.relative_to(profiles_parent) |
| profile_name = str(rel).split("/")[0] |
| except ValueError: |
| profile_name = None |
|
|
| if profile_name: |
| lines = [ |
| f"👤 **Profile:** `{profile_name}`", |
| f"📂 **Home:** `{display}`", |
| ] |
| else: |
| lines = [ |
| "👤 **Profile:** default", |
| f"📂 **Home:** `{display}`", |
| ] |
|
|
| return "\n".join(lines) |
|
|
| async def _handle_status_command(self, event: MessageEvent) -> str: |
| """Handle /status command.""" |
| source = event.source |
| session_entry = self.session_store.get_or_create_session(source) |
|
|
| connected_platforms = [p.value for p in self.adapters.keys()] |
|
|
| |
| session_key = session_entry.session_key |
| is_running = session_key in self._running_agents |
|
|
| title = None |
| if self._session_db: |
| try: |
| title = self._session_db.get_session_title(session_entry.session_id) |
| except Exception: |
| title = None |
|
|
| lines = [ |
| "📊 **Hermes Gateway Status**", |
| "", |
| f"**Session ID:** `{session_entry.session_id}`", |
| ] |
| if title: |
| lines.append(f"**Title:** {title}") |
| lines.extend([ |
| f"**Created:** {session_entry.created_at.strftime('%Y-%m-%d %H:%M')}", |
| f"**Last Activity:** {session_entry.updated_at.strftime('%Y-%m-%d %H:%M')}", |
| f"**Tokens:** {session_entry.total_tokens:,}", |
| f"**Agent Running:** {'Yes ⚡' if is_running else 'No'}", |
| "", |
| f"**Connected Platforms:** {', '.join(connected_platforms)}", |
| ]) |
|
|
| return "\n".join(lines) |
| |
| async def _handle_stop_command(self, event: MessageEvent) -> str: |
| """Handle /stop command - interrupt a running agent. |
| |
| When an agent is truly hung (blocked thread that never checks |
| _interrupt_requested), the early intercept in _handle_message() |
| handles /stop before this method is reached. This handler fires |
| only through normal command dispatch (no running agent) or as a |
| fallback. Force-clean the session lock in all cases for safety. |
| |
| When there IS a running/pending agent, the session is also marked |
| as *suspended* so the next message starts a fresh session instead |
| of resuming the stuck context (#7536). |
| """ |
| source = event.source |
| session_entry = self.session_store.get_or_create_session(source) |
| session_key = session_entry.session_key |
|
|
| agent = self._running_agents.get(session_key) |
| if agent is _AGENT_PENDING_SENTINEL: |
| |
| if session_key in self._running_agents: |
| del self._running_agents[session_key] |
| self.session_store.suspend_session(session_key) |
| logger.info("HARD STOP (pending) for session %s — suspended, sentinel cleared", session_key[:20]) |
| return "⚡ Force-stopped. The agent was still starting — your next message will start fresh." |
| if agent: |
| agent.interrupt("Stop requested") |
| |
| |
| if session_key in self._running_agents: |
| del self._running_agents[session_key] |
| self.session_store.suspend_session(session_key) |
| return "⚡ Force-stopped. Your next message will start a fresh session." |
| else: |
| return "No active task to stop." |
|
|
| async def _handle_restart_command(self, event: MessageEvent) -> str: |
| """Handle /restart command - drain active work, then restart the gateway.""" |
| if self._restart_requested or self._draining: |
| count = self._running_agent_count() |
| if count: |
| return f"⏳ Draining {count} active agent(s) before restart..." |
| return "⏳ Gateway restart already in progress..." |
|
|
| |
| |
| try: |
| import json as _json |
| notify_data = { |
| "platform": event.source.platform.value if event.source.platform else None, |
| "chat_id": event.source.chat_id, |
| } |
| if event.source.thread_id: |
| notify_data["thread_id"] = event.source.thread_id |
| (_hermes_home / ".restart_notify.json").write_text( |
| _json.dumps(notify_data) |
| ) |
| except Exception as e: |
| logger.debug("Failed to write restart notify file: %s", e) |
|
|
| active_agents = self._running_agent_count() |
| |
| |
| |
| |
| |
| _under_service = bool(os.environ.get("INVOCATION_ID")) |
| if _under_service: |
| self.request_restart(detached=False, via_service=True) |
| else: |
| self.request_restart(detached=True, via_service=False) |
| if active_agents: |
| return f"⏳ Draining {active_agents} active agent(s) before restart..." |
| return "♻ Restarting gateway. If you aren't notified within 60 seconds, restart from the console with `hermes gateway restart`." |
|
|
| async def _handle_help_command(self, event: MessageEvent) -> str: |
| """Handle /help command - list available commands.""" |
| from hermes_cli.commands import gateway_help_lines |
| lines = [ |
| "📖 **Hermes Commands**\n", |
| *gateway_help_lines(), |
| ] |
| try: |
| from agent.skill_commands import get_skill_commands |
| skill_cmds = get_skill_commands() |
| if skill_cmds: |
| lines.append(f"\n⚡ **Skill Commands** ({len(skill_cmds)} active):") |
| |
| sorted_cmds = sorted(skill_cmds) |
| for cmd in sorted_cmds[:10]: |
| lines.append(f"`{cmd}` — {skill_cmds[cmd]['description']}") |
| if len(sorted_cmds) > 10: |
| lines.append(f"\n... and {len(sorted_cmds) - 10} more. Use `/commands` for the full paginated list.") |
| except Exception: |
| pass |
| return "\n".join(lines) |
|
|
| async def _handle_commands_command(self, event: MessageEvent) -> str: |
| """Handle /commands [page] - paginated list of all commands and skills.""" |
| from hermes_cli.commands import gateway_help_lines |
|
|
| raw_args = event.get_command_args().strip() |
| if raw_args: |
| try: |
| requested_page = int(raw_args) |
| except ValueError: |
| return "Usage: `/commands [page]`" |
| else: |
| requested_page = 1 |
|
|
| |
| entries = list(gateway_help_lines()) |
| try: |
| from agent.skill_commands import get_skill_commands |
| skill_cmds = get_skill_commands() |
| if skill_cmds: |
| entries.append("") |
| entries.append("⚡ **Skill Commands**:") |
| for cmd in sorted(skill_cmds): |
| desc = skill_cmds[cmd].get("description", "").strip() or "Skill command" |
| entries.append(f"`{cmd}` — {desc}") |
| except Exception: |
| pass |
|
|
| if not entries: |
| return "No commands available." |
|
|
| from gateway.config import Platform |
| page_size = 15 if event.source.platform == Platform.TELEGRAM else 20 |
| total_pages = max(1, (len(entries) + page_size - 1) // page_size) |
| page = max(1, min(requested_page, total_pages)) |
| start = (page - 1) * page_size |
| page_entries = entries[start:start + page_size] |
|
|
| lines = [ |
| f"📚 **Commands** ({len(entries)} total, page {page}/{total_pages})", |
| "", |
| *page_entries, |
| ] |
| if total_pages > 1: |
| nav_parts = [] |
| if page > 1: |
| nav_parts.append(f"`/commands {page - 1}` ← prev") |
| if page < total_pages: |
| nav_parts.append(f"next → `/commands {page + 1}`") |
| lines.extend(["", " | ".join(nav_parts)]) |
| if page != requested_page: |
| lines.append(f"_(Requested page {requested_page} was out of range, showing page {page}.)_") |
| return "\n".join(lines) |
| |
| async def _handle_model_command(self, event: MessageEvent) -> Optional[str]: |
| """Handle /model command — switch model for this session. |
| |
| Supports: |
| /model — interactive picker (Telegram/Discord) or text list |
| /model <name> — switch for this session only |
| /model <name> --global — switch and persist to config.yaml |
| /model <name> --provider <provider> — switch provider + model |
| /model --provider <provider> — switch to provider, auto-detect model |
| """ |
| import yaml |
| from hermes_cli.model_switch import ( |
| switch_model as _switch_model, parse_model_flags, |
| list_authenticated_providers, |
| ) |
| from hermes_cli.providers import get_label |
|
|
| raw_args = event.get_command_args().strip() |
|
|
| |
| model_input, explicit_provider, persist_global = parse_model_flags(raw_args) |
|
|
| |
| current_model = "" |
| current_provider = "openrouter" |
| current_base_url = "" |
| current_api_key = "" |
| user_provs = None |
| custom_provs = None |
| config_path = _hermes_home / "config.yaml" |
| try: |
| if config_path.exists(): |
| with open(config_path, encoding="utf-8") as f: |
| cfg = yaml.safe_load(f) or {} |
| model_cfg = cfg.get("model", {}) |
| if isinstance(model_cfg, dict): |
| current_model = model_cfg.get("default", "") |
| current_provider = model_cfg.get("provider", current_provider) |
| current_base_url = model_cfg.get("base_url", "") |
| user_provs = cfg.get("providers") |
| try: |
| from hermes_cli.config import get_compatible_custom_providers |
| custom_provs = get_compatible_custom_providers(cfg) |
| except Exception: |
| custom_provs = cfg.get("custom_providers") |
| except Exception: |
| pass |
|
|
| |
| source = event.source |
| session_key = self._session_key_for_source(source) |
| override = self._session_model_overrides.get(session_key, {}) |
| if override: |
| current_model = override.get("model", current_model) |
| current_provider = override.get("provider", current_provider) |
| current_base_url = override.get("base_url", current_base_url) |
| current_api_key = override.get("api_key", current_api_key) |
|
|
| |
| if not model_input and not explicit_provider: |
| |
| adapter = self.adapters.get(source.platform) |
| has_picker = ( |
| adapter is not None |
| and getattr(type(adapter), "send_model_picker", None) is not None |
| ) |
|
|
| if has_picker: |
| try: |
| providers = list_authenticated_providers( |
| current_provider=current_provider, |
| user_providers=user_provs, |
| custom_providers=custom_provs, |
| max_models=50, |
| ) |
| except Exception: |
| providers = [] |
|
|
| if providers: |
| |
| |
| _self = self |
| _session_key = session_key |
| _cur_model = current_model |
| _cur_provider = current_provider |
| _cur_base_url = current_base_url |
| _cur_api_key = current_api_key |
|
|
| async def _on_model_selected( |
| _chat_id: str, model_id: str, provider_slug: str |
| ) -> str: |
| """Perform the model switch and return confirmation text.""" |
| result = _switch_model( |
| raw_input=model_id, |
| current_provider=_cur_provider, |
| current_model=_cur_model, |
| current_base_url=_cur_base_url, |
| current_api_key=_cur_api_key, |
| is_global=False, |
| explicit_provider=provider_slug, |
| user_providers=user_provs, |
| custom_providers=custom_provs, |
| ) |
| if not result.success: |
| return f"Error: {result.error_message}" |
|
|
| |
| cached_entry = None |
| _cache_lock = getattr(_self, "_agent_cache_lock", None) |
| _cache = getattr(_self, "_agent_cache", None) |
| if _cache_lock and _cache is not None: |
| with _cache_lock: |
| cached_entry = _cache.get(_session_key) |
| if cached_entry and cached_entry[0] is not None: |
| try: |
| cached_entry[0].switch_model( |
| new_model=result.new_model, |
| new_provider=result.target_provider, |
| api_key=result.api_key, |
| base_url=result.base_url, |
| api_mode=result.api_mode, |
| ) |
| except Exception as exc: |
| logger.warning("Picker model switch failed for cached agent: %s", exc) |
|
|
| |
| if not hasattr(_self, "_pending_model_notes"): |
| _self._pending_model_notes = {} |
| _self._pending_model_notes[_session_key] = ( |
| f"[Note: model was just switched from {_cur_model} to {result.new_model} " |
| f"via {result.provider_label or result.target_provider}. " |
| f"Adjust your self-identification accordingly.]" |
| ) |
| _self._session_model_overrides[_session_key] = { |
| "model": result.new_model, |
| "provider": result.target_provider, |
| "api_key": result.api_key, |
| "base_url": result.base_url, |
| "api_mode": result.api_mode, |
| } |
|
|
| |
| |
| |
| _self._evict_cached_agent(_session_key) |
|
|
| |
| plabel = result.provider_label or result.target_provider |
| lines = [f"Model switched to `{result.new_model}`"] |
| lines.append(f"Provider: {plabel}") |
| mi = result.model_info |
| if mi: |
| if mi.context_window: |
| lines.append(f"Context: {mi.context_window:,} tokens") |
| if mi.max_output: |
| lines.append(f"Max output: {mi.max_output:,} tokens") |
| if mi.has_cost_data(): |
| lines.append(f"Cost: {mi.format_cost()}") |
| lines.append(f"Capabilities: {mi.format_capabilities()}") |
| lines.append("_(session only — use `/model <name> --global` to persist)_") |
| return "\n".join(lines) |
|
|
| metadata = {"thread_id": source.thread_id} if source.thread_id else None |
| result = await adapter.send_model_picker( |
| chat_id=source.chat_id, |
| providers=providers, |
| current_model=current_model, |
| current_provider=current_provider, |
| session_key=session_key, |
| on_model_selected=_on_model_selected, |
| metadata=metadata, |
| ) |
| if result.success: |
| return None |
|
|
| |
| provider_label = get_label(current_provider) |
| lines = [f"Current: `{current_model or 'unknown'}` on {provider_label}", ""] |
|
|
| try: |
| providers = list_authenticated_providers( |
| current_provider=current_provider, |
| user_providers=user_provs, |
| custom_providers=custom_provs, |
| max_models=5, |
| ) |
| for p in providers: |
| tag = " (current)" if p["is_current"] else "" |
| lines.append(f"**{p['name']}** `--provider {p['slug']}`{tag}:") |
| if p["models"]: |
| model_strs = ", ".join(f"`{m}`" for m in p["models"]) |
| extra = f" (+{p['total_models'] - len(p['models'])} more)" if p["total_models"] > len(p["models"]) else "" |
| lines.append(f" {model_strs}{extra}") |
| elif p.get("api_url"): |
| lines.append(f" `{p['api_url']}`") |
| lines.append("") |
| except Exception: |
| pass |
|
|
| lines.append("`/model <name>` — switch model") |
| lines.append("`/model <name> --provider <slug>` — switch provider") |
| lines.append("`/model <name> --global` — persist") |
| return "\n".join(lines) |
|
|
| |
| result = _switch_model( |
| raw_input=model_input, |
| current_provider=current_provider, |
| current_model=current_model, |
| current_base_url=current_base_url, |
| current_api_key=current_api_key, |
| is_global=persist_global, |
| explicit_provider=explicit_provider, |
| user_providers=user_provs, |
| custom_providers=custom_provs, |
| ) |
|
|
| if not result.success: |
| return f"Error: {result.error_message}" |
|
|
| |
| cached_entry = None |
| _cache_lock = getattr(self, "_agent_cache_lock", None) |
| _cache = getattr(self, "_agent_cache", None) |
| if _cache_lock and _cache is not None: |
| with _cache_lock: |
| cached_entry = _cache.get(session_key) |
|
|
| if cached_entry and cached_entry[0] is not None: |
| try: |
| cached_entry[0].switch_model( |
| new_model=result.new_model, |
| new_provider=result.target_provider, |
| api_key=result.api_key, |
| base_url=result.base_url, |
| api_mode=result.api_mode, |
| ) |
| except Exception as exc: |
| logger.warning("In-place model switch failed for cached agent: %s", exc) |
|
|
| |
| |
| if not hasattr(self, "_pending_model_notes"): |
| self._pending_model_notes = {} |
| self._pending_model_notes[session_key] = ( |
| f"[Note: model was just switched from {current_model} to {result.new_model} " |
| f"via {result.provider_label or result.target_provider}. " |
| f"Adjust your self-identification accordingly.]" |
| ) |
|
|
| |
| self._session_model_overrides[session_key] = { |
| "model": result.new_model, |
| "provider": result.target_provider, |
| "api_key": result.api_key, |
| "base_url": result.base_url, |
| "api_mode": result.api_mode, |
| } |
|
|
| |
| |
| self._evict_cached_agent(session_key) |
|
|
| |
| if persist_global: |
| try: |
| if config_path.exists(): |
| with open(config_path, encoding="utf-8") as f: |
| cfg = yaml.safe_load(f) or {} |
| else: |
| cfg = {} |
| model_cfg = cfg.setdefault("model", {}) |
| model_cfg["default"] = result.new_model |
| model_cfg["provider"] = result.target_provider |
| if result.base_url: |
| model_cfg["base_url"] = result.base_url |
| from hermes_cli.config import save_config |
| save_config(cfg) |
| except Exception as e: |
| logger.warning("Failed to persist model switch: %s", e) |
|
|
| |
| provider_label = result.provider_label or result.target_provider |
| lines = [f"Model switched to `{result.new_model}`"] |
| lines.append(f"Provider: {provider_label}") |
|
|
| |
| mi = result.model_info |
| if mi: |
| if mi.context_window: |
| lines.append(f"Context: {mi.context_window:,} tokens") |
| if mi.max_output: |
| lines.append(f"Max output: {mi.max_output:,} tokens") |
| if mi.has_cost_data(): |
| lines.append(f"Cost: {mi.format_cost()}") |
| lines.append(f"Capabilities: {mi.format_capabilities()}") |
| else: |
| try: |
| from agent.model_metadata import get_model_context_length |
| ctx = get_model_context_length( |
| result.new_model, |
| base_url=result.base_url or current_base_url, |
| api_key=result.api_key or current_api_key, |
| provider=result.target_provider, |
| ) |
| lines.append(f"Context: {ctx:,} tokens") |
| except Exception: |
| pass |
|
|
| |
| cache_enabled = ( |
| ("openrouter" in (result.base_url or "").lower() and "claude" in result.new_model.lower()) |
| or result.api_mode == "anthropic_messages" |
| ) |
| if cache_enabled: |
| lines.append("Prompt caching: enabled") |
|
|
| if result.warning_message: |
| lines.append(f"Warning: {result.warning_message}") |
|
|
| if persist_global: |
| lines.append("Saved to config.yaml (`--global`)") |
| else: |
| lines.append("_(session only -- add `--global` to persist)_") |
|
|
| return "\n".join(lines) |
|
|
| async def _handle_provider_command(self, event: MessageEvent) -> str: |
| """Handle /provider command - show available providers.""" |
| import yaml |
| from hermes_cli.models import ( |
| list_available_providers, |
| normalize_provider, |
| _PROVIDER_LABELS, |
| ) |
|
|
| |
| current_provider = "openrouter" |
| model_cfg = {} |
| config_path = _hermes_home / 'config.yaml' |
| try: |
| if config_path.exists(): |
| with open(config_path, encoding="utf-8") as f: |
| cfg = yaml.safe_load(f) or {} |
| model_cfg = cfg.get("model", {}) |
| if isinstance(model_cfg, dict): |
| current_provider = model_cfg.get("provider", current_provider) |
| except Exception: |
| pass |
|
|
| current_provider = normalize_provider(current_provider) |
| if current_provider == "auto": |
| try: |
| from hermes_cli.auth import resolve_provider as _resolve_provider |
| current_provider = _resolve_provider(current_provider) |
| except Exception: |
| current_provider = "openrouter" |
|
|
| |
| if current_provider == "openrouter": |
| _cfg_base = model_cfg.get("base_url", "") if isinstance(model_cfg, dict) else "" |
| if _cfg_base and "openrouter.ai" not in _cfg_base: |
| current_provider = "custom" |
|
|
| current_label = _PROVIDER_LABELS.get(current_provider, current_provider) |
|
|
| lines = [ |
| f"🔌 **Current provider:** {current_label} (`{current_provider}`)", |
| "", |
| "**Available providers:**", |
| ] |
|
|
| providers = list_available_providers() |
| for p in providers: |
| marker = " ← active" if p["id"] == current_provider else "" |
| auth = "✅" if p["authenticated"] else "❌" |
| aliases = f" _(also: {', '.join(p['aliases'])})_" if p["aliases"] else "" |
| lines.append(f"{auth} `{p['id']}` — {p['label']}{aliases}{marker}") |
|
|
| lines.append("") |
| lines.append("Switch: `/model provider:model-name`") |
| lines.append("Setup: `hermes setup`") |
| return "\n".join(lines) |
| |
| async def _handle_personality_command(self, event: MessageEvent) -> str: |
| """Handle /personality command - list or set a personality.""" |
| import yaml |
|
|
| args = event.get_command_args().strip().lower() |
| config_path = _hermes_home / 'config.yaml' |
|
|
| try: |
| if config_path.exists(): |
| with open(config_path, 'r', encoding="utf-8") as f: |
| config = yaml.safe_load(f) or {} |
| personalities = config.get("agent", {}).get("personalities", {}) |
| else: |
| config = {} |
| personalities = {} |
| except Exception: |
| config = {} |
| personalities = {} |
|
|
| if not personalities: |
| return "No personalities configured in `~/.hermes/config.yaml`" |
|
|
| if not args: |
| lines = ["🎭 **Available Personalities**\n"] |
| lines.append("• `none` — (no personality overlay)") |
| for name, prompt in personalities.items(): |
| if isinstance(prompt, dict): |
| preview = prompt.get("description") or prompt.get("system_prompt", "")[:50] |
| else: |
| preview = prompt[:50] + "..." if len(prompt) > 50 else prompt |
| lines.append(f"• `{name}` — {preview}") |
| lines.append("\nUsage: `/personality <name>`") |
| return "\n".join(lines) |
|
|
| def _resolve_prompt(value): |
| if isinstance(value, dict): |
| parts = [value.get("system_prompt", "")] |
| if value.get("tone"): |
| parts.append(f'Tone: {value["tone"]}') |
| if value.get("style"): |
| parts.append(f'Style: {value["style"]}') |
| return "\n".join(p for p in parts if p) |
| return str(value) |
|
|
| if args in ("none", "default", "neutral"): |
| try: |
| if "agent" not in config or not isinstance(config.get("agent"), dict): |
| config["agent"] = {} |
| config["agent"]["system_prompt"] = "" |
| atomic_yaml_write(config_path, config) |
| except Exception as e: |
| return f"⚠️ Failed to save personality change: {e}" |
| self._ephemeral_system_prompt = "" |
| return "🎭 Personality cleared — using base agent behavior.\n_(takes effect on next message)_" |
| elif args in personalities: |
| new_prompt = _resolve_prompt(personalities[args]) |
|
|
| |
| try: |
| if "agent" not in config or not isinstance(config.get("agent"), dict): |
| config["agent"] = {} |
| config["agent"]["system_prompt"] = new_prompt |
| atomic_yaml_write(config_path, config) |
| except Exception as e: |
| return f"⚠️ Failed to save personality change: {e}" |
|
|
| |
| self._ephemeral_system_prompt = new_prompt |
|
|
| return f"🎭 Personality set to **{args}**\n_(takes effect on next message)_" |
|
|
| available = "`none`, " + ", ".join(f"`{n}`" for n in personalities) |
| return f"Unknown personality: `{args}`\n\nAvailable: {available}" |
| |
| async def _handle_retry_command(self, event: MessageEvent) -> str: |
| """Handle /retry command - re-send the last user message.""" |
| source = event.source |
| session_entry = self.session_store.get_or_create_session(source) |
| history = self.session_store.load_transcript(session_entry.session_id) |
| |
| |
| last_user_msg = None |
| last_user_idx = None |
| for i in range(len(history) - 1, -1, -1): |
| if history[i].get("role") == "user": |
| last_user_msg = history[i].get("content", "") |
| last_user_idx = i |
| break |
| |
| if not last_user_msg: |
| return "No previous message to retry." |
| |
| |
| truncated = history[:last_user_idx] |
| self.session_store.rewrite_transcript(session_entry.session_id, truncated) |
| |
| session_entry.last_prompt_tokens = 0 |
| |
| |
| retry_event = MessageEvent( |
| text=last_user_msg, |
| message_type=MessageType.TEXT, |
| source=source, |
| raw_message=event.raw_message, |
| ) |
| |
| |
| return await self._handle_message(retry_event) |
| |
| async def _handle_undo_command(self, event: MessageEvent) -> str: |
| """Handle /undo command - remove the last user/assistant exchange.""" |
| source = event.source |
| session_entry = self.session_store.get_or_create_session(source) |
| history = self.session_store.load_transcript(session_entry.session_id) |
| |
| |
| last_user_idx = None |
| for i in range(len(history) - 1, -1, -1): |
| if history[i].get("role") == "user": |
| last_user_idx = i |
| break |
| |
| if last_user_idx is None: |
| return "Nothing to undo." |
| |
| removed_msg = history[last_user_idx].get("content", "") |
| removed_count = len(history) - last_user_idx |
| self.session_store.rewrite_transcript(session_entry.session_id, history[:last_user_idx]) |
| |
| session_entry.last_prompt_tokens = 0 |
| |
| preview = removed_msg[:40] + "..." if len(removed_msg) > 40 else removed_msg |
| return f"↩️ Undid {removed_count} message(s).\nRemoved: \"{preview}\"" |
| |
| async def _handle_set_home_command(self, event: MessageEvent) -> str: |
| """Handle /sethome command -- set the current chat as the platform's home channel.""" |
| source = event.source |
| platform_name = source.platform.value if source.platform else "unknown" |
| chat_id = source.chat_id |
| chat_name = source.chat_name or chat_id |
| |
| env_key = f"{platform_name.upper()}_HOME_CHANNEL" |
| |
| |
| try: |
| import yaml |
| config_path = _hermes_home / 'config.yaml' |
| user_config = {} |
| if config_path.exists(): |
| with open(config_path, encoding="utf-8") as f: |
| user_config = yaml.safe_load(f) or {} |
| user_config[env_key] = chat_id |
| atomic_yaml_write(config_path, user_config) |
| |
| os.environ[env_key] = str(chat_id) |
| except Exception as e: |
| return f"Failed to save home channel: {e}" |
| |
| return ( |
| f"✅ Home channel set to **{chat_name}** (ID: {chat_id}).\n" |
| f"Cron jobs and cross-platform messages will be delivered here." |
| ) |
| |
| @staticmethod |
| def _get_guild_id(event: MessageEvent) -> Optional[int]: |
| """Extract Discord guild_id from the raw message object.""" |
| raw = getattr(event, "raw_message", None) |
| if raw is None: |
| return None |
| |
| if hasattr(raw, "guild_id") and raw.guild_id: |
| return int(raw.guild_id) |
| |
| if hasattr(raw, "guild") and raw.guild: |
| return raw.guild.id |
| return None |
|
|
| async def _handle_voice_command(self, event: MessageEvent) -> str: |
| """Handle /voice [on|off|tts|channel|leave|status] command.""" |
| args = event.get_command_args().strip().lower() |
| chat_id = event.source.chat_id |
|
|
| adapter = self.adapters.get(event.source.platform) |
|
|
| if args in ("on", "enable"): |
| self._voice_mode[chat_id] = "voice_only" |
| self._save_voice_modes() |
| if adapter: |
| self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=False) |
| return ( |
| "Voice mode enabled.\n" |
| "I'll reply with voice when you send voice messages.\n" |
| "Use /voice tts to get voice replies for all messages." |
| ) |
| elif args in ("off", "disable"): |
| self._voice_mode[chat_id] = "off" |
| self._save_voice_modes() |
| if adapter: |
| self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True) |
| return "Voice mode disabled. Text-only replies." |
| elif args == "tts": |
| self._voice_mode[chat_id] = "all" |
| self._save_voice_modes() |
| if adapter: |
| self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=False) |
| return ( |
| "Auto-TTS enabled.\n" |
| "All replies will include a voice message." |
| ) |
| elif args in ("channel", "join"): |
| return await self._handle_voice_channel_join(event) |
| elif args == "leave": |
| return await self._handle_voice_channel_leave(event) |
| elif args == "status": |
| mode = self._voice_mode.get(chat_id, "off") |
| labels = { |
| "off": "Off (text only)", |
| "voice_only": "On (voice reply to voice messages)", |
| "all": "TTS (voice reply to all messages)", |
| } |
| |
| adapter = self.adapters.get(event.source.platform) |
| guild_id = self._get_guild_id(event) |
| if guild_id and hasattr(adapter, "get_voice_channel_info"): |
| info = adapter.get_voice_channel_info(guild_id) |
| if info: |
| lines = [ |
| f"Voice mode: {labels.get(mode, mode)}", |
| f"Voice channel: #{info['channel_name']}", |
| f"Participants: {info['member_count']}", |
| ] |
| for m in info["members"]: |
| status = " (speaking)" if m.get("is_speaking") else "" |
| lines.append(f" - {m['display_name']}{status}") |
| return "\n".join(lines) |
| return f"Voice mode: {labels.get(mode, mode)}" |
| else: |
| |
| current = self._voice_mode.get(chat_id, "off") |
| if current == "off": |
| self._voice_mode[chat_id] = "voice_only" |
| self._save_voice_modes() |
| if adapter: |
| self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=False) |
| return "Voice mode enabled." |
| else: |
| self._voice_mode[chat_id] = "off" |
| self._save_voice_modes() |
| if adapter: |
| self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True) |
| return "Voice mode disabled." |
|
|
| async def _handle_voice_channel_join(self, event: MessageEvent) -> str: |
| """Join the user's current Discord voice channel.""" |
| adapter = self.adapters.get(event.source.platform) |
| if not hasattr(adapter, "join_voice_channel"): |
| return "Voice channels are not supported on this platform." |
|
|
| guild_id = self._get_guild_id(event) |
| if not guild_id: |
| return "This command only works in a Discord server." |
|
|
| voice_channel = await adapter.get_user_voice_channel( |
| guild_id, event.source.user_id |
| ) |
| if not voice_channel: |
| return "You need to be in a voice channel first." |
|
|
| |
| |
| if hasattr(adapter, "_voice_input_callback"): |
| adapter._voice_input_callback = self._handle_voice_channel_input |
| if hasattr(adapter, "_on_voice_disconnect"): |
| adapter._on_voice_disconnect = self._handle_voice_timeout_cleanup |
|
|
| try: |
| success = await adapter.join_voice_channel(voice_channel) |
| except Exception as e: |
| logger.warning("Failed to join voice channel: %s", e) |
| adapter._voice_input_callback = None |
| err_lower = str(e).lower() |
| if "pynacl" in err_lower or "nacl" in err_lower or "davey" in err_lower: |
| return ( |
| "Voice dependencies are missing (PyNaCl / davey). " |
| "Install or reinstall Hermes with the messaging extra, e.g. " |
| "`pip install hermes-agent[messaging]`." |
| ) |
| return f"Failed to join voice channel: {e}" |
|
|
| if success: |
| adapter._voice_text_channels[guild_id] = int(event.source.chat_id) |
| if hasattr(adapter, "_voice_sources"): |
| adapter._voice_sources[guild_id] = event.source.to_dict() |
| self._voice_mode[event.source.chat_id] = "all" |
| self._save_voice_modes() |
| self._set_adapter_auto_tts_disabled(adapter, event.source.chat_id, disabled=False) |
| return ( |
| f"Joined voice channel **{voice_channel.name}**.\n" |
| f"I'll speak my replies and listen to you. Use /voice leave to disconnect." |
| ) |
| |
| adapter._voice_input_callback = None |
| return "Failed to join voice channel. Check bot permissions (Connect + Speak)." |
|
|
| async def _handle_voice_channel_leave(self, event: MessageEvent) -> str: |
| """Leave the Discord voice channel.""" |
| adapter = self.adapters.get(event.source.platform) |
| guild_id = self._get_guild_id(event) |
|
|
| if not guild_id or not hasattr(adapter, "leave_voice_channel"): |
| return "Not in a voice channel." |
|
|
| if not hasattr(adapter, "is_in_voice_channel") or not adapter.is_in_voice_channel(guild_id): |
| return "Not in a voice channel." |
|
|
| try: |
| await adapter.leave_voice_channel(guild_id) |
| except Exception as e: |
| logger.warning("Error leaving voice channel: %s", e) |
| |
| self._voice_mode[event.source.chat_id] = "off" |
| self._save_voice_modes() |
| self._set_adapter_auto_tts_disabled(adapter, event.source.chat_id, disabled=True) |
| if hasattr(adapter, "_voice_input_callback"): |
| adapter._voice_input_callback = None |
| return "Left voice channel." |
|
|
| def _handle_voice_timeout_cleanup(self, chat_id: str) -> None: |
| """Called by the adapter when a voice channel times out. |
| |
| Cleans up runner-side voice_mode state that the adapter cannot reach. |
| """ |
| self._voice_mode[chat_id] = "off" |
| self._save_voice_modes() |
| adapter = self.adapters.get(Platform.DISCORD) |
| self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True) |
|
|
| async def _handle_voice_channel_input( |
| self, guild_id: int, user_id: int, transcript: str |
| ): |
| """Handle transcribed voice from a user in a voice channel. |
| |
| Creates a synthetic MessageEvent and processes it through the |
| adapter's full message pipeline (session, typing, agent, TTS reply). |
| """ |
| adapter = self.adapters.get(Platform.DISCORD) |
| if not adapter: |
| return |
|
|
| text_ch_id = adapter._voice_text_channels.get(guild_id) |
| if not text_ch_id: |
| return |
|
|
| |
| |
| source_data = getattr(adapter, "_voice_sources", {}).get(guild_id) |
| if source_data: |
| source = SessionSource.from_dict(source_data) |
| source.user_id = str(user_id) |
| source.user_name = str(user_id) |
| else: |
| source = SessionSource( |
| platform=Platform.DISCORD, |
| chat_id=str(text_ch_id), |
| user_id=str(user_id), |
| user_name=str(user_id), |
| chat_type="channel", |
| ) |
|
|
| |
| if not self._is_user_authorized(source): |
| logger.debug("Unauthorized voice input from user %d, ignoring", user_id) |
| return |
|
|
| |
| try: |
| channel = adapter._client.get_channel(text_ch_id) |
| if channel: |
| safe_text = transcript[:2000].replace("@everyone", "@\u200beveryone").replace("@here", "@\u200bhere") |
| await channel.send(f"**[Voice]** <@{user_id}>: {safe_text}") |
| except Exception: |
| pass |
|
|
| |
| |
| |
| from types import SimpleNamespace |
| event = MessageEvent( |
| source=source, |
| text=transcript, |
| message_type=MessageType.VOICE, |
| raw_message=SimpleNamespace(guild_id=guild_id, guild=None), |
| ) |
|
|
| await adapter.handle_message(event) |
|
|
| def _should_send_voice_reply( |
| self, |
| event: MessageEvent, |
| response: str, |
| agent_messages: list, |
| already_sent: bool = False, |
| ) -> bool: |
| """Decide whether the runner should send a TTS voice reply. |
| |
| Returns False when: |
| - voice_mode is off for this chat |
| - response is empty or an error |
| - agent already called text_to_speech tool (dedup) |
| - voice input and base adapter auto-TTS already handled it (skip_double) |
| UNLESS streaming already consumed the response (already_sent=True), |
| in which case the base adapter won't have text for auto-TTS so the |
| runner must handle it. |
| """ |
| if not response or response.startswith("Error:"): |
| return False |
|
|
| chat_id = event.source.chat_id |
| voice_mode = self._voice_mode.get(chat_id, "off") |
| is_voice_input = (event.message_type == MessageType.VOICE) |
|
|
| should = ( |
| (voice_mode == "all") |
| or (voice_mode == "voice_only" and is_voice_input) |
| ) |
| if not should: |
| return False |
|
|
| |
| has_agent_tts = any( |
| msg.get("role") == "assistant" |
| and any( |
| tc.get("function", {}).get("name") == "text_to_speech" |
| for tc in (msg.get("tool_calls") or []) |
| ) |
| for msg in agent_messages |
| ) |
| if has_agent_tts: |
| return False |
|
|
| |
| |
| |
| |
| |
| if is_voice_input and not already_sent: |
| return False |
|
|
| return True |
|
|
| async def _send_voice_reply(self, event: MessageEvent, text: str) -> None: |
| """Generate TTS audio and send as a voice message before the text reply.""" |
| import uuid as _uuid |
| audio_path = None |
| actual_path = None |
| try: |
| from tools.tts_tool import text_to_speech_tool, _strip_markdown_for_tts |
|
|
| tts_text = _strip_markdown_for_tts(text[:4000]) |
| if not tts_text: |
| return |
|
|
| |
| |
| audio_path = os.path.join( |
| tempfile.gettempdir(), "hermes_voice", |
| f"tts_reply_{_uuid.uuid4().hex[:12]}.mp3", |
| ) |
| os.makedirs(os.path.dirname(audio_path), exist_ok=True) |
|
|
| result_json = await asyncio.to_thread( |
| text_to_speech_tool, text=tts_text, output_path=audio_path |
| ) |
| result = json.loads(result_json) |
|
|
| |
| actual_path = result.get("file_path", audio_path) |
| if not result.get("success") or not os.path.isfile(actual_path): |
| logger.warning("Auto voice reply TTS failed: %s", result.get("error")) |
| return |
|
|
| adapter = self.adapters.get(event.source.platform) |
|
|
| |
| guild_id = self._get_guild_id(event) |
| if (guild_id |
| and hasattr(adapter, "play_in_voice_channel") |
| and hasattr(adapter, "is_in_voice_channel") |
| and adapter.is_in_voice_channel(guild_id)): |
| await adapter.play_in_voice_channel(guild_id, actual_path) |
| elif adapter and hasattr(adapter, "send_voice"): |
| send_kwargs: Dict[str, Any] = { |
| "chat_id": event.source.chat_id, |
| "audio_path": actual_path, |
| "reply_to": event.message_id, |
| } |
| if event.source.thread_id: |
| send_kwargs["metadata"] = {"thread_id": event.source.thread_id} |
| await adapter.send_voice(**send_kwargs) |
| except Exception as e: |
| logger.warning("Auto voice reply failed: %s", e, exc_info=True) |
| finally: |
| for p in {audio_path, actual_path} - {None}: |
| try: |
| os.unlink(p) |
| except OSError: |
| pass |
|
|
| async def _deliver_media_from_response( |
| self, |
| response: str, |
| event: MessageEvent, |
| adapter, |
| ) -> None: |
| """Extract MEDIA: tags and local file paths from a response and deliver them. |
| |
| Called after streaming has already sent the text to the user, so the |
| text itself is already delivered — this only handles file attachments |
| that the normal _process_message_background path would have caught. |
| """ |
| from pathlib import Path |
|
|
| try: |
| media_files, _ = adapter.extract_media(response) |
| _, cleaned = adapter.extract_images(response) |
| local_files, _ = adapter.extract_local_files(cleaned) |
|
|
| _thread_meta = {"thread_id": event.source.thread_id} if event.source.thread_id else None |
|
|
| _AUDIO_EXTS = {'.ogg', '.opus', '.mp3', '.wav', '.m4a'} |
| _VIDEO_EXTS = {'.mp4', '.mov', '.avi', '.mkv', '.webm', '.3gp'} |
| _IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.webp', '.gif'} |
|
|
| for media_path, is_voice in media_files: |
| try: |
| ext = Path(media_path).suffix.lower() |
| if ext in _AUDIO_EXTS: |
| await adapter.send_voice( |
| chat_id=event.source.chat_id, |
| audio_path=media_path, |
| metadata=_thread_meta, |
| ) |
| elif ext in _VIDEO_EXTS: |
| await adapter.send_video( |
| chat_id=event.source.chat_id, |
| video_path=media_path, |
| metadata=_thread_meta, |
| ) |
| elif ext in _IMAGE_EXTS: |
| await adapter.send_image_file( |
| chat_id=event.source.chat_id, |
| image_path=media_path, |
| metadata=_thread_meta, |
| ) |
| else: |
| await adapter.send_document( |
| chat_id=event.source.chat_id, |
| file_path=media_path, |
| metadata=_thread_meta, |
| ) |
| except Exception as e: |
| logger.warning("[%s] Post-stream media delivery failed: %s", adapter.name, e) |
|
|
| for file_path in local_files: |
| try: |
| ext = Path(file_path).suffix.lower() |
| if ext in _IMAGE_EXTS: |
| await adapter.send_image_file( |
| chat_id=event.source.chat_id, |
| image_path=file_path, |
| metadata=_thread_meta, |
| ) |
| else: |
| await adapter.send_document( |
| chat_id=event.source.chat_id, |
| file_path=file_path, |
| metadata=_thread_meta, |
| ) |
| except Exception as e: |
| logger.warning("[%s] Post-stream file delivery failed: %s", adapter.name, e) |
|
|
| except Exception as e: |
| logger.warning("Post-stream media extraction failed: %s", e) |
|
|
| async def _handle_rollback_command(self, event: MessageEvent) -> str: |
| """Handle /rollback command — list or restore filesystem checkpoints.""" |
| from tools.checkpoint_manager import CheckpointManager, format_checkpoint_list |
|
|
| |
| cp_cfg = {} |
| try: |
| import yaml as _y |
| _cfg_path = _hermes_home / "config.yaml" |
| if _cfg_path.exists(): |
| with open(_cfg_path, encoding="utf-8") as _f: |
| _data = _y.safe_load(_f) or {} |
| cp_cfg = _data.get("checkpoints", {}) |
| if isinstance(cp_cfg, bool): |
| cp_cfg = {"enabled": cp_cfg} |
| except Exception: |
| pass |
|
|
| if not cp_cfg.get("enabled", False): |
| return ( |
| "Checkpoints are not enabled.\n" |
| "Enable in config.yaml:\n```\ncheckpoints:\n enabled: true\n```" |
| ) |
|
|
| mgr = CheckpointManager( |
| enabled=True, |
| max_snapshots=cp_cfg.get("max_snapshots", 50), |
| ) |
|
|
| cwd = os.getenv("MESSAGING_CWD", str(Path.home())) |
| arg = event.get_command_args().strip() |
|
|
| if not arg: |
| checkpoints = mgr.list_checkpoints(cwd) |
| return format_checkpoint_list(checkpoints, cwd) |
|
|
| |
| checkpoints = mgr.list_checkpoints(cwd) |
| if not checkpoints: |
| return f"No checkpoints found for {cwd}" |
|
|
| target_hash = None |
| try: |
| idx = int(arg) - 1 |
| if 0 <= idx < len(checkpoints): |
| target_hash = checkpoints[idx]["hash"] |
| else: |
| return f"Invalid checkpoint number. Use 1-{len(checkpoints)}." |
| except ValueError: |
| target_hash = arg |
|
|
| result = mgr.restore(cwd, target_hash) |
| if result["success"]: |
| return ( |
| f"✅ Restored to checkpoint {result['restored_to']}: {result['reason']}\n" |
| f"A pre-rollback snapshot was saved automatically." |
| ) |
| return f"❌ {result['error']}" |
|
|
| async def _handle_background_command(self, event: MessageEvent) -> str: |
| """Handle /background <prompt> — run a prompt in a separate background session. |
| |
| Spawns a new AIAgent in a background thread with its own session. |
| When it completes, sends the result back to the same chat without |
| modifying the active session's conversation history. |
| """ |
| prompt = event.get_command_args().strip() |
| if not prompt: |
| return ( |
| "Usage: /background <prompt>\n" |
| "Example: /background Summarize the top HN stories today\n\n" |
| "Runs the prompt in a separate session. " |
| "You can keep chatting — the result will appear here when done." |
| ) |
|
|
| source = event.source |
| task_id = f"bg_{datetime.now().strftime('%H%M%S')}_{os.urandom(3).hex()}" |
|
|
| |
| _task = asyncio.create_task( |
| self._run_background_task(prompt, source, task_id) |
| ) |
| self._background_tasks.add(_task) |
| _task.add_done_callback(self._background_tasks.discard) |
|
|
| preview = prompt[:60] + ("..." if len(prompt) > 60 else "") |
| return f'🔄 Background task started: "{preview}"\nTask ID: {task_id}\nYou can keep chatting — results will appear when done.' |
|
|
| async def _run_background_task( |
| self, prompt: str, source: "SessionSource", task_id: str |
| ) -> None: |
| """Execute a background agent task and deliver the result to the chat.""" |
| from run_agent import AIAgent |
|
|
| adapter = self.adapters.get(source.platform) |
| if not adapter: |
| logger.warning("No adapter for platform %s in background task %s", source.platform, task_id) |
| return |
|
|
| _thread_metadata = {"thread_id": source.thread_id} if source.thread_id else None |
|
|
| try: |
| user_config = _load_gateway_config() |
| model, runtime_kwargs = self._resolve_session_agent_runtime( |
| source=source, |
| user_config=user_config, |
| ) |
| if not runtime_kwargs.get("api_key"): |
| await adapter.send( |
| source.chat_id, |
| f"❌ Background task {task_id} failed: no provider credentials configured.", |
| metadata=_thread_metadata, |
| ) |
| return |
|
|
| platform_key = _platform_config_key(source.platform) |
|
|
| from hermes_cli.tools_config import _get_platform_tools |
| enabled_toolsets = sorted(_get_platform_tools(user_config, platform_key)) |
|
|
| pr = self._provider_routing |
| max_iterations = int(os.getenv("HERMES_MAX_ITERATIONS", "90")) |
| reasoning_config = self._load_reasoning_config() |
| self._reasoning_config = reasoning_config |
| self._service_tier = self._load_service_tier() |
| turn_route = self._resolve_turn_agent_config(prompt, model, runtime_kwargs) |
|
|
| def run_sync(): |
| agent = AIAgent( |
| model=turn_route["model"], |
| **turn_route["runtime"], |
| max_iterations=max_iterations, |
| quiet_mode=True, |
| verbose_logging=False, |
| enabled_toolsets=enabled_toolsets, |
| reasoning_config=reasoning_config, |
| service_tier=self._service_tier, |
| request_overrides=turn_route.get("request_overrides"), |
| providers_allowed=pr.get("only"), |
| providers_ignored=pr.get("ignore"), |
| providers_order=pr.get("order"), |
| provider_sort=pr.get("sort"), |
| provider_require_parameters=pr.get("require_parameters", False), |
| provider_data_collection=pr.get("data_collection"), |
| session_id=task_id, |
| platform=platform_key, |
| user_id=source.user_id, |
| session_db=self._session_db, |
| fallback_model=self._fallback_model, |
| ) |
|
|
| return agent.run_conversation( |
| user_message=prompt, |
| task_id=task_id, |
| ) |
|
|
| loop = asyncio.get_event_loop() |
| result = await loop.run_in_executor(None, run_sync) |
|
|
| response = result.get("final_response", "") if result else "" |
| if not response and result and result.get("error"): |
| response = f"Error: {result['error']}" |
|
|
| |
| if response: |
| media_files, response = adapter.extract_media(response) |
| images, text_content = adapter.extract_images(response) |
|
|
| preview = prompt[:60] + ("..." if len(prompt) > 60 else "") |
| header = f'✅ Background task complete\nPrompt: "{preview}"\n\n' |
|
|
| if text_content: |
| await adapter.send( |
| chat_id=source.chat_id, |
| content=header + text_content, |
| metadata=_thread_metadata, |
| ) |
| elif not images and not media_files: |
| await adapter.send( |
| chat_id=source.chat_id, |
| content=header + "(No response generated)", |
| metadata=_thread_metadata, |
| ) |
|
|
| |
| for image_url, alt_text in (images or []): |
| try: |
| await adapter.send_image( |
| chat_id=source.chat_id, |
| image_url=image_url, |
| caption=alt_text, |
| ) |
| except Exception: |
| pass |
|
|
| |
| for media_path in (media_files or []): |
| try: |
| await adapter.send_document( |
| chat_id=source.chat_id, |
| file_path=media_path, |
| ) |
| except Exception: |
| pass |
| else: |
| preview = prompt[:60] + ("..." if len(prompt) > 60 else "") |
| await adapter.send( |
| chat_id=source.chat_id, |
| content=f'✅ Background task complete\nPrompt: "{preview}"\n\n(No response generated)', |
| metadata=_thread_metadata, |
| ) |
|
|
| except Exception as e: |
| logger.exception("Background task %s failed", task_id) |
| try: |
| await adapter.send( |
| chat_id=source.chat_id, |
| content=f"❌ Background task {task_id} failed: {e}", |
| metadata=_thread_metadata, |
| ) |
| except Exception: |
| pass |
|
|
| async def _handle_btw_command(self, event: MessageEvent) -> str: |
| """Handle /btw <question> — ephemeral side question in the same chat.""" |
| question = event.get_command_args().strip() |
| if not question: |
| return ( |
| "Usage: /btw <question>\n" |
| "Example: /btw what module owns session title sanitization?\n\n" |
| "Answers using session context. No tools, not persisted." |
| ) |
|
|
| source = event.source |
| session_key = self._session_key_for_source(source) |
|
|
| |
| existing = getattr(self, "_active_btw_tasks", {}).get(session_key) |
| if existing and not existing.done(): |
| return "A /btw is already running for this chat. Wait for it to finish." |
|
|
| if not hasattr(self, "_active_btw_tasks"): |
| self._active_btw_tasks: dict = {} |
|
|
| import uuid as _uuid |
| task_id = f"btw_{datetime.now().strftime('%H%M%S')}_{_uuid.uuid4().hex[:6]}" |
| _task = asyncio.create_task(self._run_btw_task(question, source, session_key, task_id)) |
| self._background_tasks.add(_task) |
| self._active_btw_tasks[session_key] = _task |
|
|
| def _cleanup(task): |
| self._background_tasks.discard(task) |
| if self._active_btw_tasks.get(session_key) is task: |
| self._active_btw_tasks.pop(session_key, None) |
|
|
| _task.add_done_callback(_cleanup) |
|
|
| preview = question[:60] + ("..." if len(question) > 60 else "") |
| return f'💬 /btw: "{preview}"\nReply will appear here shortly.' |
|
|
| async def _run_btw_task( |
| self, question: str, source, session_key: str, task_id: str, |
| ) -> None: |
| """Execute an ephemeral /btw side question and deliver the answer.""" |
| from run_agent import AIAgent |
|
|
| adapter = self.adapters.get(source.platform) |
| if not adapter: |
| logger.warning("No adapter for platform %s in /btw task %s", source.platform, task_id) |
| return |
|
|
| _thread_meta = {"thread_id": source.thread_id} if source.thread_id else None |
|
|
| try: |
| user_config = _load_gateway_config() |
| model, runtime_kwargs = self._resolve_session_agent_runtime( |
| source=source, |
| session_key=session_key, |
| user_config=user_config, |
| ) |
| if not runtime_kwargs.get("api_key"): |
| await adapter.send( |
| source.chat_id, |
| "❌ /btw failed: no provider credentials configured.", |
| metadata=_thread_meta, |
| ) |
| return |
|
|
| platform_key = _platform_config_key(source.platform) |
| reasoning_config = self._load_reasoning_config() |
| self._service_tier = self._load_service_tier() |
| turn_route = self._resolve_turn_agent_config(question, model, runtime_kwargs) |
| pr = self._provider_routing |
|
|
| |
| running_agent = self._running_agents.get(session_key) |
| if running_agent and running_agent is not _AGENT_PENDING_SENTINEL: |
| history_snapshot = list(getattr(running_agent, "_session_messages", []) or []) |
| else: |
| session_entry = self.session_store.get_or_create_session(source) |
| history_snapshot = self.session_store.load_transcript(session_entry.session_id) |
|
|
| btw_prompt = ( |
| "[Ephemeral /btw side question. Answer using the conversation " |
| "context. No tools available. Be direct and concise.]\n\n" |
| + question |
| ) |
|
|
| def run_sync(): |
| agent = AIAgent( |
| model=turn_route["model"], |
| **turn_route["runtime"], |
| max_iterations=8, |
| quiet_mode=True, |
| verbose_logging=False, |
| enabled_toolsets=[], |
| reasoning_config=reasoning_config, |
| service_tier=self._service_tier, |
| request_overrides=turn_route.get("request_overrides"), |
| providers_allowed=pr.get("only"), |
| providers_ignored=pr.get("ignore"), |
| providers_order=pr.get("order"), |
| provider_sort=pr.get("sort"), |
| provider_require_parameters=pr.get("require_parameters", False), |
| provider_data_collection=pr.get("data_collection"), |
| session_id=task_id, |
| platform=platform_key, |
| session_db=None, |
| fallback_model=self._fallback_model, |
| skip_memory=True, |
| skip_context_files=True, |
| persist_session=False, |
| ) |
| return agent.run_conversation( |
| user_message=btw_prompt, |
| conversation_history=history_snapshot, |
| task_id=task_id, |
| ) |
|
|
| loop = asyncio.get_event_loop() |
| result = await loop.run_in_executor(None, run_sync) |
|
|
| response = (result.get("final_response") or "") if result else "" |
| if not response and result and result.get("error"): |
| response = f"Error: {result['error']}" |
| if not response: |
| response = "(No response generated)" |
|
|
| media_files, response = adapter.extract_media(response) |
| images, text_content = adapter.extract_images(response) |
| preview = question[:60] + ("..." if len(question) > 60 else "") |
| header = f'💬 /btw: "{preview}"\n\n' |
|
|
| if text_content: |
| await adapter.send( |
| chat_id=source.chat_id, |
| content=header + text_content, |
| metadata=_thread_meta, |
| ) |
| elif not images and not media_files: |
| await adapter.send( |
| chat_id=source.chat_id, |
| content=header + "(No response generated)", |
| metadata=_thread_meta, |
| ) |
|
|
| for image_url, alt_text in (images or []): |
| try: |
| await adapter.send_image(chat_id=source.chat_id, image_url=image_url, caption=alt_text) |
| except Exception: |
| pass |
|
|
| for media_path in (media_files or []): |
| try: |
| await adapter.send_file(chat_id=source.chat_id, file_path=media_path) |
| except Exception: |
| pass |
|
|
| except Exception as e: |
| logger.exception("/btw task %s failed", task_id) |
| try: |
| await adapter.send( |
| chat_id=source.chat_id, |
| content=f"❌ /btw failed: {e}", |
| metadata=_thread_meta, |
| ) |
| except Exception: |
| pass |
|
|
| async def _handle_reasoning_command(self, event: MessageEvent) -> str: |
| """Handle /reasoning command — manage reasoning effort and display toggle. |
| |
| Usage: |
| /reasoning Show current effort level and display state |
| /reasoning <level> Set reasoning effort (none, minimal, low, medium, high, xhigh) |
| /reasoning show|on Show model reasoning in responses |
| /reasoning hide|off Hide model reasoning from responses |
| """ |
| import yaml |
|
|
| args = event.get_command_args().strip().lower() |
| config_path = _hermes_home / "config.yaml" |
| self._reasoning_config = self._load_reasoning_config() |
| self._show_reasoning = self._load_show_reasoning() |
|
|
| def _save_config_key(key_path: str, value): |
| """Save a dot-separated key to config.yaml.""" |
| try: |
| user_config = {} |
| if config_path.exists(): |
| with open(config_path, encoding="utf-8") as f: |
| user_config = yaml.safe_load(f) or {} |
| keys = key_path.split(".") |
| current = user_config |
| for k in keys[:-1]: |
| if k not in current or not isinstance(current[k], dict): |
| current[k] = {} |
| current = current[k] |
| current[keys[-1]] = value |
| atomic_yaml_write(config_path, user_config) |
| return True |
| except Exception as e: |
| logger.error("Failed to save config key %s: %s", key_path, e) |
| return False |
|
|
| if not args: |
| |
| rc = self._reasoning_config |
| if rc is None: |
| level = "medium (default)" |
| elif rc.get("enabled") is False: |
| level = "none (disabled)" |
| else: |
| level = rc.get("effort", "medium") |
| display_state = "on ✓" if self._show_reasoning else "off" |
| return ( |
| "🧠 **Reasoning Settings**\n\n" |
| f"**Effort:** `{level}`\n" |
| f"**Display:** {display_state}\n\n" |
| "_Usage:_ `/reasoning <none|minimal|low|medium|high|xhigh|show|hide>`" |
| ) |
|
|
| |
| platform_key = _platform_config_key(event.source.platform) |
| if args in ("show", "on"): |
| self._show_reasoning = True |
| _save_config_key(f"display.platforms.{platform_key}.show_reasoning", True) |
| return ( |
| "🧠 ✓ Reasoning display: **ON**\n" |
| f"Model thinking will be shown before each response on **{platform_key}**." |
| ) |
|
|
| if args in ("hide", "off"): |
| self._show_reasoning = False |
| _save_config_key(f"display.platforms.{platform_key}.show_reasoning", False) |
| return f"🧠 ✓ Reasoning display: **OFF** for **{platform_key}**" |
|
|
| |
| effort = args.strip() |
| if effort == "none": |
| parsed = {"enabled": False} |
| elif effort in ("minimal", "low", "medium", "high", "xhigh"): |
| parsed = {"enabled": True, "effort": effort} |
| else: |
| return ( |
| f"⚠️ Unknown argument: `{effort}`\n\n" |
| "**Valid levels:** none, minimal, low, medium, high, xhigh\n" |
| "**Display:** show, hide" |
| ) |
|
|
| self._reasoning_config = parsed |
| if _save_config_key("agent.reasoning_effort", effort): |
| return f"🧠 ✓ Reasoning effort set to `{effort}` (saved to config)\n_(takes effect on next message)_" |
| else: |
| return f"🧠 ✓ Reasoning effort set to `{effort}` (this session only)" |
|
|
| async def _handle_fast_command(self, event: MessageEvent) -> str: |
| """Handle /fast — mirror the CLI Priority Processing toggle in gateway chats.""" |
| import yaml |
| from hermes_cli.models import model_supports_fast_mode |
|
|
| args = event.get_command_args().strip().lower() |
| config_path = _hermes_home / "config.yaml" |
| self._service_tier = self._load_service_tier() |
|
|
| user_config = _load_gateway_config() |
| model = _resolve_gateway_model(user_config) |
| if not model_supports_fast_mode(model): |
| return "⚡ /fast is only available for OpenAI models that support Priority Processing." |
|
|
| def _save_config_key(key_path: str, value): |
| """Save a dot-separated key to config.yaml.""" |
| try: |
| user_config = {} |
| if config_path.exists(): |
| with open(config_path, encoding="utf-8") as f: |
| user_config = yaml.safe_load(f) or {} |
| keys = key_path.split(".") |
| current = user_config |
| for k in keys[:-1]: |
| if k not in current or not isinstance(current[k], dict): |
| current[k] = {} |
| current = current[k] |
| current[keys[-1]] = value |
| atomic_yaml_write(config_path, user_config) |
| return True |
| except Exception as e: |
| logger.error("Failed to save config key %s: %s", key_path, e) |
| return False |
|
|
| if not args or args == "status": |
| status = "fast" if self._service_tier == "priority" else "normal" |
| return ( |
| "⚡ Priority Processing\n\n" |
| f"Current mode: `{status}`\n\n" |
| "_Usage:_ `/fast <normal|fast|status>`" |
| ) |
|
|
| if args in {"fast", "on"}: |
| self._service_tier = "priority" |
| saved_value = "fast" |
| label = "FAST" |
| elif args in {"normal", "off"}: |
| self._service_tier = None |
| saved_value = "normal" |
| label = "NORMAL" |
| else: |
| return ( |
| f"⚠️ Unknown argument: `{args}`\n\n" |
| "**Valid options:** normal, fast, status" |
| ) |
|
|
| if _save_config_key("agent.service_tier", saved_value): |
| return f"⚡ ✓ Priority Processing: **{label}** (saved to config)\n_(takes effect on next message)_" |
| return f"⚡ ✓ Priority Processing: **{label}** (this session only)" |
|
|
| async def _handle_yolo_command(self, event: MessageEvent) -> str: |
| """Handle /yolo — toggle dangerous command approval bypass for this session only.""" |
| from tools.approval import ( |
| disable_session_yolo, |
| enable_session_yolo, |
| is_session_yolo_enabled, |
| ) |
|
|
| session_key = self._session_key_for_source(event.source) |
| current = is_session_yolo_enabled(session_key) |
| if current: |
| disable_session_yolo(session_key) |
| return "⚠️ YOLO mode **OFF** for this session — dangerous commands will require approval." |
| else: |
| enable_session_yolo(session_key) |
| return "⚡ YOLO mode **ON** for this session — all commands auto-approved. Use with caution." |
|
|
| async def _handle_verbose_command(self, event: MessageEvent) -> str: |
| """Handle /verbose command — cycle tool progress display mode. |
| |
| Gated by ``display.tool_progress_command`` in config.yaml (default off). |
| When enabled, cycles the tool progress mode through off → new → all → |
| verbose → off for the *current platform*. The setting is saved to |
| ``display.platforms.<platform>.tool_progress`` so each channel can |
| have its own verbosity level independently. |
| """ |
| import yaml |
|
|
| config_path = _hermes_home / "config.yaml" |
| platform_key = _platform_config_key(event.source.platform) |
|
|
| |
| try: |
| user_config = {} |
| if config_path.exists(): |
| with open(config_path, encoding="utf-8") as f: |
| user_config = yaml.safe_load(f) or {} |
| gate_enabled = user_config.get("display", {}).get("tool_progress_command", False) |
| except Exception: |
| gate_enabled = False |
|
|
| if not gate_enabled: |
| return ( |
| "The `/verbose` command is not enabled for messaging platforms.\n\n" |
| "Enable it in `config.yaml`:\n```yaml\n" |
| "display:\n tool_progress_command: true\n```" |
| ) |
|
|
| |
| cycle = ["off", "new", "all", "verbose"] |
| descriptions = { |
| "off": "⚙️ Tool progress: **OFF** — no tool activity shown.", |
| "new": "⚙️ Tool progress: **NEW** — shown when tool changes (preview length: `display.tool_preview_length`, default 40).", |
| "all": "⚙️ Tool progress: **ALL** — every tool call shown (preview length: `display.tool_preview_length`, default 40).", |
| "verbose": "⚙️ Tool progress: **VERBOSE** — every tool call with full arguments.", |
| } |
|
|
| |
| from gateway.display_config import resolve_display_setting |
| current = resolve_display_setting(user_config, platform_key, "tool_progress", "all") |
| if current not in cycle: |
| current = "all" |
| idx = (cycle.index(current) + 1) % len(cycle) |
| new_mode = cycle[idx] |
|
|
| |
| try: |
| if "display" not in user_config or not isinstance(user_config.get("display"), dict): |
| user_config["display"] = {} |
| display = user_config["display"] |
| if "platforms" not in display or not isinstance(display.get("platforms"), dict): |
| display["platforms"] = {} |
| if platform_key not in display["platforms"] or not isinstance(display["platforms"].get(platform_key), dict): |
| display["platforms"][platform_key] = {} |
| display["platforms"][platform_key]["tool_progress"] = new_mode |
| atomic_yaml_write(config_path, user_config) |
| return ( |
| f"{descriptions[new_mode]}\n" |
| f"_(saved for **{platform_key}** — takes effect on next message)_" |
| ) |
| except Exception as e: |
| logger.warning("Failed to save tool_progress mode: %s", e) |
| return f"{descriptions[new_mode]}\n_(could not save to config: {e})_" |
|
|
| async def _handle_compress_command(self, event: MessageEvent) -> str: |
| """Handle /compress command -- manually compress conversation context. |
| |
| Accepts an optional focus topic: ``/compress <focus>`` guides the |
| summariser to preserve information related to *focus* while being |
| more aggressive about discarding everything else. |
| """ |
| source = event.source |
| session_entry = self.session_store.get_or_create_session(source) |
| history = self.session_store.load_transcript(session_entry.session_id) |
|
|
| if not history or len(history) < 4: |
| return "Not enough conversation to compress (need at least 4 messages)." |
|
|
| |
| focus_topic = (event.get_command_args() or "").strip() or None |
|
|
| try: |
| from run_agent import AIAgent |
| from agent.manual_compression_feedback import summarize_manual_compression |
| from agent.model_metadata import estimate_messages_tokens_rough |
|
|
| session_key = self._session_key_for_source(source) |
| model, runtime_kwargs = self._resolve_session_agent_runtime( |
| source=source, |
| session_key=session_key, |
| ) |
| if not runtime_kwargs.get("api_key"): |
| return "No provider configured -- cannot compress." |
|
|
| msgs = [ |
| {"role": m.get("role"), "content": m.get("content")} |
| for m in history |
| if m.get("role") in ("user", "assistant") and m.get("content") |
| ] |
| original_count = len(msgs) |
| approx_tokens = estimate_messages_tokens_rough(msgs) |
|
|
| tmp_agent = AIAgent( |
| **runtime_kwargs, |
| model=model, |
| max_iterations=4, |
| quiet_mode=True, |
| enabled_toolsets=["memory"], |
| session_id=session_entry.session_id, |
| ) |
| tmp_agent._print_fn = lambda *a, **kw: None |
|
|
| compressor = tmp_agent.context_compressor |
| compress_start = compressor.protect_first_n |
| compress_start = compressor._align_boundary_forward(msgs, compress_start) |
| compress_end = compressor._find_tail_cut_by_tokens(msgs, compress_start) |
| if compress_start >= compress_end: |
| return "Nothing to compress yet (the transcript is still all protected context)." |
|
|
| loop = asyncio.get_event_loop() |
| compressed, _ = await loop.run_in_executor( |
| None, |
| lambda: tmp_agent._compress_context(msgs, "", approx_tokens=approx_tokens, focus_topic=focus_topic) |
| ) |
|
|
| |
| |
| |
| |
| new_session_id = tmp_agent.session_id |
| if new_session_id != session_entry.session_id: |
| session_entry.session_id = new_session_id |
| self.session_store._save() |
|
|
| self.session_store.rewrite_transcript(new_session_id, compressed) |
| |
| self.session_store.update_session( |
| session_entry.session_key, last_prompt_tokens=0 |
| ) |
| new_tokens = estimate_messages_tokens_rough(compressed) |
| summary = summarize_manual_compression( |
| msgs, |
| compressed, |
| approx_tokens, |
| new_tokens, |
| ) |
| lines = [f"🗜️ {summary['headline']}"] |
| if focus_topic: |
| lines.append(f"Focus: \"{focus_topic}\"") |
| lines.append(summary["token_line"]) |
| if summary["note"]: |
| lines.append(summary["note"]) |
| return "\n".join(lines) |
| except Exception as e: |
| logger.warning("Manual compress failed: %s", e) |
| return f"Compression failed: {e}" |
|
|
| async def _handle_title_command(self, event: MessageEvent) -> str: |
| """Handle /title command — set or show the current session's title.""" |
| source = event.source |
| session_entry = self.session_store.get_or_create_session(source) |
| session_id = session_entry.session_id |
|
|
| if not self._session_db: |
| return "Session database not available." |
|
|
| |
| |
| existing_title = self._session_db.get_session_title(session_id) |
| if existing_title is None: |
| |
| try: |
| self._session_db.create_session( |
| session_id=session_id, |
| source=source.platform.value if source.platform else "unknown", |
| user_id=source.user_id, |
| ) |
| except Exception: |
| pass |
|
|
| title_arg = event.get_command_args().strip() |
| if title_arg: |
| |
| try: |
| sanitized = self._session_db.sanitize_title(title_arg) |
| except ValueError as e: |
| return f"⚠️ {e}" |
| if not sanitized: |
| return "⚠️ Title is empty after cleanup. Please use printable characters." |
| |
| try: |
| if self._session_db.set_session_title(session_id, sanitized): |
| return f"✏️ Session title set: **{sanitized}**" |
| else: |
| return "Session not found in database." |
| except ValueError as e: |
| return f"⚠️ {e}" |
| else: |
| |
| title = self._session_db.get_session_title(session_id) |
| if title: |
| return f"📌 Session: `{session_id}`\nTitle: **{title}**" |
| else: |
| return f"📌 Session: `{session_id}`\nNo title set. Usage: `/title My Session Name`" |
|
|
| async def _handle_resume_command(self, event: MessageEvent) -> str: |
| """Handle /resume command — switch to a previously-named session.""" |
| if not self._session_db: |
| return "Session database not available." |
|
|
| source = event.source |
| session_key = self._session_key_for_source(source) |
| name = event.get_command_args().strip() |
|
|
| if not name: |
| |
| try: |
| user_source = source.platform.value if source.platform else None |
| sessions = self._session_db.list_sessions_rich( |
| source=user_source, limit=10 |
| ) |
| titled = [s for s in sessions if s.get("title")] |
| if not titled: |
| return ( |
| "No named sessions found.\n" |
| "Use `/title My Session` to name your current session, " |
| "then `/resume My Session` to return to it later." |
| ) |
| lines = ["📋 **Named Sessions**\n"] |
| for s in titled[:10]: |
| title = s["title"] |
| preview = s.get("preview", "")[:40] |
| preview_part = f" — _{preview}_" if preview else "" |
| lines.append(f"• **{title}**{preview_part}") |
| lines.append("\nUsage: `/resume <session name>`") |
| return "\n".join(lines) |
| except Exception as e: |
| logger.debug("Failed to list titled sessions: %s", e) |
| return f"Could not list sessions: {e}" |
|
|
| |
| target_id = self._session_db.resolve_session_by_title(name) |
| if not target_id: |
| return ( |
| f"No session found matching '**{name}**'.\n" |
| "Use `/resume` with no arguments to see available sessions." |
| ) |
|
|
| |
| current_entry = self.session_store.get_or_create_session(source) |
| if current_entry.session_id == target_id: |
| return f"📌 Already on session **{name}**." |
|
|
| |
| try: |
| _flush_task = asyncio.create_task( |
| self._async_flush_memories(current_entry.session_id, session_key) |
| ) |
| self._background_tasks.add(_flush_task) |
| _flush_task.add_done_callback(self._background_tasks.discard) |
| except Exception as e: |
| logger.debug("Memory flush on resume failed: %s", e) |
|
|
| |
| if session_key in self._running_agents: |
| del self._running_agents[session_key] |
|
|
| |
| new_entry = self.session_store.switch_session(session_key, target_id) |
| if not new_entry: |
| return "Failed to switch session." |
|
|
| |
| title = self._session_db.get_session_title(target_id) or name |
|
|
| |
| history = self.session_store.load_transcript(target_id) |
| msg_count = len([m for m in history if m.get("role") == "user"]) if history else 0 |
| msg_part = f" ({msg_count} message{'s' if msg_count != 1 else ''})" if msg_count else "" |
|
|
| return f"↻ Resumed session **{title}**{msg_part}. Conversation restored." |
|
|
| async def _handle_branch_command(self, event: MessageEvent) -> str: |
| """Handle /branch [name] — fork the current session into a new independent copy. |
| |
| Copies conversation history to a new session so the user can explore |
| a different approach without losing the original. |
| Inspired by Claude Code's /branch command. |
| """ |
| import uuid as _uuid |
|
|
| if not self._session_db: |
| return "Session database not available." |
|
|
| source = event.source |
| session_key = self._session_key_for_source(source) |
|
|
| |
| current_entry = self.session_store.get_or_create_session(source) |
| history = self.session_store.load_transcript(current_entry.session_id) |
| if not history: |
| return "No conversation to branch — send a message first." |
|
|
| branch_name = event.get_command_args().strip() |
|
|
| |
| from datetime import datetime as _dt |
| now = _dt.now() |
| timestamp_str = now.strftime("%Y%m%d_%H%M%S") |
| short_uuid = _uuid.uuid4().hex[:6] |
| new_session_id = f"{timestamp_str}_{short_uuid}" |
|
|
| |
| if branch_name: |
| branch_title = branch_name |
| else: |
| current_title = self._session_db.get_session_title(current_entry.session_id) |
| base = current_title or "branch" |
| branch_title = self._session_db.get_next_title_in_lineage(base) |
|
|
| parent_session_id = current_entry.session_id |
|
|
| |
| try: |
| self._session_db.create_session( |
| session_id=new_session_id, |
| source=source.platform.value if source.platform else "gateway", |
| model=(self.config.get("model", {}) or {}).get("default") if isinstance(self.config, dict) else None, |
| parent_session_id=parent_session_id, |
| ) |
| except Exception as e: |
| logger.error("Failed to create branch session: %s", e) |
| return f"Failed to create branch: {e}" |
|
|
| |
| for msg in history: |
| try: |
| self._session_db.append_message( |
| session_id=new_session_id, |
| role=msg.get("role", "user"), |
| content=msg.get("content"), |
| tool_name=msg.get("tool_name") or msg.get("name"), |
| tool_calls=msg.get("tool_calls"), |
| tool_call_id=msg.get("tool_call_id"), |
| reasoning=msg.get("reasoning"), |
| ) |
| except Exception: |
| pass |
|
|
| |
| try: |
| self._session_db.set_session_title(new_session_id, branch_title) |
| except Exception: |
| pass |
|
|
| |
| new_entry = self.session_store.switch_session(session_key, new_session_id) |
| if not new_entry: |
| return "Branch created but failed to switch to it." |
|
|
| |
| self._evict_cached_agent(session_key) |
|
|
| msg_count = len([m for m in history if m.get("role") == "user"]) |
| return ( |
| f"⑂ Branched to **{branch_title}**" |
| f" ({msg_count} message{'s' if msg_count != 1 else ''} copied)\n" |
| f"Original: `{parent_session_id}`\n" |
| f"Branch: `{new_session_id}`\n" |
| f"Use `/resume` to switch back to the original." |
| ) |
|
|
| async def _handle_usage_command(self, event: MessageEvent) -> str: |
| """Handle /usage command -- show token usage for the current session. |
| |
| Checks both _running_agents (mid-turn) and _agent_cache (between turns) |
| so that rate limits, cost estimates, and detailed token breakdowns are |
| available whenever the user asks, not only while the agent is running. |
| """ |
| source = event.source |
| session_key = self._session_key_for_source(source) |
|
|
| |
| agent = self._running_agents.get(session_key) |
| if not agent or agent is _AGENT_PENDING_SENTINEL: |
| _cache_lock = getattr(self, "_agent_cache_lock", None) |
| _cache = getattr(self, "_agent_cache", None) |
| if _cache_lock and _cache is not None: |
| with _cache_lock: |
| cached = _cache.get(session_key) |
| if cached: |
| agent = cached[0] |
|
|
| if agent and hasattr(agent, "session_total_tokens") and agent.session_api_calls > 0: |
| lines = [] |
|
|
| |
| rl_state = agent.get_rate_limit_state() |
| if rl_state and rl_state.has_data: |
| from agent.rate_limit_tracker import format_rate_limit_compact |
| lines.append(f"⏱️ **Rate Limits:** {format_rate_limit_compact(rl_state)}") |
| lines.append("") |
|
|
| |
| input_tokens = getattr(agent, "session_input_tokens", 0) or 0 |
| output_tokens = getattr(agent, "session_output_tokens", 0) or 0 |
| cache_read = getattr(agent, "session_cache_read_tokens", 0) or 0 |
| cache_write = getattr(agent, "session_cache_write_tokens", 0) or 0 |
|
|
| lines.append("📊 **Session Token Usage**") |
| lines.append(f"Model: `{agent.model}`") |
| lines.append(f"Input tokens: {input_tokens:,}") |
| if cache_read: |
| lines.append(f"Cache read tokens: {cache_read:,}") |
| if cache_write: |
| lines.append(f"Cache write tokens: {cache_write:,}") |
| lines.append(f"Output tokens: {output_tokens:,}") |
| lines.append(f"Total: {agent.session_total_tokens:,}") |
| lines.append(f"API calls: {agent.session_api_calls}") |
|
|
| |
| try: |
| from agent.usage_pricing import CanonicalUsage, estimate_usage_cost |
| cost_result = estimate_usage_cost( |
| agent.model, |
| CanonicalUsage( |
| input_tokens=input_tokens, |
| output_tokens=output_tokens, |
| cache_read_tokens=cache_read, |
| cache_write_tokens=cache_write, |
| ), |
| provider=getattr(agent, "provider", None), |
| base_url=getattr(agent, "base_url", None), |
| ) |
| if cost_result.amount_usd is not None: |
| prefix = "~" if cost_result.status == "estimated" else "" |
| lines.append(f"Cost: {prefix}${float(cost_result.amount_usd):.4f}") |
| elif cost_result.status == "included": |
| lines.append("Cost: included") |
| except Exception: |
| pass |
|
|
| |
| ctx = agent.context_compressor |
| if ctx.last_prompt_tokens: |
| pct = min(100, ctx.last_prompt_tokens / ctx.context_length * 100) if ctx.context_length else 0 |
| lines.append(f"Context: {ctx.last_prompt_tokens:,} / {ctx.context_length:,} ({pct:.0f}%)") |
| if ctx.compression_count: |
| lines.append(f"Compressions: {ctx.compression_count}") |
|
|
| return "\n".join(lines) |
|
|
| |
| session_entry = self.session_store.get_or_create_session(source) |
| history = self.session_store.load_transcript(session_entry.session_id) |
| if history: |
| from agent.model_metadata import estimate_messages_tokens_rough |
| msgs = [m for m in history if m.get("role") in ("user", "assistant") and m.get("content")] |
| approx = estimate_messages_tokens_rough(msgs) |
| return ( |
| f"📊 **Session Info**\n" |
| f"Messages: {len(msgs)}\n" |
| f"Estimated context: ~{approx:,} tokens\n" |
| f"_(Detailed usage available after the first agent response)_" |
| ) |
| return "No usage data available for this session." |
|
|
| async def _handle_insights_command(self, event: MessageEvent) -> str: |
| """Handle /insights command -- show usage insights and analytics.""" |
| import asyncio as _asyncio |
|
|
| args = event.get_command_args().strip() |
| days = 30 |
| source = None |
|
|
| |
| if args: |
| parts = args.split() |
| i = 0 |
| while i < len(parts): |
| if parts[i] == "--days" and i + 1 < len(parts): |
| try: |
| days = int(parts[i + 1]) |
| except ValueError: |
| return f"Invalid --days value: {parts[i + 1]}" |
| i += 2 |
| elif parts[i] == "--source" and i + 1 < len(parts): |
| source = parts[i + 1] |
| i += 2 |
| elif parts[i].isdigit(): |
| days = int(parts[i]) |
| i += 1 |
| else: |
| i += 1 |
|
|
| try: |
| from hermes_state import SessionDB |
| from agent.insights import InsightsEngine |
|
|
| loop = _asyncio.get_event_loop() |
|
|
| def _run_insights(): |
| db = SessionDB() |
| engine = InsightsEngine(db) |
| report = engine.generate(days=days, source=source) |
| result = engine.format_gateway(report) |
| db.close() |
| return result |
|
|
| return await loop.run_in_executor(None, _run_insights) |
| except Exception as e: |
| logger.error("Insights command error: %s", e, exc_info=True) |
| return f"Error generating insights: {e}" |
|
|
| async def _handle_reload_mcp_command(self, event: MessageEvent) -> str: |
| """Handle /reload-mcp command -- disconnect and reconnect all MCP servers.""" |
| loop = asyncio.get_event_loop() |
| try: |
| from tools.mcp_tool import shutdown_mcp_servers, discover_mcp_tools, _load_mcp_config, _servers, _lock |
|
|
| |
| with _lock: |
| old_servers = set(_servers.keys()) |
|
|
| |
| |
| await loop.run_in_executor(None, shutdown_mcp_servers) |
|
|
| |
| new_tools = await loop.run_in_executor(None, discover_mcp_tools) |
|
|
| |
| with _lock: |
| connected_servers = set(_servers.keys()) |
|
|
| added = connected_servers - old_servers |
| removed = old_servers - connected_servers |
| reconnected = connected_servers & old_servers |
|
|
| lines = ["🔄 **MCP Servers Reloaded**\n"] |
| if reconnected: |
| lines.append(f"♻️ Reconnected: {', '.join(sorted(reconnected))}") |
| if added: |
| lines.append(f"➕ Added: {', '.join(sorted(added))}") |
| if removed: |
| lines.append(f"➖ Removed: {', '.join(sorted(removed))}") |
| if not connected_servers: |
| lines.append("No MCP servers connected.") |
| else: |
| lines.append(f"\n🔧 {len(new_tools)} tool(s) available from {len(connected_servers)} server(s)") |
|
|
| |
| |
| |
| change_parts = [] |
| if added: |
| change_parts.append(f"Added servers: {', '.join(sorted(added))}") |
| if removed: |
| change_parts.append(f"Removed servers: {', '.join(sorted(removed))}") |
| if reconnected: |
| change_parts.append(f"Reconnected servers: {', '.join(sorted(reconnected))}") |
| tool_summary = f"{len(new_tools)} MCP tool(s) now available" if new_tools else "No MCP tools available" |
| change_detail = ". ".join(change_parts) + ". " if change_parts else "" |
| reload_msg = { |
| "role": "user", |
| "content": f"[SYSTEM: MCP servers have been reloaded. {change_detail}{tool_summary}. The tool list for this conversation has been updated accordingly.]", |
| } |
| try: |
| session_entry = self.session_store.get_or_create_session(event.source) |
| self.session_store.append_to_transcript( |
| session_entry.session_id, reload_msg |
| ) |
| except Exception: |
| pass |
|
|
| return "\n".join(lines) |
|
|
| except Exception as e: |
| logger.warning("MCP reload failed: %s", e) |
| return f"❌ MCP reload failed: {e}" |
|
|
| |
| |
| |
|
|
| _APPROVAL_TIMEOUT_SECONDS = 300 |
|
|
| async def _handle_approve_command(self, event: MessageEvent) -> Optional[str]: |
| """Handle /approve command — unblock waiting agent thread(s). |
| |
| The agent thread(s) are blocked inside tools/approval.py waiting for |
| the user to respond. This handler signals the event so the agent |
| resumes and the terminal_tool executes the command inline — the same |
| flow as the CLI's synchronous input() approval. |
| |
| Supports multiple concurrent approvals (parallel subagents, |
| execute_code). ``/approve`` resolves the oldest pending command; |
| ``/approve all`` resolves every pending command at once. |
| |
| Usage: |
| /approve — approve oldest pending command once |
| /approve all — approve ALL pending commands at once |
| /approve session — approve oldest + remember for session |
| /approve all session — approve all + remember for session |
| /approve always — approve oldest + remember permanently |
| /approve all always — approve all + remember permanently |
| """ |
| source = event.source |
| session_key = self._session_key_for_source(source) |
|
|
| from tools.approval import ( |
| resolve_gateway_approval, has_blocking_approval, |
| ) |
|
|
| if not has_blocking_approval(session_key): |
| if session_key in self._pending_approvals: |
| self._pending_approvals.pop(session_key) |
| return "⚠️ Approval expired (agent is no longer waiting). Ask the agent to try again." |
| return "No pending command to approve." |
|
|
| |
| args = event.get_command_args().strip().lower().split() |
| resolve_all = "all" in args |
| remaining = [a for a in args if a != "all"] |
|
|
| if any(a in ("always", "permanent", "permanently") for a in remaining): |
| choice = "always" |
| scope_msg = " (pattern approved permanently)" |
| elif any(a in ("session", "ses") for a in remaining): |
| choice = "session" |
| scope_msg = " (pattern approved for this session)" |
| else: |
| choice = "once" |
| scope_msg = "" |
|
|
| count = resolve_gateway_approval(session_key, choice, resolve_all=resolve_all) |
| if not count: |
| return "No pending command to approve." |
|
|
| |
| _adapter = self.adapters.get(source.platform) |
| if _adapter: |
| _adapter.resume_typing_for_chat(source.chat_id) |
|
|
| count_msg = f" ({count} commands)" if count > 1 else "" |
| logger.info("User approved %d dangerous command(s) via /approve%s", count, scope_msg) |
| return f"✅ Command{'s' if count > 1 else ''} approved{scope_msg}{count_msg}. The agent is resuming..." |
|
|
| async def _handle_deny_command(self, event: MessageEvent) -> str: |
| """Handle /deny command — reject pending dangerous command(s). |
| |
| Signals blocked agent thread(s) with a 'deny' result so they receive |
| a definitive BLOCKED message, same as the CLI deny flow. |
| |
| ``/deny`` denies the oldest; ``/deny all`` denies everything. |
| """ |
| source = event.source |
| session_key = self._session_key_for_source(source) |
|
|
| from tools.approval import ( |
| resolve_gateway_approval, has_blocking_approval, |
| ) |
|
|
| if not has_blocking_approval(session_key): |
| if session_key in self._pending_approvals: |
| self._pending_approvals.pop(session_key) |
| return "❌ Command denied (approval was stale)." |
| return "No pending command to deny." |
|
|
| args = event.get_command_args().strip().lower() |
| resolve_all = "all" in args |
|
|
| count = resolve_gateway_approval(session_key, "deny", resolve_all=resolve_all) |
| if not count: |
| return "No pending command to deny." |
|
|
| |
| _adapter = self.adapters.get(source.platform) |
| if _adapter: |
| _adapter.resume_typing_for_chat(source.chat_id) |
|
|
| count_msg = f" ({count} commands)" if count > 1 else "" |
| logger.info("User denied %d dangerous command(s) via /deny", count) |
| return f"❌ Command{'s' if count > 1 else ''} denied{count_msg}." |
|
|
| |
| |
| _UPDATE_ALLOWED_PLATFORMS = frozenset({ |
| Platform.TELEGRAM, Platform.DISCORD, Platform.SLACK, Platform.WHATSAPP, |
| Platform.SIGNAL, Platform.MATTERMOST, Platform.MATRIX, |
| Platform.HOMEASSISTANT, Platform.EMAIL, Platform.SMS, Platform.DINGTALK, |
| Platform.FEISHU, Platform.WECOM, Platform.WECOM_CALLBACK, Platform.WEIXIN, Platform.BLUEBUBBLES, Platform.LOCAL, |
| }) |
|
|
| async def _handle_debug_command(self, event: MessageEvent) -> str: |
| """Handle /debug — upload debug report + logs and return paste URLs.""" |
| import asyncio |
| from hermes_cli.debug import ( |
| _capture_dump, collect_debug_report, _read_full_log, |
| upload_to_pastebin, |
| ) |
|
|
| loop = asyncio.get_running_loop() |
|
|
| |
| def _collect_and_upload(): |
| dump_text = _capture_dump() |
| report = collect_debug_report(log_lines=200, dump_text=dump_text) |
| agent_log = _read_full_log("agent") |
| gateway_log = _read_full_log("gateway") |
|
|
| if agent_log: |
| agent_log = dump_text + "\n\n--- full agent.log ---\n" + agent_log |
| if gateway_log: |
| gateway_log = dump_text + "\n\n--- full gateway.log ---\n" + gateway_log |
|
|
| urls = {} |
| failures = [] |
|
|
| try: |
| urls["Report"] = upload_to_pastebin(report) |
| except Exception as exc: |
| return f"✗ Failed to upload debug report: {exc}" |
|
|
| if agent_log: |
| try: |
| urls["agent.log"] = upload_to_pastebin(agent_log) |
| except Exception: |
| failures.append("agent.log") |
|
|
| if gateway_log: |
| try: |
| urls["gateway.log"] = upload_to_pastebin(gateway_log) |
| except Exception: |
| failures.append("gateway.log") |
|
|
| lines = ["**Debug report uploaded:**", ""] |
| label_width = max(len(k) for k in urls) |
| for label, url in urls.items(): |
| lines.append(f"`{label:<{label_width}}` {url}") |
|
|
| if failures: |
| lines.append(f"\n_(failed to upload: {', '.join(failures)})_") |
|
|
| lines.append("\nShare these links with the Hermes team for support.") |
| return "\n".join(lines) |
|
|
| return await loop.run_in_executor(None, _collect_and_upload) |
|
|
| async def _handle_update_command(self, event: MessageEvent) -> str: |
| """Handle /update command — update Hermes Agent to the latest version. |
| |
| Spawns ``hermes update`` in a detached session (via ``setsid``) so it |
| survives the gateway restart that ``hermes update`` may trigger. Marker |
| files are written so either the current gateway process or the next one |
| can notify the user when the update finishes. |
| """ |
| import json |
| import shutil |
| import subprocess |
| from datetime import datetime |
| from hermes_cli.config import is_managed, format_managed_message |
|
|
| |
| platform = event.source.platform |
| if platform not in self._UPDATE_ALLOWED_PLATFORMS: |
| return "✗ /update is only available from messaging platforms. Run `hermes update` from the terminal." |
|
|
| if is_managed(): |
| return f"✗ {format_managed_message('update Hermes Agent')}" |
|
|
| project_root = Path(__file__).parent.parent.resolve() |
| git_dir = project_root / '.git' |
|
|
| if not git_dir.exists(): |
| return "✗ Not a git repository — cannot update." |
|
|
| hermes_cmd = _resolve_hermes_bin() |
| if not hermes_cmd: |
| return ( |
| "✗ Could not locate the `hermes` command. " |
| "Hermes is running, but the update command could not find the " |
| "executable on PATH or via the current Python interpreter. " |
| "Try running `hermes update` manually in your terminal." |
| ) |
|
|
| pending_path = _hermes_home / ".update_pending.json" |
| output_path = _hermes_home / ".update_output.txt" |
| exit_code_path = _hermes_home / ".update_exit_code" |
| session_key = self._session_key_for_source(event.source) |
| pending = { |
| "platform": event.source.platform.value, |
| "chat_id": event.source.chat_id, |
| "user_id": event.source.user_id, |
| "session_key": session_key, |
| "timestamp": datetime.now().isoformat(), |
| } |
| _tmp_pending = pending_path.with_suffix(".tmp") |
| _tmp_pending.write_text(json.dumps(pending)) |
| _tmp_pending.replace(pending_path) |
| exit_code_path.unlink(missing_ok=True) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| hermes_cmd_str = " ".join(shlex.quote(part) for part in hermes_cmd) |
| update_cmd = ( |
| f"PYTHONUNBUFFERED=1 {hermes_cmd_str} update --gateway" |
| f" > {shlex.quote(str(output_path))} 2>&1; " |
| f"status=$?; printf '%s' \"$status\" > {shlex.quote(str(exit_code_path))}" |
| ) |
| try: |
| setsid_bin = shutil.which("setsid") |
| if setsid_bin: |
| |
| subprocess.Popen( |
| [setsid_bin, "bash", "-c", update_cmd], |
| stdout=subprocess.DEVNULL, |
| stderr=subprocess.DEVNULL, |
| start_new_session=True, |
| ) |
| else: |
| |
| subprocess.Popen( |
| ["bash", "-c", update_cmd], |
| stdout=subprocess.DEVNULL, |
| stderr=subprocess.DEVNULL, |
| start_new_session=True, |
| ) |
| except Exception as e: |
| pending_path.unlink(missing_ok=True) |
| exit_code_path.unlink(missing_ok=True) |
| return f"✗ Failed to start update: {e}" |
|
|
| self._schedule_update_notification_watch() |
| return "⚕ Starting Hermes update… I'll stream progress here." |
|
|
| def _schedule_update_notification_watch(self) -> None: |
| """Ensure a background task is watching for update completion.""" |
| existing_task = getattr(self, "_update_notification_task", None) |
| if existing_task and not existing_task.done(): |
| return |
|
|
| try: |
| self._update_notification_task = asyncio.create_task( |
| self._watch_update_progress() |
| ) |
| except RuntimeError: |
| logger.debug("Skipping update notification watcher: no running event loop") |
|
|
| async def _watch_update_progress( |
| self, |
| poll_interval: float = 2.0, |
| stream_interval: float = 4.0, |
| timeout: float = 1800.0, |
| ) -> None: |
| """Watch ``hermes update --gateway``, streaming output + forwarding prompts. |
| |
| Polls ``.update_output.txt`` for new content and sends chunks to the |
| user periodically. Detects ``.update_prompt.json`` (written by the |
| update process when it needs user input) and forwards the prompt to |
| the messenger. The user's next message is intercepted by |
| ``_handle_message`` and written to ``.update_response``. |
| """ |
| import json |
| import re as _re |
|
|
| pending_path = _hermes_home / ".update_pending.json" |
| claimed_path = _hermes_home / ".update_pending.claimed.json" |
| output_path = _hermes_home / ".update_output.txt" |
| exit_code_path = _hermes_home / ".update_exit_code" |
| prompt_path = _hermes_home / ".update_prompt.json" |
|
|
| loop = asyncio.get_running_loop() |
| deadline = loop.time() + timeout |
|
|
| |
| adapter = None |
| chat_id = None |
| session_key = None |
| for path in (claimed_path, pending_path): |
| if path.exists(): |
| try: |
| pending = json.loads(path.read_text()) |
| platform_str = pending.get("platform") |
| chat_id = pending.get("chat_id") |
| session_key = pending.get("session_key") |
| if platform_str and chat_id: |
| platform = Platform(platform_str) |
| adapter = self.adapters.get(platform) |
| |
| if not session_key: |
| session_key = f"{platform_str}:{chat_id}" |
| break |
| except Exception: |
| pass |
|
|
| if not adapter or not chat_id: |
| logger.warning("Update watcher: cannot resolve adapter/chat_id, falling back to completion-only") |
| |
| while (pending_path.exists() or claimed_path.exists()) and loop.time() < deadline: |
| if exit_code_path.exists(): |
| await self._send_update_notification() |
| return |
| await asyncio.sleep(poll_interval) |
| if (pending_path.exists() or claimed_path.exists()) and not exit_code_path.exists(): |
| exit_code_path.write_text("124") |
| await self._send_update_notification() |
| return |
|
|
| def _strip_ansi(text: str) -> str: |
| return _re.sub(r'\x1b\[[0-9;]*[A-Za-z]', '', text) |
|
|
| bytes_sent = 0 |
| last_stream_time = loop.time() |
| buffer = "" |
|
|
| async def _flush_buffer() -> None: |
| """Send buffered output to the user.""" |
| nonlocal buffer, last_stream_time |
| if not buffer.strip(): |
| buffer = "" |
| return |
| |
| clean = _strip_ansi(buffer).strip() |
| buffer = "" |
| last_stream_time = loop.time() |
| if not clean: |
| return |
| |
| max_chunk = 3500 |
| chunks = [clean[i:i + max_chunk] for i in range(0, len(clean), max_chunk)] |
| for chunk in chunks: |
| try: |
| await adapter.send(chat_id, f"```\n{chunk}\n```") |
| except Exception as e: |
| logger.debug("Update stream send failed: %s", e) |
|
|
| while loop.time() < deadline: |
| |
| if exit_code_path.exists(): |
| |
| if output_path.exists(): |
| try: |
| content = output_path.read_text() |
| if len(content) > bytes_sent: |
| buffer += content[bytes_sent:] |
| bytes_sent = len(content) |
| except OSError: |
| pass |
| await _flush_buffer() |
|
|
| |
| try: |
| exit_code_raw = exit_code_path.read_text().strip() or "1" |
| exit_code = int(exit_code_raw) |
| if exit_code == 0: |
| await adapter.send(chat_id, "✅ Hermes update finished.") |
| else: |
| await adapter.send(chat_id, "❌ Hermes update failed (exit code {}).".format(exit_code)) |
| logger.info("Update finished (exit=%s), notified %s", exit_code, session_key) |
| except Exception as e: |
| logger.warning("Update final notification failed: %s", e) |
|
|
| |
| for p in (pending_path, claimed_path, output_path, |
| exit_code_path, prompt_path): |
| p.unlink(missing_ok=True) |
| (_hermes_home / ".update_response").unlink(missing_ok=True) |
| self._update_prompt_pending.pop(session_key, None) |
| return |
|
|
| |
| if output_path.exists(): |
| try: |
| content = output_path.read_text() |
| if len(content) > bytes_sent: |
| buffer += content[bytes_sent:] |
| bytes_sent = len(content) |
| except OSError: |
| pass |
|
|
| |
| if buffer.strip() and (loop.time() - last_stream_time) >= stream_interval: |
| await _flush_buffer() |
|
|
| |
| |
| |
| |
| if (prompt_path.exists() and session_key |
| and not self._update_prompt_pending.get(session_key)): |
| try: |
| prompt_data = json.loads(prompt_path.read_text()) |
| prompt_text = prompt_data.get("prompt", "") |
| default = prompt_data.get("default", "") |
| if prompt_text: |
| |
| |
| await _flush_buffer() |
| |
| sent_buttons = False |
| if getattr(type(adapter), "send_update_prompt", None) is not None: |
| try: |
| await adapter.send_update_prompt( |
| chat_id=chat_id, |
| prompt=prompt_text, |
| default=default, |
| session_key=session_key, |
| ) |
| sent_buttons = True |
| except Exception as btn_err: |
| logger.debug("Button-based update prompt failed: %s", btn_err) |
| if not sent_buttons: |
| default_hint = f" (default: {default})" if default else "" |
| await adapter.send( |
| chat_id, |
| f"⚕ **Update needs your input:**\n\n" |
| f"{prompt_text}{default_hint}\n\n" |
| f"Reply `/approve` (yes) or `/deny` (no), " |
| f"or type your answer directly." |
| ) |
| self._update_prompt_pending[session_key] = True |
| |
| |
| |
| |
| prompt_path.unlink(missing_ok=True) |
| logger.info("Forwarded update prompt to %s: %s", session_key, prompt_text[:80]) |
| except (json.JSONDecodeError, OSError) as e: |
| logger.debug("Failed to read update prompt: %s", e) |
|
|
| await asyncio.sleep(poll_interval) |
|
|
| |
| if not exit_code_path.exists(): |
| logger.warning("Update watcher timed out after %.0fs", timeout) |
| exit_code_path.write_text("124") |
| await _flush_buffer() |
| try: |
| await adapter.send(chat_id, "❌ Hermes update timed out after 30 minutes.") |
| except Exception: |
| pass |
| for p in (pending_path, claimed_path, output_path, |
| exit_code_path, prompt_path): |
| p.unlink(missing_ok=True) |
| (_hermes_home / ".update_response").unlink(missing_ok=True) |
| self._update_prompt_pending.pop(session_key, None) |
|
|
| async def _send_update_notification(self) -> bool: |
| """If an update finished, notify the user. |
| |
| Returns False when the update is still running so a caller can retry |
| later. Returns True after a definitive send/skip decision. |
| |
| This is the legacy notification path used when the streaming watcher |
| cannot resolve the adapter (e.g. after a gateway restart where the |
| platform hasn't reconnected yet). |
| """ |
| import json |
| import re as _re |
|
|
| pending_path = _hermes_home / ".update_pending.json" |
| claimed_path = _hermes_home / ".update_pending.claimed.json" |
| output_path = _hermes_home / ".update_output.txt" |
| exit_code_path = _hermes_home / ".update_exit_code" |
|
|
| if not pending_path.exists() and not claimed_path.exists(): |
| return False |
|
|
| cleanup = True |
| active_pending_path = claimed_path |
| try: |
| if pending_path.exists(): |
| try: |
| pending_path.replace(claimed_path) |
| except FileNotFoundError: |
| if not claimed_path.exists(): |
| return True |
| elif not claimed_path.exists(): |
| return True |
|
|
| pending = json.loads(claimed_path.read_text()) |
| platform_str = pending.get("platform") |
| chat_id = pending.get("chat_id") |
|
|
| if not exit_code_path.exists(): |
| logger.info("Update notification deferred: update still running") |
| cleanup = False |
| active_pending_path = pending_path |
| claimed_path.replace(pending_path) |
| return False |
|
|
| exit_code_raw = exit_code_path.read_text().strip() or "1" |
| exit_code = int(exit_code_raw) |
|
|
| |
| output = "" |
| if output_path.exists(): |
| output = output_path.read_text() |
|
|
| |
| platform = Platform(platform_str) |
| adapter = self.adapters.get(platform) |
|
|
| if adapter and chat_id: |
| |
| output = _re.sub(r'\x1b\[[0-9;]*m', '', output).strip() |
| if output: |
| if len(output) > 3500: |
| output = "…" + output[-3500:] |
| if exit_code == 0: |
| msg = f"✅ Hermes update finished.\n\n```\n{output}\n```" |
| else: |
| msg = f"❌ Hermes update failed.\n\n```\n{output}\n```" |
| else: |
| if exit_code == 0: |
| msg = "✅ Hermes update finished successfully." |
| else: |
| msg = "❌ Hermes update failed. Check the gateway logs or run `hermes update` manually for details." |
| await adapter.send(chat_id, msg) |
| logger.info( |
| "Sent post-update notification to %s:%s (exit=%s)", |
| platform_str, |
| chat_id, |
| exit_code, |
| ) |
| except Exception as e: |
| logger.warning("Post-update notification failed: %s", e) |
| finally: |
| if cleanup: |
| active_pending_path.unlink(missing_ok=True) |
| claimed_path.unlink(missing_ok=True) |
| output_path.unlink(missing_ok=True) |
| exit_code_path.unlink(missing_ok=True) |
|
|
| return True |
|
|
| async def _send_restart_notification(self) -> None: |
| """Notify the chat that initiated /restart that the gateway is back.""" |
| import json as _json |
|
|
| notify_path = _hermes_home / ".restart_notify.json" |
| if not notify_path.exists(): |
| return |
|
|
| try: |
| data = _json.loads(notify_path.read_text()) |
| platform_str = data.get("platform") |
| chat_id = data.get("chat_id") |
| thread_id = data.get("thread_id") |
|
|
| if not platform_str or not chat_id: |
| return |
|
|
| platform = Platform(platform_str) |
| adapter = self.adapters.get(platform) |
| if not adapter: |
| logger.debug( |
| "Restart notification skipped: %s adapter not connected", |
| platform_str, |
| ) |
| return |
|
|
| metadata = {"thread_id": thread_id} if thread_id else None |
| await adapter.send( |
| chat_id, |
| "♻ Gateway restarted successfully. Your session continues.", |
| metadata=metadata, |
| ) |
| logger.info( |
| "Sent restart notification to %s:%s", |
| platform_str, |
| chat_id, |
| ) |
| except Exception as e: |
| logger.warning("Restart notification failed: %s", e) |
| finally: |
| notify_path.unlink(missing_ok=True) |
|
|
| def _set_session_env(self, context: SessionContext) -> list: |
| """Set session context variables for the current async task. |
| |
| Uses ``contextvars`` instead of ``os.environ`` so that concurrent |
| gateway messages cannot overwrite each other's session state. |
| |
| Returns a list of reset tokens; pass them to ``_clear_session_env`` |
| in a ``finally`` block. |
| """ |
| from gateway.session_context import set_session_vars |
| return set_session_vars( |
| platform=context.source.platform.value, |
| chat_id=context.source.chat_id, |
| 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_name=str(context.source.user_name) if context.source.user_name else "", |
| session_key=context.session_key, |
| ) |
|
|
| 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 _enrich_message_with_vision( |
| self, |
| user_text: str, |
| image_paths: List[str], |
| ) -> str: |
| """ |
| Auto-analyze user-attached images with the vision tool and prepend |
| the descriptions to the message text. |
| |
| Each image is analyzed with a general-purpose prompt. The resulting |
| description *and* the local cache path are injected so the model can: |
| 1. Immediately understand what the user sent (no extra tool call). |
| 2. Re-examine the image with vision_analyze if it needs more detail. |
| |
| Args: |
| user_text: The user's original caption / message text. |
| image_paths: List of local file paths to cached images. |
| |
| Returns: |
| The enriched message string with vision descriptions prepended. |
| """ |
| from tools.vision_tools import vision_analyze_tool |
| import json as _json |
|
|
| analysis_prompt = ( |
| "Describe everything visible in this image in thorough detail. " |
| "Include any text, code, data, objects, people, layout, colors, " |
| "and any other notable visual information." |
| ) |
|
|
| enriched_parts = [] |
| for path in image_paths: |
| try: |
| logger.debug("Auto-analyzing user image: %s", path) |
| result_json = await vision_analyze_tool( |
| image_url=path, |
| user_prompt=analysis_prompt, |
| ) |
| result = _json.loads(result_json) |
| if result.get("success"): |
| description = result.get("analysis", "") |
| enriched_parts.append( |
| f"[The user sent an image~ Here's what I can see:\n{description}]\n" |
| f"[If you need a closer look, use vision_analyze with " |
| f"image_url: {path} ~]" |
| ) |
| else: |
| enriched_parts.append( |
| "[The user sent an image but I couldn't quite see it " |
| "this time (>_<) You can try looking at it yourself " |
| f"with vision_analyze using image_url: {path}]" |
| ) |
| except Exception as e: |
| logger.error("Vision auto-analysis error: %s", e) |
| enriched_parts.append( |
| f"[The user sent an image but something went wrong when I " |
| f"tried to look at it~ You can try examining it yourself " |
| f"with vision_analyze using image_url: {path}]" |
| ) |
|
|
| |
| if enriched_parts: |
| prefix = "\n\n".join(enriched_parts) |
| if user_text: |
| return f"{prefix}\n\n{user_text}" |
| return prefix |
| return user_text |
|
|
| async def _enrich_message_with_transcription( |
| self, |
| user_text: str, |
| audio_paths: List[str], |
| ) -> str: |
| """ |
| Auto-transcribe user voice/audio messages using the configured STT provider |
| and prepend the transcript to the message text. |
| |
| Args: |
| user_text: The user's original caption / message text. |
| audio_paths: List of local file paths to cached audio files. |
| |
| Returns: |
| The enriched message string with transcriptions prepended. |
| """ |
| if not getattr(self.config, "stt_enabled", True): |
| disabled_note = "[The user sent voice message(s), but transcription is disabled in config." |
| if self._has_setup_skill(): |
| disabled_note += ( |
| " You have a skill called hermes-agent-setup that can help " |
| "users configure Hermes features including voice, tools, and more." |
| ) |
| disabled_note += "]" |
| if user_text: |
| return f"{disabled_note}\n\n{user_text}" |
| return disabled_note |
|
|
| from tools.transcription_tools import transcribe_audio |
| import asyncio |
|
|
| enriched_parts = [] |
| for path in audio_paths: |
| try: |
| logger.debug("Transcribing user voice: %s", path) |
| result = await asyncio.to_thread(transcribe_audio, path) |
| if result["success"]: |
| transcript = result["transcript"] |
| enriched_parts.append( |
| f'[The user sent a voice message~ ' |
| f'Here\'s what they said: "{transcript}"]' |
| ) |
| else: |
| error = result.get("error", "unknown error") |
| if ( |
| "No STT provider" in error |
| or error.startswith("Neither VOICE_TOOLS_OPENAI_KEY nor OPENAI_API_KEY is set") |
| ): |
| _no_stt_note = ( |
| "[The user sent a voice message but I can't listen " |
| "to it right now — no STT provider is configured. " |
| "A direct message has already been sent to the user " |
| "with setup instructions." |
| ) |
| if self._has_setup_skill(): |
| _no_stt_note += ( |
| " You have a skill called hermes-agent-setup " |
| "that can help users configure Hermes features " |
| "including voice, tools, and more." |
| ) |
| _no_stt_note += "]" |
| enriched_parts.append(_no_stt_note) |
| else: |
| enriched_parts.append( |
| "[The user sent a voice message but I had trouble " |
| f"transcribing it~ ({error})]" |
| ) |
| except Exception as e: |
| logger.error("Transcription error: %s", e) |
| enriched_parts.append( |
| "[The user sent a voice message but something went wrong " |
| "when I tried to listen to it~ Let them know!]" |
| ) |
|
|
| if enriched_parts: |
| prefix = "\n\n".join(enriched_parts) |
| |
| |
| _placeholder = "(The user sent a message with no text content)" |
| if user_text and user_text.strip() == _placeholder: |
| return prefix |
| if user_text: |
| return f"{prefix}\n\n{user_text}" |
| return prefix |
| return user_text |
|
|
| async def _inject_watch_notification(self, synth_text: str, original_event) -> None: |
| """Inject a watch-pattern notification as a synthetic message event. |
| |
| Uses the source from the original user event to route the notification |
| back to the correct chat/adapter. |
| """ |
| source = getattr(original_event, "source", None) |
| if not source: |
| return |
| platform_name = source.platform.value if hasattr(source.platform, "value") else str(source.platform) |
| adapter = None |
| for p, a in self.adapters.items(): |
| if p.value == platform_name: |
| adapter = a |
| break |
| if not adapter: |
| return |
| try: |
| from gateway.platforms.base import MessageEvent, MessageType |
| synth_event = MessageEvent( |
| text=synth_text, |
| message_type=MessageType.TEXT, |
| source=source, |
| internal=True, |
| ) |
| logger.info("Watch pattern notification — injecting for %s", platform_name) |
| await adapter.handle_message(synth_event) |
| except Exception as e: |
| logger.error("Watch notification injection error: %s", e) |
|
|
| async def _run_process_watcher(self, watcher: dict) -> None: |
| """ |
| Periodically check a background process and push updates to the user. |
| |
| Runs as an asyncio task. Stays silent when nothing changed. |
| Auto-removes when the process exits or is killed. |
| |
| Notification mode (from ``display.background_process_notifications``): |
| - ``all`` — running-output updates + final message |
| - ``result`` — final completion message only |
| - ``error`` — final message only when exit code != 0 |
| - ``off`` — no messages at all |
| """ |
| from tools.process_registry import process_registry |
|
|
| session_id = watcher["session_id"] |
| interval = watcher["check_interval"] |
| session_key = watcher.get("session_key", "") |
| platform_name = watcher.get("platform", "") |
| chat_id = watcher.get("chat_id", "") |
| thread_id = watcher.get("thread_id", "") |
| user_id = watcher.get("user_id", "") |
| user_name = watcher.get("user_name", "") |
| agent_notify = watcher.get("notify_on_complete", False) |
| notify_mode = self._load_background_notifications_mode() |
|
|
| logger.debug("Process watcher started: %s (every %ss, notify=%s, agent_notify=%s)", |
| session_id, interval, notify_mode, agent_notify) |
|
|
| if notify_mode == "off" and not agent_notify: |
| |
| |
| while True: |
| await asyncio.sleep(interval) |
| session = process_registry.get(session_id) |
| if session is None or session.exited: |
| break |
| logger.debug("Process watcher ended (silent): %s", session_id) |
| return |
|
|
| last_output_len = 0 |
| while True: |
| await asyncio.sleep(interval) |
|
|
| session = process_registry.get(session_id) |
| if session is None: |
| break |
|
|
| current_output_len = len(session.output_buffer) |
| has_new_output = current_output_len > last_output_len |
| last_output_len = current_output_len |
|
|
| if session.exited: |
| |
| |
| from tools.process_registry import process_registry as _pr_check |
| if agent_notify and not _pr_check.is_completion_consumed(session_id): |
| from tools.ansi_strip import strip_ansi |
| _out = strip_ansi(session.output_buffer[-2000:]) if session.output_buffer else "" |
| synth_text = ( |
| f"[SYSTEM: Background process {session_id} completed " |
| f"(exit code {session.exit_code}).\n" |
| f"Command: {session.command}\n" |
| f"Output:\n{_out}]" |
| ) |
| adapter = None |
| for p, a in self.adapters.items(): |
| if p.value == platform_name: |
| adapter = a |
| break |
| if adapter and chat_id: |
| try: |
| from gateway.platforms.base import MessageEvent, MessageType |
| from gateway.session import SessionSource |
| from gateway.config import Platform |
| _platform_enum = Platform(platform_name) |
| _source = SessionSource( |
| platform=_platform_enum, |
| chat_id=chat_id, |
| thread_id=thread_id or None, |
| user_id=user_id or None, |
| user_name=user_name or None, |
| ) |
| synth_event = MessageEvent( |
| text=synth_text, |
| message_type=MessageType.TEXT, |
| source=_source, |
| internal=True, |
| ) |
| logger.info( |
| "Process %s finished — injecting agent notification for session %s", |
| session_id, session_key, |
| ) |
| await adapter.handle_message(synth_event) |
| except Exception as e: |
| logger.error("Agent notify injection error: %s", e) |
| break |
|
|
| |
| |
| should_notify = ( |
| notify_mode in ("all", "result") |
| or (notify_mode == "error" and session.exit_code not in (0, None)) |
| ) |
| if should_notify: |
| new_output = session.output_buffer[-1000:] if session.output_buffer else "" |
| message_text = ( |
| f"[Background process {session_id} finished with exit code {session.exit_code}~ " |
| f"Here's the final output:\n{new_output}]" |
| ) |
| adapter = None |
| for p, a in self.adapters.items(): |
| if p.value == platform_name: |
| adapter = a |
| break |
| if adapter and chat_id: |
| try: |
| send_meta = {"thread_id": thread_id} if thread_id else None |
| await adapter.send(chat_id, message_text, metadata=send_meta) |
| except Exception as e: |
| logger.error("Watcher delivery error: %s", e) |
| break |
|
|
| elif has_new_output and notify_mode == "all" and not agent_notify: |
| |
| |
| new_output = session.output_buffer[-500:] if session.output_buffer else "" |
| message_text = ( |
| f"[Background process {session_id} is still running~ " |
| f"New output:\n{new_output}]" |
| ) |
| adapter = None |
| for p, a in self.adapters.items(): |
| if p.value == platform_name: |
| adapter = a |
| break |
| if adapter and chat_id: |
| try: |
| send_meta = {"thread_id": thread_id} if thread_id else None |
| await adapter.send(chat_id, message_text, metadata=send_meta) |
| except Exception as e: |
| logger.error("Watcher delivery error: %s", e) |
|
|
| logger.debug("Process watcher ended: %s", session_id) |
|
|
| _MAX_INTERRUPT_DEPTH = 3 |
|
|
| @staticmethod |
| def _agent_config_signature( |
| model: str, |
| runtime: dict, |
| enabled_toolsets: list, |
| ephemeral_prompt: str, |
| ) -> str: |
| """Compute a stable string key from agent config values. |
| |
| When this signature changes between messages, the cached AIAgent is |
| discarded and rebuilt. When it stays the same, the cached agent is |
| reused — preserving the frozen system prompt and tool schemas for |
| prompt cache hits. |
| """ |
| import hashlib, json as _j |
|
|
| |
| |
| |
| |
| _api_key = str(runtime.get("api_key", "") or "") |
| _api_key_fingerprint = hashlib.sha256(_api_key.encode()).hexdigest() if _api_key else "" |
|
|
| blob = _j.dumps( |
| [ |
| model, |
| _api_key_fingerprint, |
| runtime.get("base_url", ""), |
| runtime.get("provider", ""), |
| runtime.get("api_mode", ""), |
| sorted(enabled_toolsets) if enabled_toolsets else [], |
| |
| |
| ephemeral_prompt or "", |
| ], |
| sort_keys=True, |
| default=str, |
| ) |
| return hashlib.sha256(blob.encode()).hexdigest()[:16] |
|
|
| def _apply_session_model_override( |
| self, session_key: str, model: str, runtime_kwargs: dict |
| ) -> tuple: |
| """Apply /model session overrides if present, returning (model, runtime_kwargs). |
| |
| The gateway /model command stores per-session overrides in |
| ``_session_model_overrides``. These must take precedence over |
| config.yaml defaults so the switched model is actually used for |
| subsequent messages. Fields with ``None`` values are skipped so |
| partial overrides don't clobber valid config defaults. |
| """ |
| override = self._session_model_overrides.get(session_key) |
| if not override: |
| return model, runtime_kwargs |
| model = override.get("model", model) |
| for key in ("provider", "api_key", "base_url", "api_mode"): |
| val = override.get(key) |
| if val is not None: |
| runtime_kwargs[key] = val |
| return model, runtime_kwargs |
|
|
| def _is_intentional_model_switch(self, session_key: str, agent_model: str) -> bool: |
| """Return True if *agent_model* matches an active /model session override.""" |
| override = self._session_model_overrides.get(session_key) |
| return override is not None and override.get("model") == agent_model |
|
|
| def _evict_cached_agent(self, session_key: str) -> None: |
| """Remove a cached agent for a session (called on /new, /model, etc).""" |
| _lock = getattr(self, "_agent_cache_lock", None) |
| if _lock: |
| with _lock: |
| self._agent_cache.pop(session_key, None) |
|
|
| async def _run_agent( |
| self, |
| message: str, |
| context_prompt: str, |
| history: List[Dict[str, Any]], |
| source: SessionSource, |
| session_id: str, |
| session_key: str = None, |
| _interrupt_depth: int = 0, |
| event_message_id: Optional[str] = None, |
| ) -> Dict[str, Any]: |
| """ |
| Run the agent with the given message and context. |
| |
| Returns the full result dict from run_conversation, including: |
| - "final_response": str (the text to send back) |
| - "messages": list (full conversation including tool calls) |
| - "api_calls": int |
| - "completed": bool |
| |
| This is run in a thread pool to not block the event loop. |
| Supports interruption via new messages. |
| """ |
| from run_agent import AIAgent |
| import queue |
| |
| user_config = _load_gateway_config() |
| platform_key = _platform_config_key(source.platform) |
|
|
| from hermes_cli.tools_config import _get_platform_tools |
| enabled_toolsets = sorted(_get_platform_tools(user_config, platform_key)) |
|
|
| display_config = user_config.get("display", {}) |
| if not isinstance(display_config, dict): |
| display_config = {} |
|
|
| |
| |
| |
| from gateway.display_config import resolve_display_setting |
|
|
| |
| try: |
| from agent.display import set_tool_preview_max_len |
| _tpl = resolve_display_setting(user_config, platform_key, "tool_preview_length", 0) |
| set_tool_preview_max_len(int(_tpl) if _tpl else 0) |
| except Exception: |
| pass |
|
|
| |
| _resolved_tp = resolve_display_setting(user_config, platform_key, "tool_progress") |
| progress_mode = ( |
| _resolved_tp |
| or os.getenv("HERMES_TOOL_PROGRESS_MODE") |
| or "all" |
| ) |
| |
| |
| from gateway.config import Platform |
| tool_progress_enabled = progress_mode != "off" and source.platform != Platform.WEBHOOK |
| |
| |
| |
| interim_assistant_messages_enabled = ( |
| source.platform != Platform.WEBHOOK |
| and is_truthy_value( |
| display_config.get("interim_assistant_messages"), |
| default=True, |
| ) |
| ) |
| |
| |
| progress_queue = queue.Queue() if tool_progress_enabled else None |
| last_tool = [None] |
| last_progress_msg = [None] |
| repeat_count = [0] |
| |
| def progress_callback(event_type: str, tool_name: str = None, preview: str = None, args: dict = None, **kwargs): |
| """Callback invoked by agent on tool lifecycle events.""" |
| if not progress_queue: |
| return |
|
|
| |
| if event_type not in ("tool.started",): |
| return |
|
|
| |
| if progress_mode == "new" and tool_name == last_tool[0]: |
| return |
| last_tool[0] = tool_name |
| |
| |
| from agent.display import get_tool_emoji |
| emoji = get_tool_emoji(tool_name, default="⚙️") |
| |
| |
| if progress_mode == "verbose": |
| if args: |
| from agent.display import get_tool_preview_max_len |
| _pl = get_tool_preview_max_len() |
| import json as _json |
| args_str = _json.dumps(args, ensure_ascii=False, default=str) |
| |
| |
| |
| if _pl > 0 and len(args_str) > _pl: |
| args_str = args_str[:_pl - 3] + "..." |
| msg = f"{emoji} {tool_name}({list(args.keys())})\n{args_str}" |
| elif preview: |
| msg = f"{emoji} {tool_name}: \"{preview}\"" |
| else: |
| msg = f"{emoji} {tool_name}..." |
| progress_queue.put(msg) |
| return |
| |
| |
| |
| |
| if preview: |
| from agent.display import get_tool_preview_max_len |
| _pl = get_tool_preview_max_len() |
| _cap = _pl if _pl > 0 else 40 |
| if len(preview) > _cap: |
| preview = preview[:_cap - 3] + "..." |
| msg = f"{emoji} {tool_name}: \"{preview}\"" |
| else: |
| msg = f"{emoji} {tool_name}..." |
| |
| |
| |
| |
| if msg == last_progress_msg[0]: |
| repeat_count[0] += 1 |
| |
| |
| progress_queue.put(("__dedup__", msg, repeat_count[0])) |
| return |
| last_progress_msg[0] = msg |
| repeat_count[0] = 0 |
| |
| progress_queue.put(msg) |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if source.platform == Platform.SLACK: |
| _progress_thread_id = source.thread_id or event_message_id |
| else: |
| _progress_thread_id = source.thread_id |
| _progress_metadata = {"thread_id": _progress_thread_id} if _progress_thread_id else None |
|
|
| async def send_progress_messages(): |
| if not progress_queue: |
| return |
|
|
| adapter = self.adapters.get(source.platform) |
| if not adapter: |
| return |
|
|
| |
| |
| |
| from gateway.platforms.base import BasePlatformAdapter as _BaseAdapter |
| if type(adapter).edit_message is _BaseAdapter.edit_message: |
| while not progress_queue.empty(): |
| try: |
| progress_queue.get_nowait() |
| except Exception: |
| break |
| return |
|
|
| progress_lines = [] |
| progress_msg_id = None |
| can_edit = True |
| _last_edit_ts = 0.0 |
| _PROGRESS_EDIT_INTERVAL = 1.5 |
|
|
| while True: |
| try: |
| raw = progress_queue.get_nowait() |
|
|
| |
| if isinstance(raw, tuple) and len(raw) == 3 and raw[0] == "__dedup__": |
| _, base_msg, count = raw |
| if progress_lines: |
| progress_lines[-1] = f"{base_msg} (×{count + 1})" |
| msg = progress_lines[-1] if progress_lines else base_msg |
| else: |
| msg = raw |
| progress_lines.append(msg) |
|
|
| |
| |
| |
| |
| _now = time.monotonic() |
| _remaining = _PROGRESS_EDIT_INTERVAL - (_now - _last_edit_ts) |
| if _remaining > 0: |
| |
| |
| |
| await asyncio.sleep(_remaining) |
| continue |
|
|
| if can_edit and progress_msg_id is not None: |
| |
| full_text = "\n".join(progress_lines) |
| result = await adapter.edit_message( |
| chat_id=source.chat_id, |
| message_id=progress_msg_id, |
| content=full_text, |
| ) |
| if not result.success: |
| _err = (getattr(result, "error", "") or "").lower() |
| if "flood" in _err or "retry after" in _err: |
| |
| |
| |
| logger.info( |
| "[%s] Progress edits disabled due to flood control", |
| adapter.name, |
| ) |
| can_edit = False |
| await adapter.send(chat_id=source.chat_id, content=msg, metadata=_progress_metadata) |
| else: |
| if can_edit: |
| |
| full_text = "\n".join(progress_lines) |
| result = await adapter.send(chat_id=source.chat_id, content=full_text, metadata=_progress_metadata) |
| else: |
| |
| result = await adapter.send(chat_id=source.chat_id, content=msg, metadata=_progress_metadata) |
| if result.success and result.message_id: |
| progress_msg_id = result.message_id |
|
|
| _last_edit_ts = time.monotonic() |
|
|
| |
| await asyncio.sleep(0.3) |
| await adapter.send_typing(source.chat_id, metadata=_progress_metadata) |
|
|
| except queue.Empty: |
| await asyncio.sleep(0.3) |
| except asyncio.CancelledError: |
| |
| while not progress_queue.empty(): |
| try: |
| raw = progress_queue.get_nowait() |
| if isinstance(raw, tuple) and len(raw) == 3 and raw[0] == "__dedup__": |
| _, base_msg, count = raw |
| if progress_lines: |
| progress_lines[-1] = f"{base_msg} (×{count + 1})" |
| else: |
| progress_lines.append(raw) |
| except Exception: |
| break |
| |
| if can_edit and progress_lines and progress_msg_id: |
| full_text = "\n".join(progress_lines) |
| try: |
| await adapter.edit_message( |
| chat_id=source.chat_id, |
| message_id=progress_msg_id, |
| content=full_text, |
| ) |
| except Exception: |
| pass |
| return |
| except Exception as e: |
| logger.error("Progress message error: %s", e) |
| await asyncio.sleep(1) |
| |
| |
| agent_holder = [None] |
| result_holder = [None] |
| tools_holder = [None] |
| stream_consumer_holder = [None] |
| |
| |
| _loop_for_step = asyncio.get_event_loop() |
| _hooks_ref = self.hooks |
|
|
| def _step_callback_sync(iteration: int, prev_tools: list) -> None: |
| try: |
| |
| |
| |
| _names: list[str] = [] |
| for _t in (prev_tools or []): |
| if isinstance(_t, dict): |
| _names.append(_t.get("name") or "") |
| else: |
| _names.append(str(_t)) |
| asyncio.run_coroutine_threadsafe( |
| _hooks_ref.emit("agent:step", { |
| "platform": source.platform.value if source.platform else "", |
| "user_id": source.user_id, |
| "session_id": session_id, |
| "iteration": iteration, |
| "tool_names": _names, |
| "tools": prev_tools, |
| }), |
| _loop_for_step, |
| ) |
| except Exception as _e: |
| logger.debug("agent:step hook error: %s", _e) |
|
|
| |
| _status_adapter = self.adapters.get(source.platform) |
| _status_chat_id = source.chat_id |
| _status_thread_metadata = {"thread_id": _progress_thread_id} if _progress_thread_id else None |
|
|
| def _status_callback_sync(event_type: str, message: str) -> None: |
| if not _status_adapter: |
| return |
| try: |
| asyncio.run_coroutine_threadsafe( |
| _status_adapter.send( |
| _status_chat_id, |
| message, |
| metadata=_status_thread_metadata, |
| ), |
| _loop_for_step, |
| ) |
| except Exception as _e: |
| logger.debug("status_callback error (%s): %s", event_type, _e) |
|
|
| def run_sync(): |
| |
| |
| |
| |
| |
| |
| nonlocal message |
|
|
| |
| |
| os.environ["HERMES_SESSION_KEY"] = session_key or "" |
|
|
| |
| max_iterations = int(os.getenv("HERMES_MAX_ITERATIONS", "90")) |
| |
| |
| |
| platform_key = "cli" if source.platform == Platform.LOCAL else source.platform.value |
| |
| |
| combined_ephemeral = context_prompt or "" |
| if self._ephemeral_system_prompt: |
| combined_ephemeral = (combined_ephemeral + "\n\n" + self._ephemeral_system_prompt).strip() |
|
|
| |
| |
| try: |
| load_dotenv(_env_path, override=True, encoding="utf-8") |
| except UnicodeDecodeError: |
| load_dotenv(_env_path, override=True, encoding="latin-1") |
| except Exception: |
| pass |
|
|
| try: |
| model, runtime_kwargs = self._resolve_session_agent_runtime( |
| source=source, |
| session_key=session_key, |
| user_config=user_config, |
| ) |
| logger.debug( |
| "run_agent resolved: model=%s provider=%s session=%s", |
| model, runtime_kwargs.get("provider"), (session_key or "")[:30], |
| ) |
| except Exception as exc: |
| return { |
| "final_response": f"⚠️ Provider authentication failed: {exc}", |
| "messages": [], |
| "api_calls": 0, |
| "tools": [], |
| } |
|
|
| pr = self._provider_routing |
| reasoning_config = self._load_reasoning_config() |
| self._reasoning_config = reasoning_config |
| self._service_tier = self._load_service_tier() |
| |
| _stream_consumer = None |
| _stream_delta_cb = None |
| _scfg = getattr(getattr(self, 'config', None), 'streaming', None) |
| if _scfg is None: |
| from gateway.config import StreamingConfig |
| _scfg = StreamingConfig() |
|
|
| |
| |
| |
| _plat_streaming = resolve_display_setting( |
| user_config, platform_key, "streaming" |
| ) |
| |
| _streaming_enabled = ( |
| _scfg.enabled and _scfg.transport != "off" |
| if _plat_streaming is None |
| else bool(_plat_streaming) |
| ) |
| _want_stream_deltas = _streaming_enabled |
| _want_interim_messages = interim_assistant_messages_enabled |
| _want_interim_consumer = _want_interim_messages |
| if _want_stream_deltas or _want_interim_consumer: |
| try: |
| from gateway.stream_consumer import GatewayStreamConsumer, StreamConsumerConfig |
| _adapter = self.adapters.get(source.platform) |
| if _adapter: |
| |
| |
| |
| |
| |
| |
| _adapter_supports_edit = getattr(_adapter, "SUPPORTS_MESSAGE_EDITING", True) |
| _effective_cursor = _scfg.cursor if _adapter_supports_edit else "" |
| _consumer_cfg = StreamConsumerConfig( |
| edit_interval=_scfg.edit_interval, |
| buffer_threshold=_scfg.buffer_threshold, |
| cursor=_effective_cursor, |
| ) |
| _stream_consumer = GatewayStreamConsumer( |
| adapter=_adapter, |
| chat_id=source.chat_id, |
| config=_consumer_cfg, |
| metadata={"thread_id": _progress_thread_id} if _progress_thread_id else None, |
| ) |
| if _want_stream_deltas: |
| _stream_delta_cb = _stream_consumer.on_delta |
| stream_consumer_holder[0] = _stream_consumer |
| except Exception as _sc_err: |
| logger.debug("Could not set up stream consumer: %s", _sc_err) |
|
|
| def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None: |
| if _stream_consumer is not None: |
| if already_streamed: |
| _stream_consumer.on_segment_break() |
| else: |
| _stream_consumer.on_commentary(text) |
| return |
| if already_streamed or not _status_adapter or not str(text or "").strip(): |
| return |
| try: |
| asyncio.run_coroutine_threadsafe( |
| _status_adapter.send( |
| _status_chat_id, |
| text, |
| metadata=_status_thread_metadata, |
| ), |
| _loop_for_step, |
| ) |
| except Exception as _e: |
| logger.debug("interim_assistant_callback error: %s", _e) |
|
|
| turn_route = self._resolve_turn_agent_config(message, model, runtime_kwargs) |
|
|
| |
| |
| |
| _sig = self._agent_config_signature( |
| turn_route["model"], |
| turn_route["runtime"], |
| enabled_toolsets, |
| combined_ephemeral, |
| ) |
| agent = None |
| _cache_lock = getattr(self, "_agent_cache_lock", None) |
| _cache = getattr(self, "_agent_cache", None) |
| if _cache_lock and _cache is not None: |
| with _cache_lock: |
| cached = _cache.get(session_key) |
| if cached and cached[1] == _sig: |
| agent = cached[0] |
| logger.debug("Reusing cached agent for session %s", session_key) |
|
|
| if agent is None: |
| |
| agent = AIAgent( |
| model=turn_route["model"], |
| **turn_route["runtime"], |
| max_iterations=max_iterations, |
| quiet_mode=True, |
| verbose_logging=False, |
| enabled_toolsets=enabled_toolsets, |
| ephemeral_system_prompt=combined_ephemeral or None, |
| prefill_messages=self._prefill_messages or None, |
| reasoning_config=reasoning_config, |
| service_tier=self._service_tier, |
| request_overrides=turn_route.get("request_overrides"), |
| providers_allowed=pr.get("only"), |
| providers_ignored=pr.get("ignore"), |
| providers_order=pr.get("order"), |
| provider_sort=pr.get("sort"), |
| provider_require_parameters=pr.get("require_parameters", False), |
| provider_data_collection=pr.get("data_collection"), |
| session_id=session_id, |
| platform=platform_key, |
| user_id=source.user_id, |
| session_db=self._session_db, |
| fallback_model=self._fallback_model, |
| ) |
| if _cache_lock and _cache is not None: |
| with _cache_lock: |
| _cache[session_key] = (agent, _sig) |
| logger.debug("Created new agent for session %s (sig=%s)", session_key, _sig) |
|
|
| |
| |
| agent.tool_progress_callback = progress_callback if tool_progress_enabled else None |
| agent.step_callback = _step_callback_sync if _hooks_ref.loaded_hooks else None |
| agent.stream_delta_callback = _stream_delta_cb |
| agent.interim_assistant_callback = _interim_assistant_cb if _want_interim_messages else None |
| agent.status_callback = _status_callback_sync |
| agent.reasoning_config = reasoning_config |
| agent.service_tier = self._service_tier |
| agent.request_overrides = turn_route.get("request_overrides") |
|
|
| |
| def _bg_review_send(message: str) -> None: |
| if not _status_adapter: |
| return |
| try: |
| asyncio.run_coroutine_threadsafe( |
| _status_adapter.send( |
| _status_chat_id, |
| message, |
| metadata=_status_thread_metadata, |
| ), |
| _loop_for_step, |
| ) |
| except Exception as _e: |
| logger.debug("background_review_callback error: %s", _e) |
|
|
| agent.background_review_callback = _bg_review_send |
|
|
| |
| agent_holder[0] = agent |
| |
| tools_holder[0] = agent.tools if hasattr(agent, 'tools') else None |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| agent_history = [] |
| for msg in history: |
| role = msg.get("role") |
| if not role: |
| continue |
| |
| |
| |
| if role in ("session_meta",): |
| continue |
| |
| |
| if role == "system": |
| continue |
| |
| |
| |
| has_tool_calls = "tool_calls" in msg |
| has_tool_call_id = "tool_call_id" in msg |
| is_tool_message = role == "tool" |
| |
| if has_tool_calls or has_tool_call_id or is_tool_message: |
| clean_msg = {k: v for k, v in msg.items() if k != "timestamp"} |
| agent_history.append(clean_msg) |
| else: |
| |
| content = msg.get("content") |
| if content: |
| |
| if msg.get("mirror"): |
| mirror_src = msg.get("mirror_source", "another session") |
| content = f"[Delivered from {mirror_src}] {content}" |
| entry = {"role": role, "content": content} |
| |
| |
| |
| |
| if role == "assistant": |
| for _rkey in ("reasoning", "reasoning_details", |
| "codex_reasoning_items"): |
| _rval = msg.get(_rkey) |
| if _rval: |
| entry[_rkey] = _rval |
| agent_history.append(entry) |
| |
| |
| |
| |
| _history_media_paths: set = set() |
| for _hm in agent_history: |
| if _hm.get("role") in ("tool", "function"): |
| _hc = _hm.get("content", "") |
| if "MEDIA:" in _hc: |
| for _match in re.finditer(r'MEDIA:(\S+)', _hc): |
| _p = _match.group(1).strip().rstrip('",}') |
| if _p: |
| _history_media_paths.add(_p) |
| |
| |
| |
| |
| |
| from tools.approval import ( |
| register_gateway_notify, |
| reset_current_session_key, |
| set_current_session_key, |
| unregister_gateway_notify, |
| ) |
|
|
| def _approval_notify_sync(approval_data: dict) -> None: |
| """Send the approval request to the user from the agent thread. |
| |
| If the adapter supports interactive button-based approvals |
| (e.g. Discord's ``send_exec_approval``), use that for a richer |
| UX. Otherwise fall back to a plain text message with |
| ``/approve`` instructions. |
| """ |
| |
| |
| |
| |
| |
| |
| |
| _status_adapter.pause_typing_for_chat(_status_chat_id) |
|
|
| cmd = approval_data.get("command", "") |
| desc = approval_data.get("description", "dangerous command") |
|
|
| |
| |
| |
| if getattr(type(_status_adapter), "send_exec_approval", None) is not None: |
| try: |
| asyncio.run_coroutine_threadsafe( |
| _status_adapter.send_exec_approval( |
| chat_id=_status_chat_id, |
| command=cmd, |
| session_key=_approval_session_key, |
| description=desc, |
| metadata=_status_thread_metadata, |
| ), |
| _loop_for_step, |
| ).result(timeout=15) |
| return |
| except Exception as _e: |
| logger.warning( |
| "Button-based approval failed, falling back to text: %s", _e |
| ) |
|
|
| |
| cmd_preview = cmd[:200] + "..." if len(cmd) > 200 else cmd |
| msg = ( |
| f"⚠️ **Dangerous command requires approval:**\n" |
| f"```\n{cmd_preview}\n```\n" |
| f"Reason: {desc}\n\n" |
| f"Reply `/approve` to execute, `/approve session` to approve this pattern " |
| f"for the session, `/approve always` to approve permanently, or `/deny` to cancel." |
| ) |
| try: |
| asyncio.run_coroutine_threadsafe( |
| _status_adapter.send( |
| _status_chat_id, |
| msg, |
| metadata=_status_thread_metadata, |
| ), |
| _loop_for_step, |
| ).result(timeout=15) |
| except Exception as _e: |
| logger.error("Failed to send approval request: %s", _e) |
|
|
| |
| _pending_notes = getattr(self, '_pending_model_notes', {}) |
| _msn = _pending_notes.pop(session_key, None) if session_key else None |
| if _msn: |
| message = _msn + "\n\n" + message |
|
|
| _approval_session_key = session_key or "" |
| _approval_session_token = set_current_session_key(_approval_session_key) |
| register_gateway_notify(_approval_session_key, _approval_notify_sync) |
| try: |
| result = agent.run_conversation(message, conversation_history=agent_history, task_id=session_id) |
| finally: |
| unregister_gateway_notify(_approval_session_key) |
| reset_current_session_key(_approval_session_token) |
| result_holder[0] = result |
|
|
| |
| if _stream_consumer is not None: |
| _stream_consumer.finish() |
| |
| |
| final_response = result.get("final_response") |
|
|
| |
| _last_prompt_toks = 0 |
| _input_toks = 0 |
| _output_toks = 0 |
| _agent = agent_holder[0] |
| if _agent and hasattr(_agent, "context_compressor"): |
| _last_prompt_toks = getattr(_agent.context_compressor, "last_prompt_tokens", 0) |
| _input_toks = getattr(_agent, "session_prompt_tokens", 0) |
| _output_toks = getattr(_agent, "session_completion_tokens", 0) |
| _resolved_model = getattr(_agent, "model", None) if _agent else None |
|
|
| if not final_response: |
| error_msg = f"⚠️ {result['error']}" if result.get("error") else "(No response generated)" |
| return { |
| "final_response": error_msg, |
| "messages": result.get("messages", []), |
| "api_calls": result.get("api_calls", 0), |
| "tools": tools_holder[0] or [], |
| "history_offset": len(agent_history), |
| "last_prompt_tokens": _last_prompt_toks, |
| "input_tokens": _input_toks, |
| "output_tokens": _output_toks, |
| "model": _resolved_model, |
| } |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if "MEDIA:" not in final_response: |
| media_tags = [] |
| has_voice_directive = False |
| for msg in result.get("messages", []): |
| if msg.get("role") in ("tool", "function"): |
| content = msg.get("content", "") |
| if "MEDIA:" in content: |
| for match in re.finditer(r'MEDIA:(\S+)', 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 |
| |
| if media_tags: |
| seen = set() |
| unique_tags = [] |
| for tag in media_tags: |
| if tag not in seen: |
| seen.add(tag) |
| unique_tags.append(tag) |
| if has_voice_directive: |
| unique_tags.insert(0, "[[audio_as_voice]]") |
| final_response = final_response + "\n" + "\n".join(unique_tags) |
| |
| |
| |
| |
| |
| agent = agent_holder[0] |
| _session_was_split = False |
| if agent and session_key and hasattr(agent, 'session_id') and agent.session_id != session_id: |
| _session_was_split = True |
| logger.info( |
| "Session split detected: %s → %s (compression)", |
| session_id, agent.session_id, |
| ) |
| entry = self.session_store._entries.get(session_key) |
| if entry: |
| entry.session_id = agent.session_id |
| self.session_store._save() |
|
|
| effective_session_id = getattr(agent, 'session_id', session_id) if agent else session_id |
|
|
| |
| |
| |
| |
| |
| _effective_history_offset = 0 if _session_was_split else len(agent_history) |
|
|
| |
| if final_response and self._session_db: |
| try: |
| from agent.title_generator import maybe_auto_title |
| all_msgs = result_holder[0].get("messages", []) if result_holder[0] else [] |
| maybe_auto_title( |
| self._session_db, |
| effective_session_id, |
| message, |
| final_response, |
| all_msgs, |
| ) |
| except Exception: |
| pass |
|
|
| return { |
| "final_response": final_response, |
| "last_reasoning": result.get("last_reasoning"), |
| "messages": result_holder[0].get("messages", []) if result_holder[0] else [], |
| "api_calls": result_holder[0].get("api_calls", 0) if result_holder[0] else 0, |
| "tools": tools_holder[0] or [], |
| "history_offset": _effective_history_offset, |
| "last_prompt_tokens": _last_prompt_toks, |
| "input_tokens": _input_toks, |
| "output_tokens": _output_toks, |
| "model": _resolved_model, |
| "session_id": effective_session_id, |
| "response_previewed": result.get("response_previewed", False), |
| } |
| |
| |
| progress_task = None |
| if tool_progress_enabled: |
| progress_task = asyncio.create_task(send_progress_messages()) |
|
|
| |
| |
| stream_task = None |
|
|
| async def _start_stream_consumer(): |
| """Wait for the stream consumer to be created, then run it.""" |
| for _ in range(200): |
| if stream_consumer_holder[0] is not None: |
| await stream_consumer_holder[0].run() |
| return |
| await asyncio.sleep(0.05) |
|
|
| stream_task = asyncio.create_task(_start_stream_consumer()) |
| |
| |
| |
| async def track_agent(): |
| |
| while agent_holder[0] is None: |
| await asyncio.sleep(0.05) |
| if session_key: |
| self._running_agents[session_key] = agent_holder[0] |
| if self._draining: |
| self._update_runtime_status("draining") |
| |
| tracking_task = asyncio.create_task(track_agent()) |
| |
| |
| |
| |
| |
| |
| |
| _interrupt_detected = asyncio.Event() |
|
|
| async def monitor_for_interrupt(): |
| if not session_key: |
| return |
|
|
| while True: |
| await asyncio.sleep(0.2) |
| try: |
| |
| |
| _adapter = self.adapters.get(source.platform) |
| if not _adapter: |
| continue |
| |
| |
| |
| |
| if hasattr(_adapter, 'has_pending_interrupt') and _adapter.has_pending_interrupt(session_key): |
| agent = agent_holder[0] |
| if agent: |
| |
| |
| |
| |
| |
| |
| |
| |
| _peek_event = _adapter._pending_messages.get(session_key) |
| pending_text = _peek_event.text if _peek_event else None |
| logger.debug("Interrupt detected from adapter, signaling agent...") |
| agent.interrupt(pending_text) |
| _interrupt_detected.set() |
| break |
| except asyncio.CancelledError: |
| raise |
| except Exception as _mon_err: |
| logger.debug("monitor_for_interrupt error (will retry): %s", _mon_err) |
| |
| interrupt_monitor = asyncio.create_task(monitor_for_interrupt()) |
|
|
| |
| |
| |
| |
| |
| _NOTIFY_INTERVAL_RAW = float(os.getenv("HERMES_AGENT_NOTIFY_INTERVAL", 600)) |
| _NOTIFY_INTERVAL = _NOTIFY_INTERVAL_RAW if _NOTIFY_INTERVAL_RAW > 0 else None |
| _notify_start = time.time() |
|
|
| async def _notify_long_running(): |
| if _NOTIFY_INTERVAL is None: |
| return |
| _notify_adapter = self.adapters.get(source.platform) |
| if not _notify_adapter: |
| return |
| while True: |
| await asyncio.sleep(_NOTIFY_INTERVAL) |
| _elapsed_mins = int((time.time() - _notify_start) // 60) |
| |
| _agent_ref = agent_holder[0] |
| _status_detail = "" |
| if _agent_ref and hasattr(_agent_ref, "get_activity_summary"): |
| try: |
| _a = _agent_ref.get_activity_summary() |
| _parts = [f"iteration {_a['api_call_count']}/{_a['max_iterations']}"] |
| if _a.get("current_tool"): |
| _parts.append(f"running: {_a['current_tool']}") |
| else: |
| _parts.append(_a.get("last_activity_desc", "")) |
| _status_detail = " — " + ", ".join(_parts) |
| except Exception: |
| pass |
| try: |
| await _notify_adapter.send( |
| source.chat_id, |
| f"⏳ Still working... ({_elapsed_mins} min elapsed{_status_detail})", |
| metadata=_status_thread_metadata, |
| ) |
| except Exception as _ne: |
| logger.debug("Long-running notification error: %s", _ne) |
|
|
| _notify_task = asyncio.create_task(_notify_long_running()) |
|
|
| try: |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _agent_timeout_raw = float(os.getenv("HERMES_AGENT_TIMEOUT", 1800)) |
| _agent_timeout = _agent_timeout_raw if _agent_timeout_raw > 0 else None |
| _agent_warning_raw = float(os.getenv("HERMES_AGENT_TIMEOUT_WARNING", 900)) |
| _agent_warning = _agent_warning_raw if _agent_warning_raw > 0 else None |
| _warning_fired = False |
| loop = asyncio.get_event_loop() |
| _executor_task = asyncio.ensure_future( |
| loop.run_in_executor(None, run_sync) |
| ) |
|
|
| _inactivity_timeout = False |
| _POLL_INTERVAL = 5.0 |
|
|
| if _agent_timeout is None: |
| |
| |
| response = None |
| while True: |
| done, _ = await asyncio.wait( |
| {_executor_task}, timeout=_POLL_INTERVAL |
| ) |
| if done: |
| response = _executor_task.result() |
| break |
| |
| |
| if not _interrupt_detected.is_set() and session_key: |
| _backup_adapter = self.adapters.get(source.platform) |
| _backup_agent = agent_holder[0] |
| if (_backup_adapter and _backup_agent |
| and hasattr(_backup_adapter, 'has_pending_interrupt') |
| and _backup_adapter.has_pending_interrupt(session_key)): |
| _bp_event = _backup_adapter._pending_messages.get(session_key) |
| _bp_text = _bp_event.text if _bp_event else None |
| logger.info( |
| "Backup interrupt detected for session %s " |
| "(monitor task state: %s)", |
| session_key[:20], |
| "done" if interrupt_monitor.done() else "running", |
| ) |
| _backup_agent.interrupt(_bp_text) |
| _interrupt_detected.set() |
| else: |
| |
| |
| |
| response = None |
| while True: |
| done, _ = await asyncio.wait( |
| {_executor_task}, timeout=_POLL_INTERVAL |
| ) |
| if done: |
| response = _executor_task.result() |
| break |
| |
| _agent_ref = agent_holder[0] |
| _idle_secs = 0.0 |
| if _agent_ref and hasattr(_agent_ref, "get_activity_summary"): |
| try: |
| _act = _agent_ref.get_activity_summary() |
| _idle_secs = _act.get("seconds_since_activity", 0.0) |
| except Exception: |
| pass |
| |
| if (not _warning_fired and _agent_warning is not None |
| and _idle_secs >= _agent_warning): |
| _warning_fired = True |
| _warn_adapter = self.adapters.get(source.platform) |
| if _warn_adapter: |
| _elapsed_warn = int(_agent_warning // 60) or 1 |
| _remaining_mins = int((_agent_timeout - _agent_warning) // 60) or 1 |
| try: |
| await _warn_adapter.send( |
| source.chat_id, |
| f"⚠️ No activity for {_elapsed_warn} min. " |
| f"If the agent does not respond soon, it will " |
| f"be timed out in {_remaining_mins} min. " |
| f"You can continue waiting or use /reset.", |
| metadata=_status_thread_metadata, |
| ) |
| except Exception as _warn_err: |
| logger.debug("Inactivity warning send error: %s", _warn_err) |
| if _idle_secs >= _agent_timeout: |
| _inactivity_timeout = True |
| break |
| |
| if not _interrupt_detected.is_set() and session_key: |
| _backup_adapter = self.adapters.get(source.platform) |
| _backup_agent = agent_holder[0] |
| if (_backup_adapter and _backup_agent |
| and hasattr(_backup_adapter, 'has_pending_interrupt') |
| and _backup_adapter.has_pending_interrupt(session_key)): |
| _bp_event = _backup_adapter._pending_messages.get(session_key) |
| _bp_text = _bp_event.text if _bp_event else None |
| logger.info( |
| "Backup interrupt detected for session %s " |
| "(monitor task state: %s)", |
| session_key[:20], |
| "done" if interrupt_monitor.done() else "running", |
| ) |
| _backup_agent.interrupt(_bp_text) |
| _interrupt_detected.set() |
|
|
| if _inactivity_timeout: |
| |
| _timed_out_agent = agent_holder[0] |
| _activity = {} |
| if _timed_out_agent and hasattr(_timed_out_agent, "get_activity_summary"): |
| try: |
| _activity = _timed_out_agent.get_activity_summary() |
| except Exception: |
| pass |
|
|
| _last_desc = _activity.get("last_activity_desc", "unknown") |
| _secs_ago = _activity.get("seconds_since_activity", 0) |
| _cur_tool = _activity.get("current_tool") |
| _iter_n = _activity.get("api_call_count", 0) |
| _iter_max = _activity.get("max_iterations", 0) |
|
|
| logger.error( |
| "Agent idle for %.0fs (timeout %.0fs) in session %s " |
| "| last_activity=%s | iteration=%s/%s | tool=%s", |
| _secs_ago, _agent_timeout, session_key, |
| _last_desc, _iter_n, _iter_max, |
| _cur_tool or "none", |
| ) |
|
|
| |
| |
| if _timed_out_agent and hasattr(_timed_out_agent, "interrupt"): |
| _timed_out_agent.interrupt("Execution timed out (inactivity)") |
|
|
| _timeout_mins = int(_agent_timeout // 60) or 1 |
|
|
| |
| _diag_lines = [ |
| f"⏱️ Agent inactive for {_timeout_mins} min — no tool calls " |
| f"or API responses." |
| ] |
| if _cur_tool: |
| _diag_lines.append( |
| f"The agent appears stuck on tool `{_cur_tool}` " |
| f"({_secs_ago:.0f}s since last activity, " |
| f"iteration {_iter_n}/{_iter_max})." |
| ) |
| else: |
| _diag_lines.append( |
| f"Last activity: {_last_desc} ({_secs_ago:.0f}s ago, " |
| f"iteration {_iter_n}/{_iter_max}). " |
| "The agent may have been waiting on an API response." |
| ) |
| _diag_lines.append( |
| "To increase the limit, set agent.gateway_timeout in config.yaml " |
| "(value in seconds, 0 = no limit) and restart the gateway.\n" |
| "Try again, or use /reset to start fresh." |
| ) |
|
|
| response = { |
| "final_response": "\n".join(_diag_lines), |
| "messages": result_holder[0].get("messages", []) if result_holder[0] else [], |
| "api_calls": _iter_n, |
| "tools": tools_holder[0] or [], |
| "history_offset": 0, |
| "failed": True, |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| _agent = agent_holder[0] |
| _result_for_fb = result_holder[0] |
| _run_failed = _result_for_fb.get("failed") if _result_for_fb else False |
| if _agent is not None and hasattr(_agent, 'model') and not _run_failed: |
| _cfg_model = _resolve_gateway_model() |
| if _agent.model != _cfg_model and not self._is_intentional_model_switch(session_key, _agent.model): |
| |
| |
| self._evict_cached_agent(session_key) |
|
|
| |
| result = result_holder[0] |
| adapter = self.adapters.get(source.platform) |
| |
| |
| |
| pending_event = None |
| pending = None |
| if result and adapter and session_key: |
| pending_event = _dequeue_pending_event(adapter, session_key) |
| if result.get("interrupted") and not pending_event and result.get("interrupt_message"): |
| pending = result.get("interrupt_message") |
| elif pending_event: |
| pending = pending_event.text or _build_media_placeholder(pending_event) |
| logger.debug("Processing queued message after agent completion: '%s...'", pending[:40]) |
|
|
| |
| |
| |
| |
| |
| if pending and pending.strip().startswith("/"): |
| _pending_parts = pending.strip().split(None, 1) |
| _pending_cmd_word = _pending_parts[0][1:].lower() if _pending_parts else "" |
| if _pending_cmd_word: |
| try: |
| from hermes_cli.commands import resolve_command as _rc_pending |
| if _rc_pending(_pending_cmd_word): |
| logger.info( |
| "Discarding command '/%s' from pending queue — " |
| "commands must not be passed as agent input", |
| _pending_cmd_word, |
| ) |
| pending_event = None |
| pending = None |
| except Exception: |
| pass |
|
|
| if self._draining and (pending_event or pending): |
| logger.info( |
| "Discarding pending follow-up for session %s during gateway %s", |
| session_key[:20] if session_key else "?", |
| self._status_action_label(), |
| ) |
| pending_event = None |
| pending = None |
|
|
| if pending_event or pending: |
| logger.debug("Processing pending message: '%s...'", pending[:40]) |
|
|
| |
| |
| |
| if adapter and hasattr(adapter, '_active_sessions') and session_key and session_key in adapter._active_sessions: |
| adapter._active_sessions[session_key].clear() |
|
|
| |
| |
| if _interrupt_depth >= self._MAX_INTERRUPT_DEPTH: |
| logger.warning( |
| "Interrupt recursion depth %d reached for session %s — " |
| "queueing message instead of recursing.", |
| _interrupt_depth, session_key, |
| ) |
| adapter = self.adapters.get(source.platform) |
| if adapter and pending_event: |
| merge_pending_message_event(adapter._pending_messages, session_key, pending_event) |
| elif adapter and hasattr(adapter, 'queue_message'): |
| adapter.queue_message(session_key, pending) |
| return result_holder[0] or {"final_response": response, "messages": history} |
|
|
| was_interrupted = result.get("interrupted") |
| if not was_interrupted: |
| |
| |
| |
| _sc = stream_consumer_holder[0] |
| if _sc and stream_task: |
| try: |
| await asyncio.wait_for(stream_task, timeout=5.0) |
| except (asyncio.TimeoutError, asyncio.CancelledError): |
| stream_task.cancel() |
| try: |
| await stream_task |
| except asyncio.CancelledError: |
| pass |
| except Exception as e: |
| logger.debug("Stream consumer wait before queued message failed: %s", e) |
| _response_previewed = bool(result.get("response_previewed")) |
| _already_streamed = bool( |
| _sc |
| and ( |
| getattr(_sc, "final_response_sent", False) |
| or ( |
| _response_previewed |
| and getattr(_sc, "already_sent", False) |
| ) |
| ) |
| ) |
| first_response = result.get("final_response", "") |
| if first_response and not _already_streamed: |
| try: |
| await adapter.send( |
| source.chat_id, |
| first_response, |
| metadata=_status_thread_metadata, |
| ) |
| except Exception as e: |
| logger.warning("Failed to send first response before queued message: %s", e) |
| |
| |
| |
|
|
| updated_history = result.get("messages", history) |
| next_source = source |
| next_message = pending |
| next_message_id = None |
| if pending_event is not None: |
| next_source = getattr(pending_event, "source", None) or source |
| next_message = await self._prepare_inbound_message_text( |
| event=pending_event, |
| source=next_source, |
| history=updated_history, |
| ) |
| if next_message is None: |
| return result |
| next_message_id = getattr(pending_event, "message_id", None) |
|
|
| return await self._run_agent( |
| message=next_message, |
| context_prompt=context_prompt, |
| history=updated_history, |
| source=next_source, |
| session_id=session_id, |
| session_key=session_key, |
| _interrupt_depth=_interrupt_depth + 1, |
| event_message_id=next_message_id, |
| ) |
| finally: |
| |
| if progress_task: |
| progress_task.cancel() |
| interrupt_monitor.cancel() |
| _notify_task.cancel() |
|
|
| |
| if stream_task: |
| try: |
| await asyncio.wait_for(stream_task, timeout=5.0) |
| except (asyncio.TimeoutError, asyncio.CancelledError): |
| stream_task.cancel() |
| try: |
| await stream_task |
| except asyncio.CancelledError: |
| pass |
| |
| |
| tracking_task.cancel() |
| if session_key and session_key in self._running_agents: |
| del self._running_agents[session_key] |
| if session_key: |
| self._running_agents_ts.pop(session_key, None) |
| if self._draining: |
| self._update_runtime_status("draining") |
| |
| |
| for task in [progress_task, interrupt_monitor, tracking_task, _notify_task]: |
| if task: |
| try: |
| await task |
| except asyncio.CancelledError: |
| pass |
|
|
| |
| |
| |
| |
| |
| _sc = stream_consumer_holder[0] |
| if _sc and isinstance(response, dict) and not response.get("failed"): |
| _response_previewed = bool(response.get("response_previewed")) |
| if ( |
| getattr(_sc, "final_response_sent", False) |
| or ( |
| _response_previewed |
| and getattr(_sc, "already_sent", False) |
| ) |
| ): |
| response["already_sent"] = True |
| |
| return response |
|
|
|
|
| def _start_cron_ticker(stop_event: threading.Event, adapters=None, loop=None, interval: int = 60): |
| """ |
| Background thread that ticks the cron scheduler at a regular interval. |
| |
| Runs inside the gateway process so cronjobs fire automatically without |
| needing a separate `hermes cron daemon` or system cron entry. |
| |
| When ``adapters`` and ``loop`` are provided, passes them through to the |
| cron delivery path so live adapters can be used for E2EE rooms. |
| |
| Also refreshes the channel directory every 5 minutes and prunes the |
| image/audio/document cache once per hour. |
| """ |
| from cron.scheduler import tick as cron_tick |
| from gateway.platforms.base import cleanup_image_cache, cleanup_document_cache |
|
|
| IMAGE_CACHE_EVERY = 60 |
| CHANNEL_DIR_EVERY = 5 |
|
|
| logger.info("Cron ticker started (interval=%ds)", interval) |
| tick_count = 0 |
| while not stop_event.is_set(): |
| try: |
| cron_tick(verbose=False, adapters=adapters, loop=loop) |
| except Exception as e: |
| logger.debug("Cron tick error: %s", e) |
|
|
| tick_count += 1 |
|
|
| if tick_count % CHANNEL_DIR_EVERY == 0 and adapters: |
| try: |
| from gateway.channel_directory import build_channel_directory |
| build_channel_directory(adapters) |
| except Exception as e: |
| logger.debug("Channel directory refresh error: %s", e) |
|
|
| if tick_count % IMAGE_CACHE_EVERY == 0: |
| try: |
| removed = cleanup_image_cache(max_age_hours=24) |
| if removed: |
| logger.info("Image cache cleanup: removed %d stale file(s)", removed) |
| except Exception as e: |
| logger.debug("Image cache cleanup error: %s", e) |
| try: |
| removed = cleanup_document_cache(max_age_hours=24) |
| if removed: |
| logger.info("Document cache cleanup: removed %d stale file(s)", removed) |
| except Exception as e: |
| logger.debug("Document cache cleanup error: %s", e) |
|
|
| stop_event.wait(timeout=interval) |
| logger.info("Cron ticker stopped") |
|
|
|
|
| async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool = False, verbosity: Optional[int] = 0) -> bool: |
| """ |
| Start the gateway and run until interrupted. |
| |
| This is the main entry point for running the gateway. |
| Returns True if the gateway ran successfully, False if it failed to start. |
| A False return causes a non-zero exit code so systemd can auto-restart. |
| |
| Args: |
| config: Optional gateway configuration override. |
| replace: If True, kill any existing gateway instance before starting. |
| Useful for systemd services to avoid restart-loop deadlocks |
| when the previous process hasn't fully exited yet. |
| """ |
| |
| |
| |
| |
| |
| import time as _time |
| from gateway.status import get_running_pid, remove_pid_file, terminate_pid |
| existing_pid = get_running_pid() |
| if existing_pid is not None and existing_pid != os.getpid(): |
| if replace: |
| logger.info( |
| "Replacing existing gateway instance (PID %d) with --replace.", |
| existing_pid, |
| ) |
| try: |
| terminate_pid(existing_pid, force=False) |
| except ProcessLookupError: |
| pass |
| except (PermissionError, OSError): |
| logger.error( |
| "Permission denied killing PID %d. Cannot replace.", |
| existing_pid, |
| ) |
| return False |
| |
| for _ in range(20): |
| try: |
| os.kill(existing_pid, 0) |
| _time.sleep(0.5) |
| except (ProcessLookupError, PermissionError): |
| break |
| else: |
| |
| logger.warning( |
| "Old gateway (PID %d) did not exit after SIGTERM, sending SIGKILL.", |
| existing_pid, |
| ) |
| try: |
| terminate_pid(existing_pid, force=True) |
| _time.sleep(0.5) |
| except (ProcessLookupError, PermissionError, OSError): |
| pass |
| remove_pid_file() |
| |
| |
| |
| try: |
| from gateway.status import release_all_scoped_locks |
| _released = release_all_scoped_locks() |
| if _released: |
| logger.info("Released %d stale scoped lock(s) from old gateway.", _released) |
| except Exception: |
| pass |
| else: |
| 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 |
|
|
| |
| try: |
| from tools.skills_sync import sync_skills |
| sync_skills(quiet=True) |
| except Exception: |
| pass |
|
|
| |
| |
| |
| from hermes_logging import setup_logging |
| setup_logging(hermes_home=_hermes_home, mode="gateway") |
|
|
| |
| |
| |
| |
| |
| if verbosity is not None: |
| from agent.redact import RedactingFormatter |
|
|
| _stderr_level = {0: logging.WARNING, 1: logging.INFO}.get(verbosity, logging.DEBUG) |
| _stderr_handler = logging.StreamHandler() |
| _stderr_handler.setLevel(_stderr_level) |
| _stderr_handler.setFormatter(RedactingFormatter('%(levelname)s %(name)s: %(message)s')) |
| logging.getLogger().addHandler(_stderr_handler) |
| |
| if _stderr_level < logging.getLogger().level: |
| logging.getLogger().setLevel(_stderr_level) |
|
|
| runner = GatewayRunner(config) |
| |
| |
| def shutdown_signal_handler(): |
| asyncio.create_task(runner.stop()) |
|
|
| def restart_signal_handler(): |
| runner.request_restart(detached=False, via_service=True) |
| |
| loop = asyncio.get_event_loop() |
| if threading.current_thread() is threading.main_thread(): |
| for sig in (signal.SIGINT, signal.SIGTERM): |
| try: |
| loop.add_signal_handler(sig, shutdown_signal_handler) |
| except NotImplementedError: |
| pass |
| if hasattr(signal, "SIGUSR1"): |
| try: |
| loop.add_signal_handler(signal.SIGUSR1, restart_signal_handler) |
| except NotImplementedError: |
| pass |
| else: |
| logger.info("Skipping signal handlers (not running in main thread).") |
| |
| |
| success = await runner.start() |
| if not success: |
| return False |
| if runner.should_exit_cleanly: |
| if runner.exit_reason: |
| logger.error("Gateway exiting cleanly: %s", runner.exit_reason) |
| return True |
| |
| |
| import atexit |
| from gateway.status import write_pid_file, remove_pid_file |
| write_pid_file() |
| atexit.register(remove_pid_file) |
| |
| |
| |
| cron_stop = threading.Event() |
| cron_thread = threading.Thread( |
| target=_start_cron_ticker, |
| args=(cron_stop,), |
| kwargs={"adapters": runner.adapters, "loop": asyncio.get_running_loop()}, |
| daemon=True, |
| name="cron-ticker", |
| ) |
| cron_thread.start() |
| |
| |
| await runner.wait_for_shutdown() |
|
|
| if runner.should_exit_with_failure: |
| if runner.exit_reason: |
| logger.error("Gateway exiting with failure: %s", runner.exit_reason) |
| return False |
| |
| |
| cron_stop.set() |
| cron_thread.join(timeout=5) |
|
|
| |
| try: |
| from tools.mcp_tool import shutdown_mcp_servers |
| shutdown_mcp_servers() |
| except Exception: |
| pass |
|
|
| if runner.exit_code is not None: |
| raise SystemExit(runner.exit_code) |
|
|
| return True |
|
|
|
|
| def main(): |
| """CLI entry point for the gateway.""" |
| 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 json |
| with open(args.config, encoding="utf-8") as f: |
| data = json.load(f) |
| config = GatewayConfig.from_dict(data) |
| |
| |
| |
| success = asyncio.run(start_gateway(config)) |
| if not success: |
| sys.exit(1) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|