mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
The monitor_for_interrupt() and backup interrupt checks were calling
get_pending_message() which pops the message from the adapter's queue.
This created a race condition: if the agent finished naturally before
checking _interrupt_requested, the pending message was permanently lost.
Timeline of the race:
1. Agent near completion, user sends message
2. Level 1 guard stores message in adapter._pending_messages, sets event
3. monitor_for_interrupt() detects event, POPS message, calls agent.interrupt()
4. Agent's run_conversation() was already returning (interrupted=False)
5. Post-run dequeue finds nothing (monitor already consumed it)
6. result.get('interrupted') is False so interrupt_message fallback doesn't fire
7. User message permanently lost — agent finishes without processing it
Fix: change all three interrupt detection sites (primary monitor + two
backup checks) from get_pending_message() (pop) to
_pending_messages.get() (peek). The message stays in the adapter's queue
until _dequeue_pending_event() consumes it in the post-run handler,
which runs regardless of whether the agent was interrupted or finished
naturally.
Reported by @_SushantSays — intermittent message loss during long
terminal command execution, persisting after the previous fix (73f970fa)
which addressed monitor task death but not this consumption race.
8758 lines
394 KiB
Python
8758 lines
394 KiB
Python
"""
|
||
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
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# SSL certificate auto-detection for NixOS and other non-standard systems.
|
||
# Must run BEFORE any HTTP library (discord, aiohttp, etc.) is imported.
|
||
# ---------------------------------------------------------------------------
|
||
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 # user already configured it
|
||
|
||
import ssl
|
||
|
||
# 1. Python's compiled-in defaults
|
||
paths = ssl.get_default_verify_paths()
|
||
for candidate in (paths.cafile, paths.openssl_cafile):
|
||
if candidate and os.path.exists(candidate):
|
||
os.environ["SSL_CERT_FILE"] = candidate
|
||
return
|
||
|
||
# 2. certifi (ships its own Mozilla bundle)
|
||
try:
|
||
import certifi
|
||
os.environ["SSL_CERT_FILE"] = certifi.where()
|
||
return
|
||
except ImportError:
|
||
pass
|
||
|
||
# 3. Common distro / macOS locations
|
||
for candidate in (
|
||
"/etc/ssl/certs/ca-certificates.crt", # Debian/Ubuntu/Gentoo
|
||
"/etc/pki/tls/certs/ca-bundle.crt", # RHEL/CentOS 7
|
||
"/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", # RHEL/CentOS 8+
|
||
"/etc/ssl/ca-bundle.pem", # SUSE/OpenSUSE
|
||
"/etc/ssl/cert.pem", # Alpine / macOS
|
||
"/etc/pki/tls/cert.pem", # Fedora
|
||
"/usr/local/etc/openssl@1.1/cert.pem", # macOS Homebrew Intel
|
||
"/opt/homebrew/etc/openssl@1.1/cert.pem", # macOS Homebrew ARM
|
||
):
|
||
if os.path.exists(candidate):
|
||
os.environ["SSL_CERT_FILE"] = candidate
|
||
return
|
||
|
||
_ensure_ssl_certs()
|
||
|
||
# Add parent directory to path
|
||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||
|
||
# Resolve Hermes home directory (respects HERMES_HOME override)
|
||
from hermes_constants import get_hermes_home
|
||
from utils import atomic_yaml_write, is_truthy_value
|
||
_hermes_home = get_hermes_home()
|
||
|
||
# Load environment variables from ~/.hermes/.env first.
|
||
# User-managed env files should override stale shell exports on restart.
|
||
from dotenv import load_dotenv # backward-compat for tests that monkeypatch this symbol
|
||
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')
|
||
|
||
# Bridge config.yaml values into the environment so os.getenv() picks them up.
|
||
# config.yaml is authoritative for terminal settings — overrides .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 {}
|
||
# Expand ${ENV_VAR} references before bridging to env vars.
|
||
from hermes_cli.config import _expand_env_vars
|
||
_cfg = _expand_env_vars(_cfg)
|
||
# Top-level simple values (fallback only — don't override .env)
|
||
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 config is nested — bridge to TERMINAL_* env vars.
|
||
# config.yaml overrides .env for these since it's the documented config path.
|
||
_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)
|
||
# Compression config is read directly from config.yaml by run_agent.py
|
||
# and auxiliary_client.py — no env var bridging needed.
|
||
# Auxiliary model/direct-endpoint overrides (vision, web_extract).
|
||
# Each task has provider/model/base_url/api_key; bridge non-default values to env vars.
|
||
_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"])
|
||
# Bridge agent.gateway_timeout → HERMES_AGENT_TIMEOUT env var.
|
||
# Env var from .env takes precedence (already in os.environ).
|
||
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 "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"])
|
||
# Timezone: bridge config.yaml → HERMES_TIMEZONE env var.
|
||
# HERMES_TIMEZONE from .env takes precedence (already in os.environ).
|
||
_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 settings
|
||
_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 # Non-fatal; gateway can still run with .env values
|
||
|
||
# Apply IPv4 preference if configured (before any HTTP clients are created).
|
||
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
|
||
|
||
# Validate config structure early — log warnings so gateway operators see problems
|
||
try:
|
||
from hermes_cli.config import print_config_warnings
|
||
print_config_warnings()
|
||
except Exception:
|
||
pass
|
||
|
||
# Gateway runs in quiet mode - suppress debug output and use cwd directly (no temp dirs)
|
||
os.environ["HERMES_QUIET"] = "1"
|
||
|
||
# Enable interactive exec approval for dangerous commands on messaging platforms
|
||
os.environ["HERMES_EXEC_ASK"] = "1"
|
||
|
||
# Set terminal working directory for messaging platforms.
|
||
# If the user set an explicit path in config.yaml (not "." or "auto"),
|
||
# respect it. Otherwise use MESSAGING_CWD or default to home directory.
|
||
_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__)
|
||
|
||
# Sentinel placed into _running_agents immediately when a session starts
|
||
# processing, *before* any await. Prevents a second message for the same
|
||
# session from bypassing the "already running" guard during the async gap
|
||
# between the guard check and actual agent creation.
|
||
_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.
|
||
"""
|
||
# Normalize: command uses hyphens, skill names may use hyphens or underscores
|
||
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()
|
||
|
||
# Check disabled skills across all dirs (local + external)
|
||
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`"
|
||
)
|
||
|
||
# Check optional skills (shipped with repo but not installed)
|
||
from hermes_constants import get_optional_skills_dir
|
||
repo_root = Path(__file__).resolve().parent.parent
|
||
optional_dir = get_optional_skills_dir(repo_root / "optional-skills")
|
||
if optional_dir.exists():
|
||
for skill_md in optional_dir.rglob("SKILL.md"):
|
||
name = skill_md.parent.name.lower().replace("_", "-")
|
||
if name == normalized:
|
||
# Build install path: official/<category>/<name>
|
||
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.
|
||
"""
|
||
|
||
# Class-level defaults so partial construction in tests doesn't
|
||
# blow up on attribute access.
|
||
_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] = {}
|
||
|
||
# Load ephemeral config from config.yaml / env vars.
|
||
# Both are injected at API-call time only and never persisted.
|
||
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()
|
||
|
||
# Wire process registry into session store for reset protection
|
||
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
|
||
|
||
# Track running agents per session for interrupt support
|
||
# Key: session_key, Value: AIAgent instance
|
||
self._running_agents: Dict[str, Any] = {}
|
||
self._running_agents_ts: Dict[str, float] = {} # start timestamp per session
|
||
self._pending_messages: Dict[str, str] = {} # Queued messages during interrupt
|
||
|
||
# Cache AIAgent instances per session to preserve prompt caching.
|
||
# Without this, a new AIAgent is created per message, rebuilding the
|
||
# system prompt (including memory) every turn — breaking prefix cache
|
||
# and costing ~10x more on providers with prompt caching (Anthropic).
|
||
# Key: session_key, Value: (AIAgent, config_signature_str)
|
||
import threading as _threading
|
||
self._agent_cache: Dict[str, tuple] = {}
|
||
self._agent_cache_lock = _threading.Lock()
|
||
|
||
# Per-session model overrides from /model command.
|
||
# Key: session_key, Value: dict with model/provider/api_key/base_url/api_mode
|
||
self._session_model_overrides: Dict[str, Dict[str, str]] = {}
|
||
# Track pending exec approvals per session
|
||
# Key: session_key, Value: {"command": str, "pattern_key": str, ...}
|
||
self._pending_approvals: Dict[str, Dict[str, Any]] = {}
|
||
|
||
# Track platforms that failed to connect for background reconnection.
|
||
# Key: Platform enum, Value: {"config": platform_config, "attempts": int, "next_retry": float}
|
||
self._failed_platforms: Dict[Platform, Dict[str, Any]] = {}
|
||
|
||
# Track pending /update prompt responses per session.
|
||
# Key: session_key, Value: True when a prompt is waiting for user input.
|
||
self._update_prompt_pending: Dict[str, bool] = {}
|
||
|
||
# Persistent Honcho managers keyed by gateway session key.
|
||
# This preserves write_frequency="session" semantics across short-lived
|
||
# per-message AIAgent instances.
|
||
|
||
|
||
|
||
# Ensure tirith security scanner is available (downloads if needed)
|
||
try:
|
||
from tools.tirith_security import ensure_installed
|
||
ensure_installed(log_failures=False)
|
||
except Exception:
|
||
pass # Non-fatal — fail-open at scan time if unavailable
|
||
|
||
# Initialize session database for session_search tool support
|
||
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)
|
||
|
||
# DM pairing store for code-based user authorization
|
||
from gateway.pairing import PairingStore
|
||
self.pairing_store = PairingStore()
|
||
|
||
# Event hook system
|
||
from gateway.hooks import HookRegistry
|
||
self.hooks = HookRegistry()
|
||
|
||
# Per-chat voice reply mode: "off" | "voice_only" | "all"
|
||
self._voice_mode: Dict[str, str] = self._load_voice_modes()
|
||
|
||
# Track background tasks to prevent garbage collection mid-execution
|
||
self._background_tasks: set = set()
|
||
|
||
|
||
|
||
|
||
# -- Setup skill availability ----------------------------------------
|
||
|
||
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 persistence ------------------------------------------
|
||
|
||
_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.
|
||
"""
|
||
# Skip cron sessions — they run headless with no meaningful user
|
||
# conversation to extract memories from.
|
||
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, # Flush agent — no memory provider
|
||
enabled_toolsets=["memory", "skills"],
|
||
session_id=old_session_id,
|
||
)
|
||
# Fully silence the flush agent — quiet_mode only suppresses init
|
||
# messages; tool call output still leaks to the terminal through
|
||
# _safe_print → _print_fn. Set a no-op to prevent that.
|
||
tmp_agent._print_fn = lambda *a, **kw: None
|
||
|
||
# Build conversation history from transcript
|
||
msgs = [
|
||
{"role": m.get("role"), "content": m.get("content")}
|
||
for m in history
|
||
if m.get("role") in ("user", "assistant") and m.get("content")
|
||
]
|
||
|
||
# Read live memory state from disk so the flush agent can see
|
||
# what's already saved and avoid overwriting newer entries.
|
||
_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 # Non-fatal — flush still works, just without the guard
|
||
|
||
# Give the agent a real turn to think about what to save
|
||
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"):
|
||
return override_model, override_runtime
|
||
|
||
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
|
||
)
|
||
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
|
||
|
||
# Queue retryable failures for background reconnection
|
||
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:
|
||
# All platforms are down and queued for background reconnection.
|
||
# If the error is retryable, exit with failure so systemd Restart=on-failure
|
||
# can restart the process. Otherwise stay alive and keep retrying in background.
|
||
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
|
||
# Close tool resources (terminal sandboxes, browser daemons,
|
||
# background processes, httpx clients) to prevent zombie
|
||
# process accumulation.
|
||
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
|
||
|
||
# Warn if no user allowlists are configured and open access is not opted in
|
||
_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)."
|
||
)
|
||
|
||
# Discover and load event hooks
|
||
self.hooks.discover_and_load()
|
||
|
||
# Recover background processes from checkpoint (crash recovery)
|
||
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)
|
||
|
||
# Suspend sessions that were active when the gateway last exited.
|
||
# This prevents stuck sessions from being blindly resumed on restart,
|
||
# which can create an unrecoverable loop (#7536). Suspended sessions
|
||
# auto-reset on the next incoming message, giving the user a clean start.
|
||
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] = []
|
||
|
||
# Initialize and connect each configured platform
|
||
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
|
||
|
||
# Set up message + fatal error handlers
|
||
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)
|
||
|
||
# Try to connect
|
||
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}"
|
||
)
|
||
# Queue for reconnection if the error is retryable
|
||
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"
|
||
)
|
||
# No fatal error info means likely a transient issue — queue for retry
|
||
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}")
|
||
# Unexpected exceptions are typically transient — queue for retry
|
||
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.")
|
||
|
||
# Update delivery router with adapters
|
||
self.delivery_router.adapters = self.adapters
|
||
|
||
self._running = True
|
||
self._update_runtime_status("running")
|
||
|
||
# Emit gateway:startup hook
|
||
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)
|
||
|
||
# Build initial channel directory for send_message name resolution
|
||
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)
|
||
|
||
# Check if we're restarting after a /update command. If the update is
|
||
# still running, keep watching so we notify once it actually finishes.
|
||
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()
|
||
|
||
# Drain any recovered process watchers (from crash recovery checkpoint)
|
||
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)
|
||
|
||
# Start background session expiry watcher for proactive memory flushing
|
||
asyncio.create_task(self._session_expiry_watcher())
|
||
|
||
# Start background reconnection watcher for platforms that failed at startup
|
||
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) # initial delay — let the gateway fully start
|
||
_flush_failures: dict[str, int] = {} # session_id -> consecutive failure count
|
||
_MAX_FLUSH_RETRIES = 3
|
||
while self._running:
|
||
try:
|
||
self.session_store._ensure_loaded()
|
||
# Collect expired sessions first, then log a single summary.
|
||
_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:
|
||
# Extract platform names from session keys for a compact summary.
|
||
# Keys look like "agent:main:telegram:dm:12345" — platform is field [2].
|
||
_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)
|
||
# Shut down memory provider and close tool resources
|
||
# on the cached agent. Idle agents live in
|
||
# _agent_cache (not _running_agents), so look there.
|
||
_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
|
||
# Fall back to _running_agents in case the agent is
|
||
# still mid-turn when the expiry fires.
|
||
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
|
||
# Mark as flushed and persist to disk so the flag
|
||
# survives gateway restarts.
|
||
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)
|
||
# Sleep in small increments so we can stop quickly
|
||
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 # 5 minutes max between retries
|
||
|
||
await asyncio.sleep(10) # initial delay — let startup finish
|
||
while self._running:
|
||
if not self._failed_platforms:
|
||
# Nothing to reconnect — sleep and check again
|
||
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 # not time yet
|
||
|
||
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)
|
||
|
||
# Rebuild channel directory with the new adapter
|
||
try:
|
||
from gateway.channel_directory import build_channel_directory
|
||
build_channel_directory(self.adapters)
|
||
except Exception:
|
||
pass
|
||
else:
|
||
# Check if the failure is non-retryable
|
||
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,
|
||
)
|
||
|
||
# Check every 10 seconds for platforms that need reconnection
|
||
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()
|
||
|
||
# Global cleanup: kill any remaining tool subprocesses not tied
|
||
# to a specific agent (catch-all for zombie prevention).
|
||
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()
|
||
|
||
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 # For cross-platform delivery
|
||
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
|
||
"""
|
||
# Home Assistant events are system-generated (state changes), not
|
||
# user-initiated messages. The HASS_TOKEN already authenticates the
|
||
# connection, so HA events are always authorized.
|
||
# Webhook events are authenticated via HMAC signature validation in
|
||
# the adapter itself — no user allowlist applies.
|
||
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",
|
||
}
|
||
|
||
# Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true)
|
||
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
|
||
|
||
# Check pairing store (always checked, regardless of allowlists)
|
||
platform_name = source.platform.value if source.platform else ""
|
||
if self.pairing_store.is_approved(platform_name, user_id):
|
||
return True
|
||
|
||
# Check platform-specific and global allowlists
|
||
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:
|
||
# No allowlists configured -- check global allow-all flag
|
||
return os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in ("true", "1", "yes")
|
||
|
||
# Check if user is in any allowlist
|
||
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())
|
||
|
||
# "*" in any allowlist means allow everyone (consistent with
|
||
# SIGNAL_GROUP_ALLOWED_USERS precedent)
|
||
if "*" in allowed_ids:
|
||
return True
|
||
|
||
check_ids = {user_id}
|
||
if "@" in user_id:
|
||
check_ids.add(user_id.split("@")[0])
|
||
|
||
# WhatsApp: resolve phone↔LID aliases from bridge session mapping files
|
||
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
|
||
|
||
# Internal events (e.g. background-process completion notifications)
|
||
# are system-generated and must skip user authorization.
|
||
if getattr(event, "internal", False):
|
||
pass
|
||
elif source.user_id is None:
|
||
# Messages with no user identity (Telegram service messages,
|
||
# channel forwards, anonymous admin actions) cannot be
|
||
# authorized — drop silently instead of triggering the pairing
|
||
# flow with a None user_id.
|
||
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)
|
||
# In DMs: offer pairing code. In groups: silently ignore.
|
||
if source.chat_type == "dm" and self._get_unauthorized_dm_behavior(source.platform) == "pair":
|
||
platform_name = source.platform.value if source.platform else "unknown"
|
||
# Rate-limit ALL pairing responses (code or rejection) to
|
||
# prevent spamming the user with repeated messages when
|
||
# multiple DMs arrive in quick succession.
|
||
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!"
|
||
)
|
||
# Record rate limit so subsequent messages are silently ignored
|
||
self.pairing_store._record_rate_limit(platform_name, source.user_id)
|
||
return None
|
||
|
||
# Intercept messages that are responses to a pending /update prompt.
|
||
# The update process (detached) wrote .update_prompt.json; the watcher
|
||
# forwarded it to the user; now the user's reply goes back via
|
||
# .update_response so the update process can continue.
|
||
_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()
|
||
# Accept /approve and /deny as shorthand for yes/no
|
||
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."
|
||
|
||
# PRIORITY handling when an agent is already running for this session.
|
||
# Default behavior is to interrupt immediately so user text/stop messages
|
||
# are handled with minimal latency.
|
||
#
|
||
# Special case: Telegram/photo bursts often arrive as multiple near-
|
||
# simultaneous updates. Do NOT interrupt for photo-only follow-ups here;
|
||
# let the adapter-level batching/queueing logic absorb them.
|
||
|
||
# Staleness eviction: detect leaked locks from hung/crashed handlers.
|
||
# With inactivity-based timeout, active tasks can run for hours, so
|
||
# wall-clock age alone isn't sufficient. Evict only when the agent
|
||
# has been *idle* beyond the inactivity threshold (or when the agent
|
||
# object has no activity tracker and wall-clock age is extreme).
|
||
_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)
|
||
# Never evict the pending sentinel — it was just placed moments
|
||
# ago during the async setup phase before the real agent is
|
||
# created. Sentinels have no get_activity_summary(), so the
|
||
# idle check below would always evaluate to inf >= timeout and
|
||
# immediately evict them, racing with the setup path.
|
||
_stale_idle = float("inf") # assume idle if we can't check
|
||
_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
|
||
# Evict if: agent is idle beyond timeout, OR wall-clock age is
|
||
# extreme (10x timeout or 2h, whichever is larger — catches
|
||
# cases where the agent object was garbage-collected).
|
||
_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)
|
||
|
||
# Resolve the command once for all early-intercept checks below.
|
||
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)
|
||
|
||
# /stop must hard-kill the session when an agent is running.
|
||
# A soft interrupt (agent.interrupt()) doesn't help when the agent
|
||
# is truly hung — the executor thread is blocked and never checks
|
||
# _interrupt_requested. Force-clean _running_agents so the session
|
||
# is unlocked and subsequent messages are processed normally.
|
||
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")
|
||
# Force-clean: remove the session lock regardless of agent state
|
||
adapter = self.adapters.get(source.platform)
|
||
if adapter and hasattr(adapter, 'get_pending_message'):
|
||
adapter.get_pending_message(_quick_key) # consume and discard
|
||
self._pending_messages.pop(_quick_key, None)
|
||
if _quick_key in self._running_agents:
|
||
del self._running_agents[_quick_key]
|
||
# Mark session suspended so the next message starts fresh
|
||
# instead of resuming the stuck context (#7536).
|
||
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."
|
||
|
||
# /reset and /new must bypass the running-agent guard so they
|
||
# actually dispatch as commands instead of being queued as user
|
||
# text (which would be fed back to the agent with the same
|
||
# broken history — #2170). Interrupt the agent first, then
|
||
# clear the adapter's pending queue so the stale "/reset" text
|
||
# doesn't get re-processed as a user message after the
|
||
# interrupt completes.
|
||
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")
|
||
# Clear any pending messages so the old text doesn't replay
|
||
adapter = self.adapters.get(source.platform)
|
||
if adapter and hasattr(adapter, 'get_pending_message'):
|
||
adapter.get_pending_message(_quick_key) # consume and discard
|
||
self._pending_messages.pop(_quick_key, None)
|
||
# Clean up the running agent entry so the reset handler
|
||
# doesn't think an agent is still active.
|
||
if _quick_key in self._running_agents:
|
||
del self._running_agents[_quick_key]
|
||
return await self._handle_reset_command(event)
|
||
|
||
# /queue <prompt> — queue without interrupting
|
||
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."
|
||
|
||
# /model must not be used while the agent is running.
|
||
if _cmd_def_inner and _cmd_def_inner.name == "model":
|
||
return "Agent is running — wait or /stop first, then switch models."
|
||
|
||
# /approve and /deny must bypass the running-agent interrupt path.
|
||
# The agent thread is blocked on a threading.Event inside
|
||
# tools/approval.py — sending an interrupt won't unblock it.
|
||
# Route directly to the approval handler so the event is signalled.
|
||
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)
|
||
|
||
# /background must bypass the running-agent guard — it starts a
|
||
# parallel task and must never interrupt the active conversation.
|
||
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:
|
||
# Agent is being set up but not ready yet.
|
||
if event.get_command() == "stop":
|
||
# Force-clean the sentinel so the session is unlocked.
|
||
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."
|
||
# Queue the message so it will be picked up after the
|
||
# agent starts.
|
||
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
|
||
|
||
# Check for commands
|
||
command = event.get_command()
|
||
|
||
# Emit command:* hook for any recognized slash command.
|
||
# GATEWAY_KNOWN_COMMANDS is derived from the central COMMAND_REGISTRY
|
||
# in hermes_cli/commands.py — no hardcoded set to maintain here.
|
||
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(),
|
||
})
|
||
|
||
# Resolve aliases to canonical name so dispatch only checks canonicals.
|
||
_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 == "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."
|
||
|
||
# User-defined quick commands (bypass agent loop, no LLM call)
|
||
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
|
||
# Fall through to normal command dispatch below
|
||
else:
|
||
return f"Quick command '/{command}' has no target defined."
|
||
else:
|
||
return f"Quick command '/{command}' has unsupported type (supported: 'exec', 'alias')."
|
||
|
||
# Plugin-registered slash commands
|
||
if command:
|
||
try:
|
||
from hermes_cli.plugins import get_plugin_command_handler
|
||
# Normalize underscores to hyphens so Telegram's underscored
|
||
# autocomplete form matches plugin commands registered with
|
||
# hyphens. See hermes_cli/commands.py:_build_telegram_menu.
|
||
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)
|
||
|
||
# Skill slash commands: /skill-name loads the skill and sends to agent.
|
||
# resolve_skill_command_key() handles the Telegram underscore/hyphen
|
||
# round-trip so /claude_code from Telegram autocomplete still resolves
|
||
# to the claude-code skill.
|
||
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:
|
||
# Check per-platform disabled status before executing.
|
||
# get_skill_commands() only applies the *global* disabled
|
||
# list at scan time; per-platform overrides need checking
|
||
# here because the cache is process-global across platforms.
|
||
_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
|
||
# Fall through to normal message processing with skill content
|
||
else:
|
||
# Not an active skill — check if it's a known-but-disabled or
|
||
# uninstalled skill and give actionable guidance.
|
||
_unavail_msg = _check_unavailable_skill(command)
|
||
if _unavail_msg:
|
||
return _unavail_msg
|
||
# Genuinely unrecognized /command: not a built-in, not a
|
||
# plugin, not a skill, not a known-inactive skill. Warn
|
||
# the user instead of silently forwarding it to the LLM
|
||
# as free text (which leads to silent-failure behavior
|
||
# like the model inventing a delegate_task call).
|
||
# Normalize to hyphenated form before checking known
|
||
# built-ins (command may be an alias target set by the
|
||
# quick-command block above, so _cmd_def can be stale).
|
||
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)
|
||
|
||
# Pending exec approvals are handled by /approve and /deny commands above.
|
||
# No bare text matching — "yes" in normal conversation must not trigger
|
||
# execution of a dangerous command.
|
||
|
||
# ── Claim this session before any await ───────────────────────
|
||
# Between here and _run_agent registering the real AIAgent, there
|
||
# are numerous await points (hooks, vision enrichment, STT,
|
||
# session hygiene compression). Without this sentinel a second
|
||
# message arriving during any of those yields would pass the
|
||
# "already running" guard and spin up a duplicate agent for the
|
||
# same session — corrupting the transcript.
|
||
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 _run_agent replaced the sentinel with a real agent and
|
||
# then cleaned it up, this is a no-op. If we exited early
|
||
# (exception, command fallthrough, etc.) the sentinel must
|
||
# not linger or the session would be permanently locked out.
|
||
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,
|
||
)
|
||
|
||
# Get or create session
|
||
session_entry = self.session_store.get_or_create_session(source)
|
||
session_key = session_entry.session_key
|
||
|
||
# Emit session:start for new or auto-reset sessions
|
||
_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,
|
||
})
|
||
|
||
# Build session context
|
||
context = build_session_context(source, self.config, session_entry)
|
||
|
||
# Set session context variables for tools (task-local, concurrency-safe)
|
||
_session_env_tokens = self._set_session_env(context)
|
||
|
||
# Read privacy.redact_pii from config (re-read per message)
|
||
_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
|
||
|
||
# Build the context prompt to inject
|
||
context_prompt = build_session_context_prompt(context, redact_pii=_redact_pii)
|
||
|
||
# If the previous session expired and was auto-reset, prepend a notice
|
||
# so the agent knows this is a fresh conversation (not an intentional /reset).
|
||
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
|
||
|
||
# Send a user-facing notification explaining the reset, unless:
|
||
# - notifications are disabled in config
|
||
# - the platform is excluded (e.g. api_server, webhook)
|
||
# - the expired session had no activity (nothing was cleared)
|
||
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)
|
||
# Suspended sessions always notify (they were explicitly stopped
|
||
# or crashed mid-operation) — skip the policy check.
|
||
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-load skill(s) for topic/channel bindings (Telegram DM Topics,
|
||
# Discord channel_skill_bindings). Supports a single name or ordered list.
|
||
# Only inject on NEW sessions — ongoing conversations already have the
|
||
# skill content in their conversation history from the first message.
|
||
_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:
|
||
# Append the user's original text after all skill payloads
|
||
_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)
|
||
|
||
# Load conversation history from transcript
|
||
history = self.session_store.load_transcript(session_entry.session_id)
|
||
|
||
# -----------------------------------------------------------------
|
||
# Session hygiene: auto-compress pathologically large transcripts
|
||
#
|
||
# Long-lived gateway sessions can accumulate enough history that
|
||
# every new message rehydrates an oversized transcript, causing
|
||
# repeated truncation/context failures. Detect this early and
|
||
# compress proactively — before the agent even starts. (#628)
|
||
#
|
||
# Token source priority:
|
||
# 1. Actual API-reported prompt_tokens from the last turn
|
||
# (stored in session_entry.last_prompt_tokens)
|
||
# 2. Rough char-based estimate (str(msg)//4). Overestimates
|
||
# by 30-50% on code/JSON-heavy sessions, but that just
|
||
# means hygiene fires a bit early — safe and harmless.
|
||
# -----------------------------------------------------------------
|
||
if history and len(history) >= 4:
|
||
from agent.model_metadata import (
|
||
estimate_messages_tokens_rough,
|
||
get_model_context_length,
|
||
)
|
||
|
||
# Read model + compression config from config.yaml.
|
||
# NOTE: hygiene threshold is intentionally HIGHER than the agent's
|
||
# own compressor (0.85 vs 0.50). Hygiene is a safety net for
|
||
# sessions that grew too large between turns — it fires pre-agent
|
||
# to prevent API failures. The agent's own compressor handles
|
||
# normal context management during its tool loop with accurate
|
||
# real token counts. Having hygiene at 0.50 caused premature
|
||
# compression on every turn in long gateway sessions.
|
||
_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 {}
|
||
|
||
# Resolve model name (same logic as run_sync)
|
||
_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
|
||
# Read explicit context_length override from model config
|
||
# (same as run_agent.py lines 995-1005)
|
||
_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
|
||
# Read provider for accurate context detection
|
||
_hyg_provider = _model_cfg.get("provider") or None
|
||
_hyg_base_url = _model_cfg.get("base_url") or None
|
||
|
||
# Read compression settings — only use enabled flag.
|
||
# The threshold is intentionally separate from the agent's
|
||
# compression.threshold (hygiene runs higher).
|
||
_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
|
||
|
||
# Check custom_providers per-model context_length
|
||
# (same fallback as run_agent.py lines 1171-1189).
|
||
# Must run after runtime resolution so _hyg_base_url is set.
|
||
if _hyg_config_context_length is None and _hyg_base_url:
|
||
try:
|
||
_hyg_custom_providers = _hyg_data.get("custom_providers")
|
||
if isinstance(_hyg_custom_providers, list):
|
||
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)
|
||
|
||
# Prefer actual API-reported tokens from the last turn
|
||
# (stored in session entry) over the rough char-based estimate.
|
||
_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"
|
||
# Note: rough estimates overestimate by 30-50% for code/JSON-heavy
|
||
# sessions, but that just means hygiene fires a bit early — which
|
||
# is safe and harmless. The 85% threshold already provides ample
|
||
# headroom (agent's own compressor runs at 50%). A previous 1.4x
|
||
# multiplier tried to compensate by inflating the threshold, but
|
||
# 85% * 1.4 = 119% of context — which exceeds the model's limit
|
||
# and prevented hygiene from ever firing for ~200K models (GLM-5).
|
||
|
||
# Hard safety valve: force compression if message count is
|
||
# extreme, regardless of token estimates. This breaks the
|
||
# death spiral where API disconnects prevent token data
|
||
# collection, which prevents compression, which causes more
|
||
# disconnects. 400 messages is well above normal sessions
|
||
# but catches runaway growth before it becomes unrecoverable.
|
||
# (#2153)
|
||
_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,
|
||
),
|
||
)
|
||
|
||
# _compress_context ends the old session and creates
|
||
# a new session_id. Write compressed messages into
|
||
# the NEW session so the old transcript stays intact
|
||
# and searchable via session_search.
|
||
_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
|
||
)
|
||
# Reset stored token count — transcript was rewritten
|
||
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
|
||
)
|
||
|
||
# First-message onboarding -- only on the very first interaction ever
|
||
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.]"
|
||
)
|
||
|
||
# One-time prompt if no home channel is set for this platform
|
||
# Skip for webhooks - they deliver directly to configured targets (github_comment, etc.)
|
||
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."
|
||
)
|
||
|
||
# -----------------------------------------------------------------
|
||
# Voice channel awareness — inject current voice channel state
|
||
# into context so the agent knows who is in the channel and who
|
||
# is speaking, without needing a separate tool call.
|
||
# -----------------------------------------------------------------
|
||
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}"
|
||
|
||
# -----------------------------------------------------------------
|
||
# Auto-analyze images sent by the user
|
||
#
|
||
# If the user attached image(s), we run the vision tool eagerly so
|
||
# the conversation model always receives a text description. The
|
||
# local file path is also included so the model can re-examine the
|
||
# image later with a more targeted question via vision_analyze.
|
||
#
|
||
# We filter to image paths only (by media_type) so that non-image
|
||
# attachments (documents, audio, etc.) are not sent to the vision
|
||
# tool even when they appear in the same message.
|
||
# -----------------------------------------------------------------
|
||
message_text = await self._prepare_inbound_message_text(
|
||
event=event,
|
||
source=source,
|
||
history=history,
|
||
)
|
||
if message_text is None:
|
||
return
|
||
|
||
try:
|
||
# Emit agent:start hook
|
||
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)
|
||
|
||
# Run the agent
|
||
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,
|
||
)
|
||
|
||
# Stop persistent typing indicator now that the agent is done
|
||
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,
|
||
)
|
||
|
||
# Surface error details when the agent failed silently (final_response=None)
|
||
if not response and agent_result.get("failed"):
|
||
error_detail = agent_result.get("error", "unknown error")
|
||
error_str = str(error_detail).lower()
|
||
|
||
# Detect context-overflow failures and give specific guidance.
|
||
# Generic 400 "Error" from Anthropic with large sessions is the
|
||
# most common cause of this (#1630).
|
||
_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 the agent's session_id changed during compression, update
|
||
# session_entry so transcript writes below go to the right session.
|
||
if agent_result.get("session_id") and agent_result["session_id"] != session_entry.session_id:
|
||
session_entry.session_id = agent_result["session_id"]
|
||
|
||
# Prepend reasoning/thinking if display is enabled (per-platform)
|
||
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:
|
||
# Collapse long reasoning to keep messages readable
|
||
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}"
|
||
|
||
# Emit agent:end hook
|
||
await self.hooks.emit("agent:end", {
|
||
**hook_ctx,
|
||
"response": (response or "")[:500],
|
||
})
|
||
|
||
# Check for pending process watchers (check_interval on background processes)
|
||
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)
|
||
|
||
# Drain watch pattern notifications that arrived during the agent run.
|
||
# Watch events and completions share the same queue; completions are
|
||
# already handled by the per-process watcher task above, so we only
|
||
# inject watch-type events here.
|
||
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)
|
||
# else: completion events are handled by the watcher task
|
||
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)
|
||
|
||
# NOTE: Dangerous command approvals are now handled inline by the
|
||
# blocking gateway approval mechanism in tools/approval.py. The agent
|
||
# thread blocks until the user responds with /approve or /deny, so by
|
||
# the time we reach here the approval has already been resolved. The
|
||
# old post-loop pop_pending + approval_hint code was removed in favour
|
||
# of the blocking approach that mirrors CLI's synchronous input().
|
||
|
||
# Save the full conversation to the transcript, including tool calls.
|
||
# This preserves the complete agent loop (tool_calls, tool results,
|
||
# intermediate reasoning) so sessions can be resumed with full context
|
||
# and transcripts are useful for debugging and training data.
|
||
#
|
||
# IMPORTANT: When the agent failed before producing any response
|
||
# (e.g. context-overflow 400), do NOT persist the user's message.
|
||
# Persisting it would make the session even larger, causing the
|
||
# same failure on the next attempt — an infinite loop. (#1630)
|
||
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 this is a fresh session (no history), write the full tool
|
||
# definitions as the first entry so the transcript is self-describing
|
||
# -- the same list of dicts sent as tools=[...] in the API request.
|
||
if agent_failed_early:
|
||
pass # Skip all transcript writes — don't grow a broken session
|
||
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,
|
||
}
|
||
)
|
||
|
||
# Find only the NEW messages from this turn (skip history we loaded).
|
||
# Use the filtered history length (history_offset) that was actually
|
||
# passed to the agent, not len(history) which includes session_meta
|
||
# entries that were stripped before the agent saw them.
|
||
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 no new messages found (edge case), fall back to simple user/assistant
|
||
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:
|
||
# The agent already persisted these messages to SQLite via
|
||
# _flush_messages_to_session_db(), so skip the DB write here
|
||
# to prevent the duplicate-write bug (#860). We still write
|
||
# to JSONL for backward compatibility and as a backup.
|
||
agent_persisted = self._session_db is not None
|
||
for msg in new_messages:
|
||
# Skip system messages (they're rebuilt each run)
|
||
if msg.get("role") == "system":
|
||
continue
|
||
# Add timestamp to each message for debugging
|
||
entry = {**msg, "timestamp": ts}
|
||
self.session_store.append_to_transcript(
|
||
session_entry.session_id, entry,
|
||
skip_db=agent_persisted,
|
||
)
|
||
|
||
# Token counts and model are now persisted by the agent directly.
|
||
# Keep only last_prompt_tokens here for context-window tracking and
|
||
# compression decisions.
|
||
self.session_store.update_session(
|
||
session_entry.session_key,
|
||
last_prompt_tokens=agent_result.get("last_prompt_tokens", 0),
|
||
)
|
||
|
||
# Auto voice reply: send TTS audio before the text response
|
||
_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 streaming already delivered the response, extract and
|
||
# deliver any MEDIA: files before returning None. Streaming
|
||
# sends raw text chunks that include MEDIA: tags — the normal
|
||
# post-processing in _process_message_background is skipped
|
||
# when already_sent is True, so media files would never be
|
||
# delivered without this.
|
||
#
|
||
# Never skip when the agent failed — the error message is new
|
||
# content the user hasn't seen (streaming only sent earlier
|
||
# partial output before the failure). Without this guard,
|
||
# users see the agent "stop responding without explanation."
|
||
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:
|
||
# Stop typing indicator on error too
|
||
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:
|
||
# Check if this is a plan usage limit (resets on a schedule) vs a transient rate limit
|
||
_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):
|
||
# 400 with a large session is context overflow.
|
||
# 500 with a large session often means the payload is too large
|
||
# for the API to process — treat it the same way.
|
||
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:
|
||
# Restore session context variables to their pre-handler state
|
||
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
|
||
|
||
# Resolve runtime credentials for probing
|
||
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 "",
|
||
)
|
||
|
||
# Format context source hint
|
||
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"
|
||
|
||
# Format context length for display
|
||
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})",
|
||
]
|
||
|
||
# Show endpoint for local/custom setups
|
||
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
|
||
|
||
# Get existing session key
|
||
session_key = self._session_key_for_source(source)
|
||
|
||
# Flush memories in the background (fire-and-forget) so the user
|
||
# gets the "Session reset!" response immediately.
|
||
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)
|
||
# Close tool resources on the old agent (terminal sandboxes, browser
|
||
# daemons, background processes) before evicting from cache.
|
||
# Guard with getattr because test fixtures may skip __init__.
|
||
_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
|
||
|
||
# Reset the session
|
||
new_entry = self.session_store.reset_session(session_key)
|
||
|
||
# Clear any session-scoped model override so the next agent picks up
|
||
# the configured default instead of the previously switched model.
|
||
self._session_model_overrides.pop(session_key, None)
|
||
|
||
# Fire plugin on_session_finalize hook (session boundary)
|
||
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
|
||
|
||
# Emit session:end hook (session is ending)
|
||
await self.hooks.emit("session:end", {
|
||
"platform": source.platform.value if source.platform else "",
|
||
"user_id": source.user_id,
|
||
"session_key": session_key,
|
||
})
|
||
|
||
# Emit session:reset hook
|
||
await self.hooks.emit("session:reset", {
|
||
"platform": source.platform.value if source.platform else "",
|
||
"user_id": source.user_id,
|
||
"session_key": session_key,
|
||
})
|
||
|
||
# Resolve session config info to surface to the user
|
||
try:
|
||
session_info = self._format_session_info()
|
||
except Exception:
|
||
session_info = ""
|
||
|
||
if new_entry:
|
||
header = "✨ Session reset! Starting fresh."
|
||
else:
|
||
# No existing session, just create one
|
||
new_entry = self.session_store.get_or_create_session(source, force_new=True)
|
||
header = "✨ New session started!"
|
||
|
||
# Fire plugin on_session_reset hook (new session guaranteed to exist)
|
||
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
|
||
|
||
# Append a random tip to the reset message
|
||
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()
|
||
|
||
# Detect profile name from HERMES_HOME path
|
||
# Profile paths look like: ~/.hermes/profiles/<name>
|
||
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()]
|
||
|
||
# Check if there's an active agent
|
||
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:
|
||
# Force-clean the sentinel so the session is unlocked.
|
||
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")
|
||
# Force-clean the session lock so a truly hung agent doesn't
|
||
# keep it locked forever.
|
||
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..."
|
||
|
||
active_agents = self._running_agent_count()
|
||
self.request_restart(detached=True, via_service=False)
|
||
if active_agents:
|
||
return f"⏳ Draining {active_agents} active agent(s) before restart..."
|
||
return "♻ Restarting gateway..."
|
||
|
||
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):")
|
||
# Show first 10, then point to /commands for the rest
|
||
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
|
||
|
||
# Build combined entry list: built-in commands + skill commands
|
||
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()
|
||
|
||
# Parse --provider and --global flags
|
||
model_input, explicit_provider, persist_global = parse_model_flags(raw_args)
|
||
|
||
# Read current model/provider from config
|
||
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")
|
||
custom_provs = cfg.get("custom_providers")
|
||
except Exception:
|
||
pass
|
||
|
||
# Check for session override
|
||
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)
|
||
|
||
# No args: show interactive picker (Telegram/Discord) or text list
|
||
if not model_input and not explicit_provider:
|
||
# Try interactive picker if the platform supports it
|
||
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:
|
||
# Build a callback closure for when the user picks a model.
|
||
# Captures self + locals needed for the switch logic.
|
||
_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}"
|
||
|
||
# Update cached agent in-place
|
||
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)
|
||
|
||
# Store model note + session override
|
||
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,
|
||
}
|
||
|
||
# Build confirmation text
|
||
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 # Picker sent — adapter handles the response
|
||
|
||
# Fallback: text list (for platforms without picker or if picker failed)
|
||
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)
|
||
|
||
# Perform the switch
|
||
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}"
|
||
|
||
# If there's a cached agent, update it in-place
|
||
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)
|
||
|
||
# Store a note to prepend to the next user message so the model
|
||
# knows about the switch (avoids system messages mid-history).
|
||
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.]"
|
||
)
|
||
|
||
# Store session override so next agent creation uses the new model
|
||
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,
|
||
}
|
||
|
||
# Persist to config if --global
|
||
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)
|
||
|
||
# Build confirmation message with full metadata
|
||
provider_label = result.provider_label or result.target_provider
|
||
lines = [f"Model switched to `{result.new_model}`"]
|
||
lines.append(f"Provider: {provider_label}")
|
||
|
||
# Rich metadata from models.dev
|
||
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 notice
|
||
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,
|
||
)
|
||
|
||
# Resolve current provider from config
|
||
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"
|
||
|
||
# Detect custom endpoint from config base_url
|
||
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])
|
||
|
||
# Write to config.yaml, same pattern as CLI save_config_value.
|
||
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}"
|
||
|
||
# Update in-memory so it takes effect on the very next message.
|
||
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)
|
||
|
||
# Find the last user message
|
||
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."
|
||
|
||
# Truncate history to before the last user message and persist
|
||
truncated = history[:last_user_idx]
|
||
self.session_store.rewrite_transcript(session_entry.session_id, truncated)
|
||
# Reset stored token count — transcript was truncated
|
||
session_entry.last_prompt_tokens = 0
|
||
|
||
# Re-send by creating a fake text event with the old message
|
||
retry_event = MessageEvent(
|
||
text=last_user_msg,
|
||
message_type=MessageType.TEXT,
|
||
source=source,
|
||
raw_message=event.raw_message,
|
||
)
|
||
|
||
# Let the normal message handler process it
|
||
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)
|
||
|
||
# Find the last user message and remove everything from it onward
|
||
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])
|
||
# Reset stored token count — transcript was truncated
|
||
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"
|
||
|
||
# Save to config.yaml
|
||
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)
|
||
# Also set in the current environment so it takes effect immediately
|
||
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
|
||
# Slash command interaction
|
||
if hasattr(raw, "guild_id") and raw.guild_id:
|
||
return int(raw.guild_id)
|
||
# Regular message
|
||
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)",
|
||
}
|
||
# Append voice channel info if connected
|
||
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:
|
||
# Toggle: off → on, on/all → off
|
||
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."
|
||
|
||
# Wire callbacks BEFORE join so voice input arriving immediately
|
||
# after connection is not lost.
|
||
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)
|
||
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."
|
||
)
|
||
# Join failed — clear callback
|
||
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)
|
||
# Always clean up state even if leave raised an exception
|
||
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
|
||
|
||
# Check authorization before processing voice input
|
||
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
|
||
|
||
# Show transcript in text channel (after auth, with mention sanitization)
|
||
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
|
||
|
||
# Build a synthetic MessageEvent and feed through the normal pipeline
|
||
# Use SimpleNamespace as raw_message so _get_guild_id() can extract
|
||
# guild_id and _send_voice_reply() plays audio in the voice channel.
|
||
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
|
||
|
||
# Dedup: agent already called TTS tool
|
||
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
|
||
|
||
# Dedup: base adapter auto-TTS already handles voice input
|
||
# (play_tts plays in VC when connected, so runner can skip).
|
||
# When streaming already delivered the text (already_sent=True),
|
||
# the base adapter will receive None and can't run auto-TTS,
|
||
# so the runner must take over.
|
||
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
|
||
|
||
# Use .mp3 extension so edge-tts conversion to opus works correctly.
|
||
# The TTS tool may convert to .ogg — use file_path from result.
|
||
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)
|
||
|
||
# Use the actual file path from result (may differ after opus conversion)
|
||
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)
|
||
|
||
# If connected to a voice channel, play there instead of sending a file
|
||
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
|
||
|
||
# Read checkpoint config from config.yaml
|
||
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)
|
||
|
||
# Restore by number or hash
|
||
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()}"
|
||
|
||
# Fire-and-forget the background task
|
||
_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']}"
|
||
|
||
# Extract media files from the response
|
||
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,
|
||
)
|
||
|
||
# Send extracted images
|
||
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
|
||
|
||
# Send media files
|
||
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)
|
||
|
||
# Guard: one /btw at a time per session
|
||
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
|
||
|
||
# Snapshot history from running agent or stored transcript
|
||
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:
|
||
# Show current state
|
||
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>`"
|
||
)
|
||
|
||
# Display toggle (per-platform)
|
||
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 level change
|
||
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)
|
||
|
||
# --- check config gate ------------------------------------------------
|
||
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 mode (per-platform) ----------------------------------------
|
||
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.",
|
||
}
|
||
|
||
# Read current effective mode for this platform via the resolver
|
||
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]
|
||
|
||
# Save to display.platforms.<platform>.tool_progress
|
||
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)."
|
||
|
||
# Extract optional focus topic from command args
|
||
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)
|
||
)
|
||
|
||
# _compress_context already calls end_session() on the old session
|
||
# (preserving its full transcript in SQLite) and creates a new
|
||
# session_id for the continuation. Write the compressed messages
|
||
# into the NEW session so the original history stays searchable.
|
||
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)
|
||
# Reset stored token count — transcript changed, old value is stale
|
||
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."
|
||
|
||
# Ensure session exists in SQLite DB (it may only exist in session_store
|
||
# if this is the first command in a new session)
|
||
existing_title = self._session_db.get_session_title(session_id)
|
||
if existing_title is None:
|
||
# Session doesn't exist in DB yet — create it
|
||
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 # Session might already exist, ignore errors
|
||
|
||
title_arg = event.get_command_args().strip()
|
||
if title_arg:
|
||
# Sanitize the title before setting
|
||
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."
|
||
# Set the title
|
||
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:
|
||
# Show the current title and session ID
|
||
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:
|
||
# List recent titled sessions for this user/platform
|
||
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}"
|
||
|
||
# Resolve the name to a session ID
|
||
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."
|
||
)
|
||
|
||
# Check if already on that session
|
||
current_entry = self.session_store.get_or_create_session(source)
|
||
if current_entry.session_id == target_id:
|
||
return f"📌 Already on session **{name}**."
|
||
|
||
# Flush memories for current session before switching
|
||
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)
|
||
|
||
# Clear any running agent for this session key
|
||
if session_key in self._running_agents:
|
||
del self._running_agents[session_key]
|
||
|
||
# Switch the session entry to point at the old session
|
||
new_entry = self.session_store.switch_session(session_key, target_id)
|
||
if not new_entry:
|
||
return "Failed to switch session."
|
||
|
||
# Get the title for confirmation
|
||
title = self._session_db.get_session_title(target_id) or name
|
||
|
||
# Count messages for context
|
||
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)
|
||
|
||
# Load the current session and its transcript
|
||
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()
|
||
|
||
# Generate the new session ID
|
||
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}"
|
||
|
||
# Determine branch title
|
||
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
|
||
|
||
# Create the new session with parent link
|
||
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}"
|
||
|
||
# Copy conversation history to the new session
|
||
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 # Best-effort copy
|
||
|
||
# Set title
|
||
try:
|
||
self._session_db.set_session_title(new_session_id, branch_title)
|
||
except Exception:
|
||
pass
|
||
|
||
# Switch the session store entry to the new session
|
||
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."
|
||
|
||
# Evict any cached agent for this session
|
||
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)
|
||
|
||
# Try running agent first (mid-turn), then cached agent (between turns)
|
||
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 = []
|
||
|
||
# Rate limits (when available from provider headers)
|
||
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("")
|
||
|
||
# Session token usage — detailed breakdown matching CLI
|
||
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}")
|
||
|
||
# Cost estimation
|
||
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
|
||
|
||
# Context window and compressions
|
||
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)
|
||
|
||
# No agent at all -- check session history for a rough count
|
||
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
|
||
|
||
# Parse simple args: /insights 7 or /insights --days 7
|
||
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
|
||
|
||
# Capture old server names before shutdown
|
||
with _lock:
|
||
old_servers = set(_servers.keys())
|
||
|
||
# Read new config before shutting down, so we know what will be added/removed
|
||
# Shutdown existing connections
|
||
await loop.run_in_executor(None, shutdown_mcp_servers)
|
||
|
||
# Reconnect by discovering tools (reads config.yaml fresh)
|
||
new_tools = await loop.run_in_executor(None, discover_mcp_tools)
|
||
|
||
# Compute what changed
|
||
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)")
|
||
|
||
# Inject a message at the END of the session history so the
|
||
# model knows tools changed on its next turn. Appended after
|
||
# all existing messages to preserve prompt-cache for the prefix.
|
||
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 # Best-effort; don't fail the reload over a transcript write
|
||
|
||
return "\n".join(lines)
|
||
|
||
except Exception as e:
|
||
logger.warning("MCP reload failed: %s", e)
|
||
return f"❌ MCP reload failed: {e}"
|
||
|
||
# ------------------------------------------------------------------
|
||
# /approve & /deny — explicit dangerous-command approval
|
||
# ------------------------------------------------------------------
|
||
|
||
_APPROVAL_TIMEOUT_SECONDS = 300 # 5 minutes
|
||
|
||
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."
|
||
|
||
# Parse args: support "all", "all session", "all always", "session", "always"
|
||
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."
|
||
|
||
# Resume typing indicator — agent is about to continue processing.
|
||
_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."
|
||
|
||
# Resume typing indicator — agent continues (with BLOCKED result).
|
||
_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}."
|
||
|
||
# Platforms where /update is allowed. ACP, API server, and webhooks are
|
||
# programmatic interfaces that should not trigger system updates.
|
||
_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_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
|
||
|
||
# Block non-messaging platforms (API server, webhooks, ACP)
|
||
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)
|
||
|
||
# Spawn `hermes update --gateway` detached so it survives gateway restart.
|
||
# --gateway enables file-based IPC for interactive prompts (stash
|
||
# restore, config migration) so the gateway can forward them to the
|
||
# user instead of silently skipping them.
|
||
# Use setsid for portable session detach (works under system services
|
||
# where systemd-run --user fails due to missing D-Bus session).
|
||
# PYTHONUNBUFFERED ensures output is flushed line-by-line so the
|
||
# gateway can stream it to the messenger in near-real-time.
|
||
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:
|
||
# Preferred: setsid creates a new session, fully detached
|
||
subprocess.Popen(
|
||
[setsid_bin, "bash", "-c", update_cmd],
|
||
stdout=subprocess.DEVNULL,
|
||
stderr=subprocess.DEVNULL,
|
||
start_new_session=True,
|
||
)
|
||
else:
|
||
# Fallback: start_new_session=True calls os.setsid() in child
|
||
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
|
||
|
||
# Resolve the adapter and chat_id for sending messages
|
||
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)
|
||
# Fallback session key if not stored (old pending files)
|
||
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")
|
||
# Fall back to old behavior: wait for exit code and send final notification
|
||
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
|
||
# Chunk to fit message limits (Telegram: 4096, others: generous)
|
||
clean = _strip_ansi(buffer).strip()
|
||
buffer = ""
|
||
last_stream_time = loop.time()
|
||
if not clean:
|
||
return
|
||
# Split into chunks if too long
|
||
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:
|
||
# Check for completion
|
||
if exit_code_path.exists():
|
||
# Read any remaining output
|
||
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()
|
||
|
||
# Send final status
|
||
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)
|
||
|
||
# Cleanup
|
||
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
|
||
|
||
# Check for new output
|
||
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
|
||
|
||
# Flush buffer periodically
|
||
if buffer.strip() and (loop.time() - last_stream_time) >= stream_interval:
|
||
await _flush_buffer()
|
||
|
||
# Check for prompts
|
||
if prompt_path.exists() and 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:
|
||
# Flush any buffered output first so the user sees
|
||
# context before the prompt
|
||
await _flush_buffer()
|
||
# Try platform-native buttons first (Discord, Telegram)
|
||
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
|
||
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)
|
||
|
||
# Timeout
|
||
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)
|
||
|
||
# Read the captured update output
|
||
output = ""
|
||
if output_path.exists():
|
||
output = output_path.read_text()
|
||
|
||
# Resolve adapter
|
||
platform = Platform(platform_str)
|
||
adapter = self.adapters.get(platform)
|
||
|
||
if adapter and chat_id:
|
||
# Strip ANSI escape codes for clean display
|
||
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
|
||
|
||
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}]"
|
||
)
|
||
|
||
# Combine: vision descriptions first, then the user's original text
|
||
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)
|
||
# Strip the empty-content placeholder from the Discord adapter
|
||
# when we successfully transcribed the audio — it's redundant.
|
||
_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:
|
||
# Still wait for the process to exit so we can log it, but don't
|
||
# push any messages to the user.
|
||
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:
|
||
# --- Agent-triggered completion: inject synthetic message ---
|
||
# Skip if the agent already consumed the result via wait/poll/log
|
||
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
|
||
|
||
# --- Normal text-only notification ---
|
||
# Decide whether to notify based on mode
|
||
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 available -- deliver status update (only in "all" mode)
|
||
# Skip periodic updates for agent_notify watchers (they only care about completion)
|
||
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 # Cap recursive interrupt handling (#816)
|
||
|
||
@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
|
||
|
||
# Fingerprint the FULL credential string instead of using a short
|
||
# prefix. OAuth/JWT-style tokens frequently share a common prefix
|
||
# (e.g. "eyJhbGci"), which can cause false cache hits across auth
|
||
# switches if only the first few characters are considered.
|
||
_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 [],
|
||
# reasoning_config excluded — it's set per-message on the
|
||
# cached agent and doesn't affect system prompt or tools.
|
||
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 = {}
|
||
|
||
# Per-platform display settings — resolve via display_config module
|
||
# which checks display.platforms.<platform>.<key> first, then
|
||
# display.<key> global, then built-in platform defaults.
|
||
from gateway.display_config import resolve_display_setting
|
||
|
||
# Apply tool preview length config (0 = no limit)
|
||
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
|
||
|
||
# Tool progress mode — resolved per-platform with env var fallback
|
||
_resolved_tp = resolve_display_setting(user_config, platform_key, "tool_progress")
|
||
progress_mode = (
|
||
_resolved_tp
|
||
or os.getenv("HERMES_TOOL_PROGRESS_MODE")
|
||
or "all"
|
||
)
|
||
# Disable tool progress for webhooks - they don't support message editing,
|
||
# so each progress line would be sent as a separate message.
|
||
from gateway.config import Platform
|
||
tool_progress_enabled = progress_mode != "off" and source.platform != Platform.WEBHOOK
|
||
# Natural assistant status messages are intentionally independent from
|
||
# tool progress and token streaming. Users can keep tool_progress quiet
|
||
# in chat platforms while opting into concise mid-turn updates.
|
||
interim_assistant_messages_enabled = (
|
||
source.platform != Platform.WEBHOOK
|
||
and is_truthy_value(
|
||
display_config.get("interim_assistant_messages"),
|
||
default=True,
|
||
)
|
||
)
|
||
|
||
# Queue for progress messages (thread-safe)
|
||
progress_queue = queue.Queue() if tool_progress_enabled else None
|
||
last_tool = [None] # Mutable container for tracking in closure
|
||
last_progress_msg = [None] # Track last message for dedup
|
||
repeat_count = [0] # How many times the same message repeated
|
||
|
||
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
|
||
|
||
# Only act on tool.started events (ignore tool.completed, reasoning.available, etc.)
|
||
if event_type not in ("tool.started",):
|
||
return
|
||
|
||
# "new" mode: only report when tool changes
|
||
if progress_mode == "new" and tool_name == last_tool[0]:
|
||
return
|
||
last_tool[0] = tool_name
|
||
|
||
# Build progress message with primary argument preview
|
||
from agent.display import get_tool_emoji
|
||
emoji = get_tool_emoji(tool_name, default="⚙️")
|
||
|
||
# Verbose mode: show detailed arguments, respects tool_preview_length
|
||
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)
|
||
_cap = _pl if _pl > 0 else 200
|
||
if len(args_str) > _cap:
|
||
args_str = args_str[:_cap - 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
|
||
|
||
# "all" / "new" modes: short preview, respects tool_preview_length
|
||
# config (defaults to 40 chars when unset to keep gateway messages
|
||
# compact — unlike CLI spinners, these persist as permanent messages).
|
||
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}..."
|
||
|
||
# Dedup: collapse consecutive identical progress messages.
|
||
# Common with execute_code where models iterate with the same
|
||
# code (same boilerplate imports → identical previews).
|
||
if msg == last_progress_msg[0]:
|
||
repeat_count[0] += 1
|
||
# Update the last line in progress_lines with a counter
|
||
# via a special "dedup" queue message.
|
||
progress_queue.put(("__dedup__", msg, repeat_count[0]))
|
||
return
|
||
last_progress_msg[0] = msg
|
||
repeat_count[0] = 0
|
||
|
||
progress_queue.put(msg)
|
||
|
||
# Background task to send progress messages
|
||
# Accumulates tool lines into a single message that gets edited.
|
||
#
|
||
# Threading metadata is platform-specific:
|
||
# - Slack DM threading needs event_message_id fallback (reply thread)
|
||
# - Telegram uses message_thread_id only for forum topics; passing a
|
||
# normal DM/group message id as thread_id causes send failures
|
||
# - Other platforms should use explicit source.thread_id only
|
||
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
|
||
|
||
# Skip tool progress for platforms that don't support message
|
||
# editing (e.g. iMessage/BlueBubbles) — each progress update
|
||
# would become a separate message bubble, which is noisy.
|
||
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 = [] # Accumulated tool lines
|
||
progress_msg_id = None # ID of the progress message to edit
|
||
can_edit = True # False once an edit fails (platform doesn't support it)
|
||
_last_edit_ts = 0.0 # Throttle edits to avoid Telegram flood control
|
||
_PROGRESS_EDIT_INTERVAL = 1.5 # Minimum seconds between edits
|
||
|
||
while True:
|
||
try:
|
||
raw = progress_queue.get_nowait()
|
||
|
||
# Handle dedup messages: update last line with repeat counter
|
||
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)
|
||
|
||
# Throttle edits: batch rapid tool updates into fewer
|
||
# API calls to avoid hitting Telegram flood control.
|
||
# (grammY auto-retry pattern: proactively rate-limit
|
||
# instead of reacting to 429s.)
|
||
_now = time.monotonic()
|
||
_remaining = _PROGRESS_EDIT_INTERVAL - (_now - _last_edit_ts)
|
||
if _remaining > 0:
|
||
# Wait out the throttle interval, then loop back to
|
||
# drain any additional queued messages before sending
|
||
# a single batched edit.
|
||
await asyncio.sleep(_remaining)
|
||
continue
|
||
|
||
if can_edit and progress_msg_id is not None:
|
||
# Try to edit the existing progress message
|
||
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:
|
||
# Flood control hit — disable further edits,
|
||
# switch to sending new messages only for
|
||
# important updates. Don't block 23s.
|
||
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:
|
||
# First tool: send all accumulated text as new message
|
||
full_text = "\n".join(progress_lines)
|
||
result = await adapter.send(chat_id=source.chat_id, content=full_text, metadata=_progress_metadata)
|
||
else:
|
||
# Editing unsupported: send just this line
|
||
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()
|
||
|
||
# Restore typing indicator
|
||
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:
|
||
# Drain remaining queued messages
|
||
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
|
||
# Final edit with all remaining tools (only if editing works)
|
||
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)
|
||
|
||
# We need to share the agent instance for interrupt support
|
||
agent_holder = [None] # Mutable container for the agent instance
|
||
result_holder = [None] # Mutable container for the result
|
||
tools_holder = [None] # Mutable container for the tool definitions
|
||
stream_consumer_holder = [None] # Mutable container for stream consumer
|
||
|
||
# Bridge sync step_callback → async hooks.emit for agent:step events
|
||
_loop_for_step = asyncio.get_event_loop()
|
||
_hooks_ref = self.hooks
|
||
|
||
def _step_callback_sync(iteration: int, prev_tools: list) -> None:
|
||
try:
|
||
# prev_tools may be list[str] or list[dict] with "name"/"result"
|
||
# keys. Normalise to keep "tool_names" backward-compatible for
|
||
# user-authored hooks that do ', '.join(tool_names)'.
|
||
_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)
|
||
|
||
# Bridge sync status_callback → async adapter.send for context pressure
|
||
_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():
|
||
# The conditional re-assignment of `message` further below
|
||
# (prepending model-switch notes) makes Python treat it as a
|
||
# local variable in the entire function. `nonlocal` lets us
|
||
# read *and* reassign the outer `_run_agent` parameter without
|
||
# triggering an UnboundLocalError on the earlier read at
|
||
# `_resolve_turn_agent_config(message, …)`.
|
||
nonlocal message
|
||
|
||
# session_key is now set via contextvars in _set_session_env()
|
||
# (concurrency-safe). Keep os.environ as fallback for CLI/cron.
|
||
os.environ["HERMES_SESSION_KEY"] = session_key or ""
|
||
|
||
# Read from env var or use default (same as CLI)
|
||
max_iterations = int(os.getenv("HERMES_MAX_ITERATIONS", "90"))
|
||
|
||
# Map platform enum to the platform hint key the agent understands.
|
||
# Platform.LOCAL ("local") maps to "cli"; others pass through as-is.
|
||
platform_key = "cli" if source.platform == Platform.LOCAL else source.platform.value
|
||
|
||
# Combine platform context with user-configured ephemeral system prompt
|
||
combined_ephemeral = context_prompt or ""
|
||
if self._ephemeral_system_prompt:
|
||
combined_ephemeral = (combined_ephemeral + "\n\n" + self._ephemeral_system_prompt).strip()
|
||
|
||
# Re-read .env and config for fresh credentials (gateway is long-lived,
|
||
# keys may change without restart).
|
||
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,
|
||
)
|
||
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()
|
||
# Set up stream consumer for token streaming or interim commentary.
|
||
_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()
|
||
|
||
# Per-platform streaming gate: display.platforms.<plat>.streaming
|
||
# can disable streaming for specific platforms even when the global
|
||
# streaming config is enabled.
|
||
_plat_streaming = resolve_display_setting(
|
||
user_config, platform_key, "streaming"
|
||
)
|
||
# None = no per-platform override → follow global config
|
||
_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:
|
||
_consumer_cfg = StreamConsumerConfig(
|
||
edit_interval=_scfg.edit_interval,
|
||
buffer_threshold=_scfg.buffer_threshold,
|
||
cursor=_scfg.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)
|
||
|
||
# Check agent cache — reuse the AIAgent from the previous message
|
||
# in this session to preserve the frozen system prompt and tool
|
||
# schemas for prompt cache hits.
|
||
_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:
|
||
# Config changed or first message — create fresh agent
|
||
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)
|
||
|
||
# Per-message state — callbacks and reasoning config change every
|
||
# turn and must not be baked into the cached agent constructor.
|
||
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")
|
||
|
||
# Background review delivery — send "💾 Memory updated" etc. to user
|
||
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
|
||
|
||
# Store agent reference for interrupt support
|
||
agent_holder[0] = agent
|
||
# Capture the full tool definitions for transcript logging
|
||
tools_holder[0] = agent.tools if hasattr(agent, 'tools') else None
|
||
|
||
# Convert history to agent format.
|
||
# Two cases:
|
||
# 1. Normal path (from transcript): simple {role, content, timestamp} dicts
|
||
# - Strip timestamps, keep role+content
|
||
# 2. Interrupt path (from agent result["messages"]): full agent messages
|
||
# that may include tool_calls, tool_call_id, reasoning, etc.
|
||
# - These must be passed through intact so the API sees valid
|
||
# assistant→tool sequences (dropping tool_calls causes 500 errors)
|
||
agent_history = []
|
||
for msg in history:
|
||
role = msg.get("role")
|
||
if not role:
|
||
continue
|
||
|
||
# Skip metadata entries (tool definitions, session info)
|
||
# -- these are for transcript logging, not for the LLM
|
||
if role in ("session_meta",):
|
||
continue
|
||
|
||
# Skip system messages -- the agent rebuilds its own system prompt
|
||
if role == "system":
|
||
continue
|
||
|
||
# Rich agent messages (tool_calls, tool results) must be passed
|
||
# through intact so the API sees valid assistant→tool sequences
|
||
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:
|
||
# Simple text message - just need role and content
|
||
content = msg.get("content")
|
||
if content:
|
||
# Tag cross-platform mirror messages so the agent knows their origin
|
||
if msg.get("mirror"):
|
||
mirror_src = msg.get("mirror_source", "another session")
|
||
content = f"[Delivered from {mirror_src}] {content}"
|
||
entry = {"role": role, "content": content}
|
||
# Preserve reasoning fields on assistant messages so
|
||
# multi-turn reasoning context survives session reload.
|
||
# The agent's _build_api_kwargs converts these to the
|
||
# provider-specific format (reasoning_content, etc.).
|
||
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)
|
||
|
||
# Collect MEDIA paths already in history so we can exclude them
|
||
# from the current turn's extraction. This is compression-safe:
|
||
# even if the message list shrinks, we know which paths are old.
|
||
_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)
|
||
|
||
# Register per-session gateway approval callback so dangerous
|
||
# command approval blocks the agent thread (mirrors CLI input()).
|
||
# The callback bridges sync→async to send the approval request
|
||
# to the user immediately.
|
||
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.
|
||
"""
|
||
# Pause the typing indicator while the agent waits for
|
||
# user approval. Critical for Slack's Assistant API where
|
||
# assistant_threads_setStatus disables the compose box — the
|
||
# user literally cannot type /approve while "is thinking..."
|
||
# is active. The approval message send auto-clears the Slack
|
||
# status; pausing prevents _keep_typing from re-setting it.
|
||
# Typing resumes in _handle_approve_command/_handle_deny_command.
|
||
_status_adapter.pause_typing_for_chat(_status_chat_id)
|
||
|
||
cmd = approval_data.get("command", "")
|
||
desc = approval_data.get("description", "dangerous command")
|
||
|
||
# Prefer button-based approval when the adapter supports it.
|
||
# Check the *class* for the method, not the instance — avoids
|
||
# false positives from MagicMock auto-attribute creation in tests.
|
||
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
|
||
)
|
||
|
||
# Fallback: plain text approval prompt
|
||
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)
|
||
|
||
# Prepend pending model switch note so the model knows about the switch
|
||
_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
|
||
|
||
# Signal the stream consumer that the agent is done
|
||
if _stream_consumer is not None:
|
||
_stream_consumer.finish()
|
||
|
||
# Return final response, or a message if something went wrong
|
||
final_response = result.get("final_response")
|
||
|
||
# Extract actual token counts from the agent instance used for this run
|
||
_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,
|
||
}
|
||
|
||
# Scan tool results for MEDIA:<path> tags that need to be delivered
|
||
# as native audio/file attachments. The TTS tool embeds MEDIA: tags
|
||
# in its JSON response, but the model's final text reply usually
|
||
# doesn't include them. We collect unique tags from tool results and
|
||
# append any that aren't already present in the final response, so the
|
||
# adapter's extract_media() can find and deliver the files exactly once.
|
||
#
|
||
# Uses path-based deduplication against _history_media_paths (collected
|
||
# before run_conversation) instead of index slicing. This is safe even
|
||
# when context compression shrinks the message list. (Fixes #160)
|
||
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)
|
||
|
||
# Sync session_id: the agent may have created a new session during
|
||
# mid-run context compression (_compress_context splits sessions).
|
||
# If so, update the session store entry so the NEXT message loads
|
||
# the compressed transcript, not the stale pre-compression one.
|
||
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
|
||
|
||
# When compression created a new session, the messages list was
|
||
# shortened. Using the original history offset would produce an
|
||
# empty new_messages slice, causing the gateway to write only a
|
||
# user/assistant pair — losing the compressed summary and tail.
|
||
# Reset to 0 so the gateway writes ALL compressed messages.
|
||
_effective_history_offset = 0 if _session_was_split else len(agent_history)
|
||
|
||
# Auto-generate session title after first exchange (non-blocking)
|
||
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),
|
||
}
|
||
|
||
# Start progress message sender if enabled
|
||
progress_task = None
|
||
if tool_progress_enabled:
|
||
progress_task = asyncio.create_task(send_progress_messages())
|
||
|
||
# Start stream consumer task — polls for consumer creation since it
|
||
# happens inside run_sync (thread pool) after the agent is constructed.
|
||
stream_task = None
|
||
|
||
async def _start_stream_consumer():
|
||
"""Wait for the stream consumer to be created, then run it."""
|
||
for _ in range(200): # Up to 10s wait
|
||
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())
|
||
|
||
# Track this agent as running for this session (for interrupt support)
|
||
# We do this in a callback after the agent is created
|
||
async def track_agent():
|
||
# Wait for agent to be created
|
||
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())
|
||
|
||
# Monitor for interrupts from the adapter (new messages arriving).
|
||
# This is the PRIMARY interrupt path for regular text messages —
|
||
# Level 1 (base.py) catches them before _handle_message() is reached,
|
||
# so the Level 2 running_agent.interrupt() path never fires.
|
||
# The inactivity poll loop below has a BACKUP check in case this
|
||
# task dies (no error handling = silent death = lost interrupts).
|
||
_interrupt_detected = asyncio.Event() # shared with backup check
|
||
|
||
async def monitor_for_interrupt():
|
||
if not session_key:
|
||
return
|
||
|
||
while True:
|
||
await asyncio.sleep(0.2) # Check every 200ms
|
||
try:
|
||
# Re-resolve adapter each iteration so reconnects don't
|
||
# leave us holding a stale reference.
|
||
_adapter = self.adapters.get(source.platform)
|
||
if not _adapter:
|
||
continue
|
||
# Check if adapter has a pending interrupt for this session.
|
||
# Must use session_key (build_session_key output) — NOT
|
||
# source.chat_id — because the adapter stores interrupt events
|
||
# under the full session key.
|
||
if hasattr(_adapter, 'has_pending_interrupt') and _adapter.has_pending_interrupt(session_key):
|
||
agent = agent_holder[0]
|
||
if agent:
|
||
# Peek at the pending message text WITHOUT consuming it.
|
||
# The message must remain in _pending_messages so the
|
||
# post-run dequeue at _dequeue_pending_event() can
|
||
# retrieve the full MessageEvent (with media metadata).
|
||
# If we pop here, a race exists: the agent may finish
|
||
# before checking _interrupt_requested, and the message
|
||
# is lost — neither the interrupt path nor the dequeue
|
||
# path finds it.
|
||
_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())
|
||
|
||
# Periodic "still working" notifications for long-running tasks.
|
||
# Fires every 10 minutes so the user knows the agent hasn't died.
|
||
_NOTIFY_INTERVAL = 600 # 10 minutes
|
||
_notify_start = time.time()
|
||
|
||
async def _notify_long_running():
|
||
_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)
|
||
# Include agent activity context if available.
|
||
_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:
|
||
# Run in thread pool to not block. Use an *inactivity*-based
|
||
# timeout instead of a wall-clock limit: the agent can run for
|
||
# hours if it's actively calling tools / receiving stream tokens,
|
||
# but a hung API call or stuck tool with no activity for the
|
||
# configured duration is caught and killed. (#4815)
|
||
#
|
||
# Config: agent.gateway_timeout in config.yaml, or
|
||
# HERMES_AGENT_TIMEOUT env var (env var takes precedence).
|
||
# Default 1800s (30 min inactivity). 0 = unlimited.
|
||
_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:
|
||
# Unlimited — still poll periodically for backup interrupt
|
||
# detection in case monitor_for_interrupt() silently died.
|
||
response = None
|
||
while True:
|
||
done, _ = await asyncio.wait(
|
||
{_executor_task}, timeout=_POLL_INTERVAL
|
||
)
|
||
if done:
|
||
response = _executor_task.result()
|
||
break
|
||
# Backup interrupt check: if the monitor task died or
|
||
# missed the interrupt, catch it here.
|
||
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:
|
||
# Poll loop: check the agent's built-in activity tracker
|
||
# (updated by _touch_activity() on every tool call, API
|
||
# call, and stream delta) every few seconds.
|
||
response = None
|
||
while True:
|
||
done, _ = await asyncio.wait(
|
||
{_executor_task}, timeout=_POLL_INTERVAL
|
||
)
|
||
if done:
|
||
response = _executor_task.result()
|
||
break
|
||
# Agent still running — check inactivity.
|
||
_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
|
||
# Staged warning: fire once before escalating to full timeout.
|
||
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
|
||
# Backup interrupt check (same as unlimited path).
|
||
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:
|
||
# Build a diagnostic summary from the agent's activity tracker.
|
||
_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",
|
||
)
|
||
|
||
# Interrupt the agent if it's still running so the thread
|
||
# pool worker is freed.
|
||
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
|
||
|
||
# Construct a user-facing message with diagnostic context.
|
||
_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,
|
||
}
|
||
|
||
# Track fallback model state: if the agent switched to a
|
||
# fallback model during this run, persist it so /model shows
|
||
# the actually-active model instead of the config default.
|
||
# Skip eviction when the run failed — evicting a failed agent
|
||
# forces MCP reinit on the next message for no benefit (the
|
||
# same error will recur). This was the root cause of #7130:
|
||
# a bad model ID triggered fallback → eviction → recreation →
|
||
# MCP reinit → same 400 → loop, burning 91% CPU for hours.
|
||
_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):
|
||
# Fallback activated on a successful run — evict cached
|
||
# agent so the next message retries the primary model.
|
||
self._evict_cached_agent(session_key)
|
||
|
||
# Check if we were interrupted OR have a queued message (/queue).
|
||
result = result_holder[0]
|
||
adapter = self.adapters.get(source.platform)
|
||
|
||
# Get pending message from adapter.
|
||
# Use session_key (not source.chat_id) to match adapter's storage keys.
|
||
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])
|
||
|
||
# Safety net: if the pending text is a slash command (e.g. "/stop",
|
||
# "/new"), discard it — commands should never be passed to the agent
|
||
# as user input. The primary fix is in base.py (commands bypass the
|
||
# active-session guard), but this catches edge cases where command
|
||
# text leaks through the interrupt_message fallback.
|
||
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])
|
||
|
||
# Clear the adapter's interrupt event so the next _run_agent call
|
||
# doesn't immediately re-trigger the interrupt before the new agent
|
||
# even makes its first API call (this was causing an infinite loop).
|
||
if adapter and hasattr(adapter, '_active_sessions') and session_key and session_key in adapter._active_sessions:
|
||
adapter._active_sessions[session_key].clear()
|
||
|
||
# Cap recursion depth to prevent resource exhaustion when the
|
||
# user sends multiple messages while the agent keeps failing. (#816)
|
||
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:
|
||
# Queued message after normal completion — deliver the first
|
||
# response before processing the queued follow-up.
|
||
# Skip if streaming already delivered it.
|
||
_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)
|
||
# else: interrupted — discard the interrupted response ("Operation
|
||
# interrupted." is just noise; the user already knows they sent a
|
||
# new message).
|
||
|
||
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:
|
||
# Stop progress sender, interrupt monitor, and notification task
|
||
if progress_task:
|
||
progress_task.cancel()
|
||
interrupt_monitor.cancel()
|
||
_notify_task.cancel()
|
||
|
||
# Wait for stream consumer to finish its final edit
|
||
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
|
||
|
||
# Clean up tracking
|
||
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")
|
||
|
||
# Wait for cancelled tasks
|
||
for task in [progress_task, interrupt_monitor, tracking_task, _notify_task]:
|
||
if task:
|
||
try:
|
||
await task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
|
||
# If streaming already delivered the response, mark it so the
|
||
# caller's send() is skipped (avoiding duplicate messages).
|
||
# BUT: never suppress delivery when the agent failed — the error
|
||
# message is new content the user hasn't seen, and it must reach
|
||
# them even if streaming had sent earlier partial output.
|
||
_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 # ticks — once per hour at default 60s interval
|
||
CHANNEL_DIR_EVERY = 5 # ticks — every 5 minutes
|
||
|
||
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.
|
||
"""
|
||
# ── Duplicate-instance guard ──────────────────────────────────────
|
||
# Prevent two gateways from running under the same HERMES_HOME.
|
||
# The PID file is scoped to HERMES_HOME, so future multi-profile
|
||
# setups (each profile using a distinct HERMES_HOME) will naturally
|
||
# allow concurrent instances without tripping this guard.
|
||
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 # Already gone
|
||
except (PermissionError, OSError):
|
||
logger.error(
|
||
"Permission denied killing PID %d. Cannot replace.",
|
||
existing_pid,
|
||
)
|
||
return False
|
||
# Wait up to 10 seconds for the old process to exit
|
||
for _ in range(20):
|
||
try:
|
||
os.kill(existing_pid, 0)
|
||
_time.sleep(0.5)
|
||
except (ProcessLookupError, PermissionError):
|
||
break # Process is gone
|
||
else:
|
||
# Still alive after 10s — force kill
|
||
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()
|
||
# Also release all scoped locks left by the old process.
|
||
# Stopped (Ctrl+Z) processes don't release locks on exit,
|
||
# leaving stale lock files that block the new gateway from starting.
|
||
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
|
||
|
||
# Sync bundled skills on gateway start (fast -- skips unchanged)
|
||
try:
|
||
from tools.skills_sync import sync_skills
|
||
sync_skills(quiet=True)
|
||
except Exception:
|
||
pass
|
||
|
||
# Centralized logging — agent.log (INFO+), errors.log (WARNING+),
|
||
# and gateway.log (INFO+, gateway-component records only).
|
||
# Idempotent, so repeated calls from AIAgent.__init__ won't duplicate.
|
||
from hermes_logging import setup_logging
|
||
setup_logging(hermes_home=_hermes_home, mode="gateway")
|
||
|
||
# Optional stderr handler — level driven by -v/-q flags on the CLI.
|
||
# verbosity=None (-q/--quiet): no stderr output
|
||
# verbosity=0 (default): WARNING and above
|
||
# verbosity=1 (-v): INFO and above
|
||
# verbosity=2+ (-vv/-vvv): DEBUG
|
||
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)
|
||
# Lower root logger level if needed so DEBUG records can reach the handler
|
||
if _stderr_level < logging.getLogger().level:
|
||
logging.getLogger().setLevel(_stderr_level)
|
||
|
||
runner = GatewayRunner(config)
|
||
|
||
# Set up signal handlers
|
||
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()
|
||
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
|
||
|
||
# Start the gateway
|
||
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
|
||
|
||
# Write PID file so CLI can detect gateway is running
|
||
import atexit
|
||
from gateway.status import write_pid_file, remove_pid_file
|
||
write_pid_file()
|
||
atexit.register(remove_pid_file)
|
||
|
||
# Start background cron ticker so scheduled jobs fire automatically.
|
||
# Pass the event loop so cron delivery can use live adapters (E2EE support).
|
||
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()
|
||
|
||
# Wait for shutdown
|
||
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
|
||
|
||
# Stop cron ticker cleanly
|
||
cron_stop.set()
|
||
cron_thread.join(timeout=5)
|
||
|
||
# Close MCP server connections
|
||
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)
|
||
|
||
# Run the gateway - exit with code 1 if no platforms connected,
|
||
# so systemd Restart=on-failure will retry on transient errors (e.g. DNS)
|
||
success = asyncio.run(start_gateway(config))
|
||
if not success:
|
||
sys.exit(1)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|