Files
hermes-agent/gateway/run_inbound.py
T
Teknium 66366d3dab refactor(gateway): split GatewayRunner into 13 run_* mixins + TurnRunner module (run.py 31332 -> 6957)
AST-driven, body-identical move of 359 GatewayRunner methods into cohesive
mixin modules (gateway/run_{voice,adapters,topics,turn,shutdown,busy,
config_loaders,startup,watchers,notifications,inbound,goals,agent_cache}.py)
plus TurnRunner -> gateway/run_turn_runner.py. run.py-internal symbols are
imported lazily inside method bodies so patch('gateway.run.X') keeps
intercepting; neutral deps are top-level; logger name stays 'gateway.run'.
_UNSET moved to leaf gateway/run_common.py (def-time default-arg sentinel).
Whole-module inspect.getsource(gateway_run) AST-walker tests repointed to
the module that now holds the walked code.
2026-09-02 16:11:50 -07:00

2635 lines
128 KiB
Python

"""Inbound message pipeline (_handle_message, text/media preparation, durable-turn markers, plugin injection) for GatewayRunner.
Split out of ``gateway/run.py``; bound onto ``GatewayRunner`` via the MRO.
``gateway.run`` internals are imported lazily inside method bodies (import cycle),
so ``patch("gateway.run.X")`` keeps intercepting them at call time.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
import asyncio
import concurrent.futures
import dataclasses
import json
import os
import re
import time
from contextlib import suppress
from gateway.config import Platform
from gateway.platforms.base import EphemeralReply, MessageEvent, MessageType
from gateway.run_common import _UNSET
from gateway.session import (
SessionSource,
is_shared_multi_user_session,
neutralize_untrusted_inline_text,
)
from gateway.turn_lease import TurnLeaseTimeoutError
from typing import Any, Dict, List, Optional, Tuple
if TYPE_CHECKING: # string annotations only; never imported at runtime (cycle)
from gateway.run import GatewayRunner, TurnRunner # noqa: F401
# Log-record parity with the origin module.
logger = logging.getLogger("gateway.run")
class GatewayInboundMixin:
"""Inbound message pipeline (_handle_message, text/media preparation, durable-turn markers, plugin injection) for GatewayRunner."""
async def _hm_admit_event(
self, event: "MessageEvent"
) -> Optional[Tuple["MessageEvent", SessionSource, bool]]:
"""Ingress gates for ``_handle_message``: leak guard, profile route, ignored channels,
startup-restore queueing, ``pre_gateway_dispatch`` hook, authorization/pairing.
Returns ``None`` when the message is dropped, else ``(event, source, is_internal)`` —
the hook may have rewritten ``event``.
"""
from gateway.run import _is_slack_ignored_channel
source = event.source
# 🔴 Cross-session leak guard. This per-message task was created via create_task(), which
# copies the spawning context: if a concurrent message had already bound its session via
# set_session_vars(), we inherited ITS HERMES_SESSION_* ContextVars, and until _set_session_env
# binds ours any subprocess would read the foreign identity (the _UNSET-strip guard can't
# help — the vars are set-to-foreign). Reset to _UNSET so that window strips safe instead.
try:
from gateway.session_context import reset_session_vars
reset_session_vars()
except Exception:
logger.debug("reset_session_vars failed at handler entry", exc_info=True)
# Most adapters resolve profile routes in build_source(), before they hand us the event. A
# few internal/voice paths construct SessionSource directly, so resolve those here as the
# shared fail-closed ingress gate before authorization, hooks, or session side effects.
if (
getattr(getattr(self, "config", None), "multiplex_profiles", False)
and not getattr(source, "profile", None)
and getattr(source, "profile_route_rejected", False) is not True
):
from gateway.profile_routing import ProfileRouteRejected
try:
source.profile = self._profile_name_for_source(source)
except ProfileRouteRejected:
source.profile_route_rejected = True
# SessionSource owns a strict boolean marker. Require the literal value
# so duck-typed test/internal sources with dynamic attributes are not
# mistaken for an explicit matched-route rejection.
if getattr(source, "profile_route_rejected", False) is True:
logger.warning(
"Dropping inbound message because its explicit profile route "
"targets an unserved profile"
)
return None
# Internal events (e.g. background-process completion notifications)
# are system-generated and must skip user authorization.
is_internal = bool(getattr(event, "internal", False))
# Ignored-channel guard runs FIRST — before startup-restore queueing, plugin hooks, auth,
# and session setup — so an ignored channel can never reach pairing/auth/session state.
# getattr: bare test runners construct GatewayRunner via object.__new__ without config.
if (
not is_internal
and getattr(source, "platform", None) == Platform.SLACK
and _is_slack_ignored_channel(
getattr(self, "config", None), getattr(source, "chat_id", None)
)
):
logger.info(
"Dropping Slack message from configured ignored channel %s",
getattr(source, "chat_id", None),
)
return None
if (
getattr(self, "_startup_restore_in_progress", False)
and not is_internal
and not getattr(event, "_hermes_startup_restore_replay", False)
):
self._queue_startup_restore_event(event)
return None
# scale-to-zero: stamp the gateway-scoped last-inbound clock (read by is_idle) for real
# user-originated inbound only. Internal/system events are NOT traffic — counting them
# would keep a genuinely idle gateway awake.
if not is_internal:
self._scale_to_zero_note_real_inbound()
# pre_gateway_dispatch plugin hook (user-originated only). Plugins may return
# {"action": "skip", "reason": ...} -> drop; {"action": "rewrite", "text": ...} -> replace
# event.text; {"action": "allow"} / None -> normal dispatch.
# Runs BEFORE auth so plugins can handle unauthorized senders without the pairing flow.
if not is_internal:
try:
from hermes_cli.lifecycle import invoke_hook as _invoke_hook
_hook_results = _invoke_hook(
"pre_gateway_dispatch",
event=event,
gateway=self,
# getattr: bare-runner tests build GatewayRunner via object.__new__ without
# __init__; the hook must not fail dispatch over a missing attribute.
session_store=getattr(self, "session_store", None),
)
except Exception as _hook_exc:
logger.warning("pre_gateway_dispatch invocation failed: %s", _hook_exc)
_hook_results = []
for _result in _hook_results:
if not isinstance(_result, dict):
continue
_action = _result.get("action")
if _action == "skip":
logger.info(
"pre_gateway_dispatch skip: reason=%s platform=%s chat=%s",
_result.get("reason"),
source.platform.value if source.platform else "unknown",
source.chat_id or "unknown",
)
return None
if _action == "rewrite":
_new_text = _result.get("text")
if isinstance(_new_text, str):
event = dataclasses.replace(event, text=_new_text)
source = event.source
break
if _action == "allow":
break
if is_internal:
pass
elif source.user_id is None:
# Messages with no user identity (Telegram service messages, channel forwards, anonymous
# admin posts, sender_chat) can't be paired but may be authorized via a chat-scoped
# allowlist (e.g. TELEGRAM_GROUP_ALLOWED_CHATS), so defer to _is_user_authorized.
if not self._is_user_authorized_for_source(source):
logger.debug("Ignoring message with no user_id from %s", source.platform.value)
return None
elif not self._is_user_authorized_for_source(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,
profile=source.profile,
)
== "pair"
):
platform_name = source.platform.value if source.platform else "unknown"
pairing_store = self._pairing_store_for(source)
if pairing_store is None:
logger.error(
"Cannot offer pairing code on %s: no pairing store",
platform_name,
)
return None
# Rate-limit ALL pairing responses (code or rejection) so a burst of DMs doesn't
# spam the user with repeated messages.
if pairing_store._is_rate_limited(platform_name, source.user_id):
return None
code = pairing_store.generate_code(
platform_name, source.user_id, source.user_name or ""
)
if code:
adapter = self._adapter_for_source(source)
if adapter:
store_profile = getattr(pairing_store, "profile", None)
profile_arg = (
f"-p {store_profile} "
if isinstance(store_profile, str)
and store_profile
and store_profile != "default"
else ""
)
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 {profile_arg}pairing approve "
f"{platform_name} {code}`"
)
else:
adapter = self._adapter_for_source(source)
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
pairing_store._record_rate_limit(platform_name, source.user_id)
return None
return event, source, is_internal
def _hm_estop_gate(
self, event: "MessageEvent", source: SessionSource, is_internal: bool
) -> Optional[str]:
"""Return the global emergency-stop notice when this turn must be blocked, else None."""
# Global emergency stop (`hermes pause`): new turns get a brief paused notice instead of an
# agent run. Placed after auth so unauthorized senders can't probe pause state. Pause blocks
# NEW agent turns, never running work or control traffic, so these pass through: internal
# events from IN-FLIGHT work; recognized slash commands (/status, /approve, ... and /pause off
# as the in-band resume path); replies owned by in-flight work — pending update prompt,
# clarify, slash-confirm, dangerous-command approval, or steering an already-running session.
if not is_internal:
try:
from agent.estop import paused_reply as _estop_paused_reply
_paused_notice = _estop_paused_reply()
except ImportError:
_paused_notice = None
if _paused_notice is not None:
_estop_allow = False
_estop_cmd = None
try:
_estop_cmd = event.get_command()
except Exception:
_estop_cmd = None
if _estop_cmd:
try:
from hermes_cli.commands import (
resolve_command as _resolve_estop_cmd,
)
_estop_allow = _resolve_estop_cmd(_estop_cmd) is not None
except Exception:
_estop_allow = False
if not _estop_allow:
try:
_estop_key = self._session_key_for_source(source)
_estop_state = self._peek_session_state(_estop_key)
if (
_estop_state is not None
and _estop_state.persistent.update_prompt_pending
):
_estop_allow = True
if not _estop_allow and self._is_session_running(_estop_key):
# Steering / interrupting in-flight work (also covers pending clarify +
# tool approvals held by the running agent).
_estop_allow = True
if not _estop_allow:
from tools import slash_confirm as _estop_confirm_mod
if _estop_confirm_mod.get_pending(_estop_key):
_estop_allow = True
if not _estop_allow:
from tools.approval import (
has_blocking_approval as _estop_has_approval,
)
if _estop_has_approval(_estop_key):
_estop_allow = True
except Exception:
pass
if not _estop_allow:
logger.info(
"Gateway turn paused by global emergency stop (platform=%s chat=%s)",
getattr(getattr(source, "platform", None), "value", "unknown"),
getattr(source, "chat_id", None) or "unknown",
)
return _paused_notice
return None
def _hm_update_prompt_reply(
self, event: "MessageEvent", _quick_key: str, allow_gateway_control: bool
) -> Optional[str]:
"""Consume a reply to a pending ``/update`` prompt; None when nothing was consumed."""
from gateway.run import _hermes_home
# Route replies to a pending /update prompt back to the detached update process via
# .update_response. Recognized slash commands must bypass this or /new, /help etc. get
# silently consumed as update answers.
_up_state = self._peek_session_state(_quick_key)
if (
allow_gateway_control
and _up_state is not None
and _up_state.persistent.update_prompt_pending
):
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:
_recognized_cmd = None
if cmd:
try:
from hermes_cli.commands import resolve_command as _resolve_update_cmd
except Exception:
_resolve_update_cmd = None
if _resolve_update_cmd is not None:
try:
_cmd_def = _resolve_update_cmd(cmd)
_recognized_cmd = _cmd_def.name if _cmd_def else None
except Exception:
_recognized_cmd = None
response_text = "" if _recognized_cmd else raw
if response_text:
response_path = _hermes_home / ".update_response"
prompt_path = _hermes_home / ".update_prompt.json"
try:
tmp = response_path.with_suffix(".tmp")
tmp.write_text(response_text, encoding="utf-8")
tmp.replace(response_path)
prompt_path.unlink(missing_ok=True)
except OSError as e:
logger.warning("Failed to write update response: %s", e)
return f"✗ Failed to send response to update process: {e}"
_up_state.persistent.update_prompt_pending = False
label = response_text if len(response_text) <= 20 else response_text[:20] + "…"
return f"✓ Sent `{label}` to the update process."
# Recognized slash command during a pending update prompt: write a blank response so the
# detached update's ``_gateway_prompt`` returns the prompt's default (typically a safe
# "n" / skip) and exits instead of blocking on stdin until the watcher timeout.
if _recognized_cmd:
response_path = _hermes_home / ".update_response"
prompt_path = _hermes_home / ".update_prompt.json"
try:
tmp = response_path.with_suffix(".tmp")
tmp.write_text("", encoding="utf-8")
tmp.replace(response_path)
prompt_path.unlink(missing_ok=True)
logger.info(
"Recognized /%s during pending update prompt for %s; "
"cancelled prompt with default and dispatching command",
_recognized_cmd,
_quick_key,
)
except OSError as e:
logger.warning(
"Failed to write cancel response for pending update prompt: %s",
e,
)
_up_state.persistent.update_prompt_pending = False
return None
async def _hm_clarify_reply(
self,
event: "MessageEvent",
source: SessionSource,
_quick_key: str,
allow_gateway_control: bool,
) -> Optional[str]:
"""Intercept a reply to a pending clarify prompt; None when the message falls through."""
# Intercept replies to a pending clarify: open-ended prompts and "Other" responses are free
# text; direct replies to multi-choice prompts are accepted too ("2" → second option).
_clarify_mod = None
try:
from tools import clarify_gateway as _clarify_mod
_pending_clarify = _clarify_mod.get_pending_for_session(
_quick_key, include_choice_prompts=True,
)
except Exception:
_pending_clarify = None
if (
allow_gateway_control
and _pending_clarify is not None
and _clarify_mod is not None
):
_clarify_has_audio = bool(self._pending_event_audio_paths(event))
_raw_clarify_reply = await self._prepare_clarify_reply_text(event)
if _clarify_has_audio and not _raw_clarify_reply:
logger.info(
"Gateway retained pending clarify after voice transcription "
"produced no usable text (session=%s, id=%s)",
_quick_key,
_pending_clarify.clarify_id,
)
return ""
# Skip slash commands — the user wanted a command, not to answer the clarify. Leave it
# pending so they can retry; on timeout the agent unblocks with an empty response.
if _raw_clarify_reply and not _raw_clarify_reply.startswith("/"):
_text_outcome = _clarify_mod.attempt_text_response_for_session(
_quick_key, _raw_clarify_reply,
)
if _text_outcome == _clarify_mod.TEXT_RESOLVED:
logger.info(
"Gateway intercepted clarify text response (session=%s, id=%s)",
_quick_key, _pending_clarify.clarify_id,
)
# The clarify callback pauses the platform typing/status indicator while waiting
# so Slack users can type their answer. The active agent resumes as soon as this
# reply resolves the wait, so re-enable its indicator here too.
_clarify_adapter = self._adapter_for_source(source)
if _clarify_adapter:
try:
_clarify_adapter.resume_typing_for_chat(source.chat_id)
except Exception:
logger.debug(
"Failed to resume typing after clarify response",
exc_info=True,
)
# Acknowledge with empty string so adapters that emit the agent's response don't
# double-post; the agent itself produces the next user-facing message.
return ""
if _text_outcome == _clarify_mod.TEXT_REJECTED_SELECTION:
# Selection-shaped but invalid (out-of-range number, bad comma-list): keep the
# clarify armed for retry — don't cancel, don't treat as an unrelated follow-up.
logger.info(
"Gateway retained pending clarify after invalid "
"selection attempt (session=%s, id=%s)",
_quick_key, _pending_clarify.clarify_id,
)
return ""
if _text_outcome == _clarify_mod.TEXT_REJECTED_PROSE:
# Native-choice prompts deliberately reject unmatched prose so it can continue
# through normal busy-message routing. Release this clarify first: redirect()
# degrades to steer() while tools execute, and that steer cannot drain until
# the clarify tool returns.
_clarify_mod.resolve_gateway_clarify(
_pending_clarify.clarify_id,
"",
)
return None
async def _hm_slash_confirm_reply(
self, event: "MessageEvent", _quick_key: str, allow_gateway_control: bool
) -> Optional[str]:
"""Resolve a reply to a pending slash-confirm prompt; None when the message falls through."""
# Replies to a pending slash-confirm prompt (/reload-mcp etc.): /approve, /always, /cancel and
# short aliases. Anything else falls through — a stale pending confirm does NOT block other
# commands. A pending dangerous-command approval takes precedence: /approve there unblocks
# the waiting tool thread; slash-confirm only catches it when no tool approval is live.
from tools import slash_confirm as _slash_confirm_mod
_pending_confirm = _slash_confirm_mod.get_pending(_quick_key)
_tool_approval_live = False
try:
from tools.approval import has_blocking_approval
_tool_approval_live = has_blocking_approval(_quick_key)
except Exception:
_tool_approval_live = False
if allow_gateway_control and _pending_confirm and not _tool_approval_live:
_raw_reply = (event.text or "").strip()
# Accept bang-prefixed replies (`!always`, `!cancel`) verbatim: Slack/Matrix show the
# `!` prefix (typed `/` is blocked in Slack threads) and adapters only rewrite
# `!<known-command>` — confirm keywords aren't commands, so the `!` survives to here.
_norm_reply = _raw_reply.lstrip("!/").lower()
_cmd_reply = event.get_command()
_confirm_choice = None
if _cmd_reply in {"approve", "yes", "ok", "confirm"}:
_confirm_choice = "once"
elif _cmd_reply in {"always", "remember"}:
_confirm_choice = "always"
elif _cmd_reply in {"cancel", "no", "deny", "nevermind"}:
_confirm_choice = "cancel"
elif _norm_reply in {"approve", "approve once", "once"}:
_confirm_choice = "once"
elif _norm_reply in {"always", "always approve"}:
_confirm_choice = "always"
elif _norm_reply in {"cancel", "nevermind", "no"}:
_confirm_choice = "cancel"
if _confirm_choice is not None:
_resolved = await _slash_confirm_mod.resolve(
_quick_key, _pending_confirm.get("confirm_id"), _confirm_choice,
)
return _resolved or ""
# Stale pending + unrelated command: the user moved on, so drop the pending state rather
# than let the confirm block normal usage indefinitely.
_slash_confirm_mod.clear_if_stale(_quick_key)
return None
def _hm_evict_stale_running_agent(self, _quick_key: str) -> None:
"""Evict a leaked/reaped ``_running_agents`` slot before the busy-session fast-path."""
from gateway.run import _AGENT_PENDING_SENTINEL, _float_env
# Staleness eviction: detect leaked locks from hung/crashed handlers. With inactivity-based
# timeout active tasks can run for hours, so evict only when the agent has been *idle* past
# the threshold (or has no activity tracker and its wall-clock age is extreme).
_raw_stale_timeout = _float_env("HERMES_AGENT_TIMEOUT", 1800)
_quick_state = self._peek_session_state(_quick_key)
_stale_ts = _quick_state.turn.started_ts if _quick_state else 0
if _quick_state is not None and _quick_state.turn.agent is not None and _stale_ts:
_stale_age = time.time() - _stale_ts
_stale_agent = _quick_state.turn.agent
# Never evict the pending sentinel — it was just placed during async setup before the
# real agent exists. Sentinels have no get_activity_summary(), so the idle check would
# read inf >= timeout and evict them immediately, racing the setup path.
_stale_idle = float("inf") # assume idle if we can't check
_stale_detail = ""
_activity_summary_valid = False
if _stale_agent and hasattr(_stale_agent, "get_activity_summary"):
try:
_sa = _stale_agent.get_activity_summary()
from gateway.session_stall import (
resolve_session_idle_seconds_from_activity,
)
_resolved_idle = resolve_session_idle_seconds_from_activity(
_sa if isinstance(_sa, dict) else None,
now=time.time(),
)
if _resolved_idle is not None:
_stale_idle = _resolved_idle
_activity_summary_valid = True
_stale_detail = (
f" | last_activity={_sa.get('last_activity_desc', 'unknown') if isinstance(_sa, dict) else 'unknown'} "
f"({_stale_idle:.0f}s ago) "
f"| iteration={_sa.get('api_call_count', 0) if isinstance(_sa, dict) else 0}/{_sa.get('max_iterations', 0) if isinstance(_sa, dict) else 0}"
)
except Exception:
pass
# A valid activity clock is authoritative: total age alone never
# makes an actively progressing turn stale. The emergency wall TTL
# is only a fallback when the agent cannot report usable activity.
_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 (
(
_activity_summary_valid
and _raw_stale_timeout > 0
and _stale_idle >= _raw_stale_timeout
)
or (
not _activity_summary_valid
and _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, _stale_age, _stale_idle,
_raw_stale_timeout, _stale_detail,
)
self._invalidate_session_run_generation(
_quick_key,
reason="stale_running_agent_eviction",
)
self._release_running_agent_state(_quick_key)
# Durable-reaped guard. A session whose routing row was ended in state.db (``ws_orphan_reap``
# / ``agent_close``) while the gateway lived keeps its in-memory turn slot, so the fast-path
# would queue every next message into the dead runtime. Evict the stale slot so the cold
# path re-attaches via ``get_or_create_session`` → ``reopen`` or creates a fresh session.
if self._is_session_running(_quick_key):
try:
_reap_store = getattr(self, "session_store", None)
# Use the public, lock-held accessors: peek_session_id resolves key -> session_id
# under the store lock, and returns a non-str on stubbed stores in bare test runners
# — both the isinstance() gate and the ``is True`` gate below keep this guard inert
# unless a real SessionStore answers.
_reap_peek = getattr(_reap_store, "peek_session_id", None)
_is_ended = getattr(_reap_store, "_is_session_ended_in_db", None)
_reap_sid = _reap_peek(_quick_key) if callable(_reap_peek) else None
if (
isinstance(_reap_sid, str)
and _reap_sid
and callable(_is_ended)
and _is_ended(_reap_sid) is True
):
logger.warning(
"Evicting stale _running_agents entry for %s — "
"durable session %s is ended (reaped) in state.db; "
"healing routing on next message (#99106)",
_quick_key,
_reap_sid,
)
self._invalidate_session_run_generation(
_quick_key,
reason="reaped_session_eviction",
)
self._release_running_agent_state(_quick_key)
except Exception:
logger.debug("reaped-session staleness check failed", exc_info=True)
async def _hm_handle_running_session_message(
self, event: "MessageEvent", source: SessionSource, _quick_key: str
) -> Optional[str]:
"""Fast-path for a message that arrives while this session's agent is running."""
from gateway.run import (
_AGENT_PENDING_SENTINEL,
_build_media_placeholder,
merge_pending_message_event,
)
# 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.
# Exception: Telegram photo bursts arrive as near-simultaneous updates — do NOT interrupt
# for photo-only follow-ups; adapter-level batching absorbs them.
# Resolve the command once; each command's mid-run behavior is declared on its
# CommandDef (busy_policy / busy_handler in hermes_cli/commands.py) and dispatched via
# _dispatch_busy_slash_command below — no per-command if-chain here.
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
# /status and /context are intentionally pre-gate so users
# always see session state.
if _cmd_def_inner and _cmd_def_inner.name == "status":
return await self._handle_status_command(event)
if _cmd_def_inner and _cmd_def_inner.name == "context":
return await self._handle_context_command(event)
# Slash command access control on the running-agent fast-path. Mirrors the cold-path
# gate below so non-admins can't bypass gating just because an agent is busy. /status
# above is intentionally pre-gate; /help and /whoami are the always-allowed floor.
if _evt_cmd and _cmd_def_inner is not None:
_denied = self._check_slash_access(source, _cmd_def_inner.name)
if _denied is not None:
return _denied
# Any recognized slash command: dispatch according to its declared busy_policy (dispatch
# / interrupt_then_dispatch / reject). Unrecognized commands and plain text fall through
# to the interrupt/queue logic below.
if _cmd_def_inner:
return await self._dispatch_busy_slash_command(
event, _cmd_def_inner, _quick_key, source,
)
if event.message_type == MessageType.PHOTO:
logger.debug("PRIORITY photo follow-up for session %s — queueing without interrupt", _quick_key)
adapter = self._adapter_for_source(source)
if adapter:
merge_pending_message_event(adapter._pending_messages, _quick_key, event)
return None
effective_busy_input_mode = self._effective_busy_input_mode(source)
_telegram_followup_grace = float(
os.getenv("HERMES_TELEGRAM_FOLLOWUP_GRACE_SECONDS", "3.0")
)
_grace_state = self._peek_session_state(_quick_key)
_started_at = _grace_state.turn.started_ts if _grace_state else 0
if (
source.platform == Platform.TELEGRAM
and event.message_type == MessageType.TEXT
and _telegram_followup_grace > 0
and _started_at
and (time.time() - _started_at) <= _telegram_followup_grace
):
logger.debug(
"Telegram follow-up arrived %.2fs after run start for %s — queueing without interrupt",
time.time() - _started_at,
_quick_key,
)
adapter = self._adapter_for_source(source)
if adapter:
if effective_busy_input_mode == "queue":
self._enqueue_fifo(_quick_key, event, adapter)
else:
merge_pending_message_event(
adapter._pending_messages,
_quick_key,
event,
merge_text=True,
)
return None
_ra_state = self._peek_session_state(_quick_key)
running_agent = _ra_state.turn.agent if _ra_state else None
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.
self._release_running_agent_state(_quick_key)
logger.info("HARD STOP (pending) for session %s — sentinel cleared", _quick_key)
return EphemeralReply("⚡ Force-stopped. The agent was still starting — session unlocked.")
# Queue the message so it will be picked up after the
# agent starts.
adapter = self._adapter_for_source(source)
if adapter:
merge_pending_message_event(
adapter._pending_messages,
_quick_key,
event,
merge_text=True,
)
return None
if self._draining:
queue_during_drain = self._queue_during_drain_enabled(
effective_busy_input_mode
)
if queue_during_drain:
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 queue_during_drain
else f"⏳ Gateway is {self._status_action_gerund()} and is not accepting another turn right now."
)
if effective_busy_input_mode == "queue":
logger.debug("PRIORITY queue follow-up for session %s", _quick_key)
self._queue_or_replace_pending_event(_quick_key, event)
return None
if effective_busy_input_mode == "steer":
# Steer mode: inject text into the running agent mid-run via
# agent.steer(). Falls back to queue semantics if the payload
# is empty, the agent lacks steer(), or steer() rejects.
steer_text = (event.text or "").strip()
steered = False
if (
event.message_type == MessageType.TEXT
and not event.media_urls
and not event.media_types
and steer_text
and hasattr(running_agent, "steer")
):
try:
steered = bool(running_agent.steer(steer_text))
except Exception as exc:
logger.warning("PRIORITY steer failed for session %s: %s", _quick_key, exc)
steered = False
if steered:
logger.debug("PRIORITY steer for session %s", _quick_key)
return None
logger.debug("PRIORITY steer-fallback-to-queue for session %s", _quick_key)
self._queue_or_replace_pending_event(_quick_key, event)
return None
# Subagent protection (PRIORITY path). Same rationale as
# ``_handle_active_session_busy_message``: an interrupt cascades through
# ``_active_children`` and aborts in-flight delegate_task work, so demote to queue
# semantics while subagents run. /stop reached its handler above — still an escape hatch.
if self._agent_has_active_subagents(running_agent):
logger.info(
"PRIORITY interrupt demoted to queue for session %s "
"because the running agent has active subagents (#30170)",
_quick_key,
)
self._queue_or_replace_pending_event(_quick_key, event)
return None
# Compression protection (PRIORITY path), as in ``_handle_active_session_busy_message``:
# an interrupt would start a new turn on the pre-rotation parent while compression
# rotates the id away, forking orphaned siblings. Demote to queue until rotation lands.
if await self._session_has_compression_in_flight(_quick_key):
logger.info(
"PRIORITY interrupt demoted to queue for session %s "
"because context compression is in flight (#56391)",
_quick_key,
)
self._queue_or_replace_pending_event(_quick_key, event)
return None
# Text-only corrections redirect the live turn (preserving displayed context) when the
# runtime supports it; media/voice and older runtimes use the interrupt path below.
if (
event.message_type == MessageType.TEXT
and not event.media_urls
and not event.media_types
and getattr(running_agent, "_supports_active_turn_redirect", False)
is True
and hasattr(running_agent, "redirect")
):
try:
if running_agent.redirect((event.text or "").strip()):
logger.debug("PRIORITY redirect for session %s", _quick_key)
return None
except Exception as exc:
logger.warning(
"PRIORITY redirect failed for session %s: %s",
_quick_key,
exc,
)
logger.debug("PRIORITY interrupt for session %s", _quick_key)
_interrupt_text = event.text
_media_urls = getattr(event, "media_urls", None) or []
if self._pending_event_audio_paths(event):
_interrupt_text, _ = await self._transcribe_and_echo_pending_voice(
event,
self._adapter_for_source(source),
source,
event.text or "",
log_context="Voice-priority-interrupt",
)
elif not _interrupt_text and _media_urls:
_interrupt_text = _build_media_placeholder(event)
running_agent.interrupt(_interrupt_text)
# The interrupt message is delivered via adapter._pending_messages (read by _run_agent);
# don't also buffer it on self — that copy was never consumed and grew unbounded.
return None
async def _hm_resolve_command(
self, event: "MessageEvent", source: SessionSource, _quick_key: str
) -> Tuple[bool, Optional[str], Optional[str], Optional[str]]:
"""Resolve the slash command (aliases, access gate, ``pre_command`` + ``command:<name>`` hooks).
Returns ``(handled, result, command, canonical)``; when ``handled`` the caller returns
``result`` as-is (it may legitimately be None).
"""
# Check for commands
command = event.get_command()
from hermes_cli.commands import (
is_gateway_known_command,
resolve_command as _resolve_cmd,
)
# Resolve aliases to canonical name so dispatch and hook names
# don't depend on the exact alias the user typed.
_cmd_def = _resolve_cmd(command) if command else None
canonical = _cmd_def.name if _cmd_def else command
# Expand alias quick commands before built-in dispatch so targets like /model openai/gpt-5.5
# --provider openrouter reach the /model handler. Preserve built-in precedence; aliases only
# need early handling when the typed command is not already known.
if command and _cmd_def is None:
if isinstance(self.config, dict):
quick_commands = self.config.get("quick_commands", {}) or {}
else:
quick_commands = getattr(self.config, "quick_commands", {}) or {}
if isinstance(quick_commands, dict) and command in quick_commands:
qcmd = quick_commands[command]
if qcmd.get("type") == "alias":
target = (qcmd.get("target") or "").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.split()[0] if target_command else target_command
_cmd_def = _resolve_cmd(command) if command else None
canonical = _cmd_def.name if _cmd_def else command
# Per-platform slash command access control. Only kicks in when the operator has set
# ``allow_admin_from`` for the source's scope (DM vs group). When unset → backward-compat:
# every allowed user can run every command. When set → non-admins get only
# ``user_allowed_commands`` plus the /help, /whoami floor. Plain chat is never gated.
if command and canonical and is_gateway_known_command(canonical):
_denied = self._check_slash_access(source, canonical)
if _denied is not None:
return True, _denied, command, canonical
# pre_command observer hook (returns ignored) fires for every recognized slash command
# BEFORE core handling, mirroring cli.py. The running-agent intercept path above (/stop,
# /approve, busy_policy) deliberately does NOT fire it — a slow or hostile plugin must not
# interfere with the operator's escape hatches for a live agent.
if command and is_gateway_known_command(canonical):
try:
from hermes_cli.plugins import fire_pre_command_hook
fire_pre_command_hook(
surface="gateway",
command=str(canonical),
alias_used=str(command),
args_raw=event.get_command_args().strip(),
session_key=_quick_key,
platform=source.platform.value if source.platform else "",
)
except Exception as _pre_cmd_err:
logger.debug(
"pre_command hook dispatch failed (non-fatal): %s",
_pre_cmd_err,
)
# Fire ``command:<canonical>`` for any recognized slash command (built-in or plugin).
# Handlers may return ``{"decision": "deny" | "handled" | "rewrite", ...}`` to intercept
# dispatch; handlers returning nothing behave as plain observers.
if command and is_gateway_known_command(canonical):
raw_args = event.get_command_args().strip()
hook_ctx = {
"platform": source.platform.value if source.platform else "",
"user_id": source.user_id,
"command": canonical,
"raw_command": command,
"args": raw_args,
"raw_args": raw_args,
}
try:
hook_results = await self.hooks.emit_collect(
f"command:{canonical}", hook_ctx
)
except Exception as _hook_err:
logger.debug(
"command:%s hook dispatch failed (non-fatal): %s",
canonical, _hook_err,
)
hook_results = []
for hook_result in hook_results:
if not isinstance(hook_result, dict):
continue
decision = str(hook_result.get("decision", "")).strip().lower()
if not decision or decision == "allow":
continue
if decision == "deny":
message = hook_result.get("message")
if isinstance(message, str) and message:
return True, message, command, canonical
return True, f"Command `/{command}` was blocked by a hook.", command, canonical
if decision == "handled":
message = hook_result.get("message")
return True, message if isinstance(message, str) and message else None, command, canonical
if decision == "rewrite":
new_command = str(
hook_result.get("command_name", "")
).strip().lstrip("/")
if not new_command:
continue
new_args = str(hook_result.get("raw_args", "")).strip()
event.text = f"/{new_command} {new_args}".strip()
command = event.get_command()
_cmd_def = _resolve_cmd(command) if command else None
canonical = _cmd_def.name if _cmd_def else command
break
return False, None, command, canonical
async def _hm_dispatch_canonical_command(
self,
event: "MessageEvent",
source: SessionSource,
_quick_key: str,
canonical: Optional[str],
) -> Tuple[bool, Optional[str]]:
"""Dispatch built-in idle-path commands (plain handlers, /new, /learn, /plan, /moa, ...).
Returns ``(handled, result)``. Prompt-rewriting commands (/learn, /plan, /init, /steer,
/moa, ...) mutate ``event.text`` and return ``(False, None)`` to fall through to the agent.
"""
plain_handler = (
self._gateway_plain_command_handlers().get(canonical)
or self._gateway_idle_command_handlers().get(canonical)
)
if plain_handler is not None:
return True, await plain_handler(event)
if canonical == "new":
if await asyncio.to_thread(self._is_telegram_topic_root_lobby, source):
return True, self._telegram_topic_root_new_message()
async def _do_reset():
return await self._handle_reset_command(event)
return True, await self._maybe_confirm_destructive_slash(
event=event,
command="new",
title="/new",
detail=(
"This starts a fresh session and discards the current "
"conversation history."
),
execute=_do_reset,
)
if canonical == "start":
logger.info("Ignoring /start platform ping for session %s", _quick_key)
return True, ""
if canonical == "egress":
from hermes_cli.proxy_cli import format_status_text
return True, format_status_text()
if canonical == "learn":
# Open-ended: rewrite the turn to a standards-guided prompt and fall through to normal
# agent processing. Mirrors the /blueprint fall-through so role alternation is
# preserved. No engine, works on any backend.
from agent.learn_prompt import build_learn_prompt
_learn_req = event.get_command_args().strip()
_ack = (
"Learning a skill from what you described…"
if _learn_req
else "Learning a skill from this conversation…"
)
await self._send_command_ack(source, _ack, "learn")
try:
event.text = build_learn_prompt(_learn_req)
# fall through to agent processing
except Exception:
return True, "Could not start /learn — please try again."
if canonical == "plan":
# /plan: rewrite the turn to the plan-mode prompt and fall through to normal agent
# processing (the /learn fall-through keeps role alternation). Works on any backend.
from agent.plan_prompt import build_plan_prompt
_plan_task = event.get_command_args().strip()
_ack = (
f"Planning: {_plan_task[:80]}{'…' if len(_plan_task) > 80 else ''}"
if _plan_task
else "Planning from this conversation's context…"
)
await self._send_command_ack(source, _ack, "plan")
try:
event.text = build_plan_prompt(_plan_task)
# fall through to agent processing
except Exception:
return True, "Could not start /plan — please try again."
if canonical == "init":
# /init: rewrite the turn to a guidance-laden prompt and fall through to normal agent
# processing (the /learn fall-through keeps role alternation). Works on any backend.
from hermes_cli.init_command import build_init_prompt_for_cwd
_init_notes = event.get_command_args().strip()
try:
_init_prompt = build_init_prompt_for_cwd(extra=_init_notes)
except Exception:
return True, "Could not start /init — please try again."
_ack = (
"Updating AGENTS.md from a project scan…"
if "UPDATE the existing AGENTS.md" in _init_prompt
else "Generating AGENTS.md from a project scan…"
)
await self._send_command_ack(source, _ack, "init")
event.text = _init_prompt
# fall through to agent processing
if canonical == "blueprint":
_blueprint_result = await self._handle_blueprint_command(event)
_blueprint_seed = getattr(_blueprint_result, "agent_seed", None)
if _blueprint_seed:
# Blueprint matched — rewrite the turn to the seed and fall through to
# _handle_message_with_agent so the agent collects each slot value conversationally,
# then calls the cronjob tool (the /steer fall-through pattern).
_ack = getattr(_blueprint_result, "text", "") or ""
if _ack:
await self._send_command_ack(source, _ack, "blueprint")
try:
event.text = _blueprint_seed
except Exception:
return True, getattr(_blueprint_result, "text", "") or None
else:
return True, getattr(_blueprint_result, "text", "") or None
if canonical == "undo":
async def _do_undo():
return await self._handle_undo_command(event)
_undo_n = 1
_undo_raw = event.get_command_args().strip()
if _undo_raw:
try:
_undo_n = max(1, int(_undo_raw.split()[0]))
except (ValueError, IndexError):
_undo_n = 1
_undo_detail = (
"This removes the last user/assistant exchange from history."
if _undo_n == 1
else f"This removes the last {_undo_n} user turns from history."
)
return True, await self._maybe_confirm_destructive_slash(
event=event,
command="undo",
title="/undo",
detail=_undo_detail,
execute=_do_undo,
)
if canonical == "queue":
queue_payload = event.get_command_args().strip()
if not queue_payload:
return True, "Usage: /queue <prompt>"
with suppress(Exception):
event.text = queue_payload
if canonical == "steer":
# No active agent — /steer has nothing to inject into. Strip the prefix so downstream
# treats it as a normal user message; an empty payload surfaces the usage hint.
steer_payload = event.get_command_args().strip()
if not steer_payload:
return True, "Usage: /steer <prompt> (no agent is running; sending as a normal message)"
with suppress(Exception):
event.text = steer_payload
# Do NOT return — fall through to _handle_message_with_agent at the end of this function
# so the rewritten text is sent to the agent as a regular user turn.
if canonical == "moa":
# /moa is one-shot sugar only: run a single prompt through the default MoA preset, then
# restore the prior model. To *switch* to a MoA preset for the session, pick it from the
# model picker (MoA presets surface as a virtual "Mixture of Agents" provider).
from hermes_cli.moa_config import (
moa_usage,
normalize_moa_config,
)
from hermes_cli.config import load_config
moa_payload = event.get_command_args().strip()
if not moa_payload:
return True, moa_usage()
try:
cfg = load_config()
moa_cfg = normalize_moa_config(cfg.get("moa") if isinstance(cfg, dict) else {})
except Exception:
moa_cfg = normalize_moa_config({})
preset = moa_cfg["default_preset"]
try:
event.text = moa_payload
_moa_state = self._session_state(_quick_key)
event._moa_restore_override = _moa_state.conversation.model_override
_moa_state.conversation.model_override = {
"provider": "moa",
"model": preset,
"base_url": "moa://local",
"api_key": "moa-virtual-provider",
"api_mode": "chat_completions",
}
self._evict_cached_agent(_quick_key)
event._moa_disable_after_turn = True
except Exception:
return True, "Failed to prepare MoA turn."
return False, None
async def _hm_dispatch_quick_and_plugin_commands(
self, event: "MessageEvent", source: SessionSource, command: Optional[str]
) -> Tuple[bool, Optional[str], Optional[str]]:
"""Drain gate, user-defined quick commands (exec/alias) and plugin slash commands.
Returns ``(handled, result, command)`` — an alias quick command rewrites ``command``.
"""
if self._draining:
return True, f"⏳ Gateway is {self._status_action_gerund()} and is not accepting new work right now.", command
# 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:
# Quick commands are slash capabilities too — and type:exec ones run a shell command
# in the gateway process. The early gate only fires for registry-known commands and
# quick commands are never in the registry, so apply the same admin/user policy to
# the raw typed name here so non-admins can't invoke admin-only quick commands.
_denied = self._check_slash_access(source, command)
if _denied is not None:
return True, _denied, command
qcmd = quick_commands[command]
if qcmd.get("type") == "exec":
exec_cmd = qcmd.get("command", "")
if exec_cmd:
try:
# Sanitize env to prevent credential leakage — quick commands run in the
# gateway process, which has all API keys in os.environ.
from tools.environments.local import build_subprocess_env
sanitized_env = build_subprocess_env()
proc = await asyncio.create_subprocess_shell(
exec_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=sanitized_env,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30)
output = (stdout or stderr).decode().strip()
# Redact any remaining sensitive patterns in output
if output:
from agent.redact import redact_sensitive_text
output = redact_sensitive_text(output)
return True, output if output else "Command returned no output.", command
except asyncio.TimeoutError:
return True, "Quick command timed out (30s).", command
except Exception as e:
return True, f"Quick command error: {e}", command
else:
return True, f"Quick command '/{command}' has no command defined.", command
elif qcmd.get("type") == "alias":
target = (qcmd.get("target") or "").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.split()[0] if target_command else target_command
# Fall through to normal command dispatch below
else:
return True, f"Quick command '/{command}' has no target defined.", command
else:
return True, f"Quick command '/{command}' has unsupported type (supported: 'exec', 'alias').", command
# 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 _build_telegram_menu).
plugin_handler = get_plugin_command_handler(command.replace("_", "-"))
if plugin_handler:
user_args = event.get_command_args().strip()
result = plugin_handler(user_args)
if asyncio.iscoroutine(result):
result = await result
return True, str(result) if result else None, command
except Exception as e:
logger.warning("Plugin command dispatch failed: %s", e)
return False, None, command
def _hm_skill_slash_rewrite(
self,
event: "MessageEvent",
source: SessionSource,
_quick_key: str,
command: Optional[str],
) -> Optional[str]:
"""Rewrite ``/<bundle>`` / ``/<skill>`` invocations into the skill prompt on ``event.text``.
Returns a reply string when the command is disabled/unknown/failed, else None.
"""
from gateway.run import _check_unavailable_skill
from hermes_cli.commands import GATEWAY_KNOWN_COMMANDS
# 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:
# Skill bundles take precedence over individual skill commands —
# /<bundle> loads multiple skills at once. Mirrors CLI dispatch.
_bundle_handled = False
try:
from agent.skill_bundles import (
build_bundle_invocation_message,
resolve_bundle_command_key,
)
bundle_key = resolve_bundle_command_key(command)
if bundle_key is not None:
user_instruction = event.get_command_args().strip()
# Pass the platform explicitly: bundle skill loading bypasses
# get_skill_commands()' scan-time disabled filter, and the gateway serves
# multiple platforms in one process, so env-var platform resolution can't be
# trusted here.
_bundle_plat = source.platform.value if source.platform else None
bundle_result = build_bundle_invocation_message(
bundle_key, user_instruction, task_id=_quick_key,
platform=_bundle_plat,
)
if bundle_result:
msg, _loaded, missing = bundle_result
event.text = msg
_bundle_handled = True
if missing:
logger.info(
"Bundle %s skipped missing skills: %s",
bundle_key, ", ".join(missing),
)
# Fall through to normal message processing with bundle content
except Exception as exc:
logger.warning("Bundle dispatch failed: %s", exc)
if command and not locals().get("_bundle_handled", False):
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()
# Stacked slash-skill invocations: `/skill-a /skill-b do XYZ` loads every
# leading skill (up to 5), not just the first. Mirrors CLI.
try:
from agent.skill_commands import (
build_stacked_skill_invocation_message as _build_stacked,
split_stacked_skill_commands,
)
extra_keys, stacked_instruction = (
split_stacked_skill_commands(user_instruction)
)
except Exception:
_build_stacked = None
extra_keys, stacked_instruction = [], user_instruction
if extra_keys and _plat:
# split_stacked_skill_commands() only resolves that each extra token is a
# KNOWN skill command — like get_skill_commands() itself, it has no per-
# platform view. Re-check every stacked skill against the same disabled list,
# or a skill disabled for this platform still gets loaded via the stack.
from agent.skill_utils import get_disabled_skill_names as _get_plat_disabled
_plat_disabled = _get_plat_disabled(platform=_plat)
_disabled_extra = [
skill_cmds.get(k, {}).get("name", "")
for k in extra_keys
if skill_cmds.get(k, {}).get("name", "") in _plat_disabled
]
if _disabled_extra:
return (
f"The **{', '.join(_disabled_extra)}** skill(s) in this "
f"stacked invocation are disabled for {_plat}.\n"
f"Enable them with: `hermes skills config`"
)
if extra_keys and _build_stacked is not None:
stacked_result = _build_stacked(
[cmd_key, *extra_keys],
stacked_instruction,
task_id=_quick_key,
)
if stacked_result:
msg, _loaded, _missing = stacked_result
event.text = msg
# Fall through to normal message processing
else:
return f"Failed to load stacked skills for /{command}."
else:
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 built-in/plugin/skill/known-inactive):
# warn instead of forwarding to the LLM as free text (it invents tool calls).
# Normalize to hyphenated form first: the quick-command block may have set an
# alias target, 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)
return None
async def _handle_message(self, event: MessageEvent) -> Optional[str]:
"""Handle an incoming message from any platform.
Pipeline: auth → command check → running-agent interrupt → get/create session → build
context → run agent → return response.
"""
from gateway.run import _AGENT_PENDING_SENTINEL
_admitted = await self._hm_admit_event(event)
if _admitted is None:
return None
event, source, is_internal = _admitted
_paused_notice = self._hm_estop_gate(event, source, is_internal)
if _paused_notice is not None:
return _paused_notice
# Replies owned by in-flight work: pending /update prompt, clarify, slash-confirm.
_quick_key = self._session_key_for_source(source)
allow_gateway_control = event.allow_gateway_control
_update_reply = self._hm_update_prompt_reply(event, _quick_key, allow_gateway_control)
if _update_reply is not None:
return _update_reply
_clarify_reply = await self._hm_clarify_reply(
event, source, _quick_key, allow_gateway_control
)
if _clarify_reply is not None:
return _clarify_reply
_confirm_reply = await self._hm_slash_confirm_reply(
event, _quick_key, allow_gateway_control
)
if _confirm_reply is not None:
return _confirm_reply
self._hm_evict_stale_running_agent(_quick_key)
if self._is_session_running(_quick_key):
return await self._hm_handle_running_session_message(event, source, _quick_key)
# Idle path: resolve + dispatch slash commands; rewriting commands fall through to the agent.
_handled, _result, command, canonical = await self._hm_resolve_command(
event, source, _quick_key
)
if _handled:
return _result
_handled, _result = await self._hm_dispatch_canonical_command(
event, source, _quick_key, canonical
)
if _handled:
return _result
_handled, _result, command = await self._hm_dispatch_quick_and_plugin_commands(
event, source, command
)
if _handled:
return _result
_skill_reply = self._hm_skill_slash_rewrite(event, source, _quick_key, command)
if _skill_reply is not None:
return _skill_reply
# Pending exec approvals go through /approve and /deny only — no bare-text matching, or a
# conversational "yes" would execute a dangerous command.
if not is_internal and await asyncio.to_thread(
self._is_telegram_topic_root_lobby, source
):
# Debounce the lobby reminder so a user who forgets about
# topic mode and fires ten prompts doesn't get ten copies.
if self._should_send_telegram_lobby_reminder(source):
return self._telegram_topic_root_lobby_message()
return None
# ── External-drain new-turn gate ─────────────────────────────
# When NAS engaged an external drain (.drain_request.json, seen by _drain_control_watcher),
# refuse to START new turns so the in-flight set can only fall to zero (stop accepting
# FIRST, then NAS polls active_agents==0). Internal/system events bypass; reversible.
if self._external_drain_active and not is_internal:
logger.info(
"Refusing new turn for session %s — external drain active.",
_quick_key,
)
return (
"⏳ This agent is draining for a maintenance action and isn't "
"accepting new turns right now. It'll be back in a moment — "
"please resend shortly."
)
# ── Claim this session before any await ───────────────────────
# Many awaits sit between here and _run_agent registering the real AIAgent; without this
# sentinel a second message during any of them passes the "already running" guard and spins
# up a duplicate agent for the same session, corrupting the transcript.
_active_session_lease, _limit_message = self._claim_active_session_slot(
_quick_key,
source,
)
if _limit_message is not None:
logger.info(
"Rejecting new active session %s: max_concurrent_sessions reached",
_quick_key,
)
return _limit_message
# ── FIFO orphan rescue ───────────────────────────────────────
# A session that went idle with a populated overflow (post-turn drain never promoted, e.g. a
# compression-demoted follow-up) silently orphaned those events. Re-stage them FIFO and
# enqueue this event behind them. Skipped for control commands and internal events.
try:
_orphan_adapter = self._adapter_for_source(source)
if (
_orphan_adapter is not None
and not bool(getattr(event, "internal", False))
and not event.get_command()
):
_rescued = self._rescue_orphaned_overflow(
_quick_key, _orphan_adapter
)
if _rescued is not None:
# The oldest orphan runs as THIS turn. Park the incoming event behind the rest of
# the chain: into the slot when the chain was a single orphan (post-turn drain
# picks it up), otherwise into overflow behind the already-staged next orphan.
self._enqueue_fifo(_quick_key, event, _orphan_adapter)
event = _rescued
# Same session key by construction; carry the orphan's own source so reply
# anchors / thread metadata point at the message actually being answered.
_rescued_source = getattr(_rescued, "source", None)
if _rescued_source is not None:
source = _rescued_source
is_internal = bool(getattr(_rescued, "internal", False))
except Exception:
logger.debug(
"FIFO orphan rescue pre-claim failed for %s",
_quick_key,
exc_info=True,
)
_claim_state = self._session_state(_quick_key)
if _active_session_lease is not None:
_claim_state.turn.lease = _active_session_lease
_claim_state.turn.agent = _AGENT_PENDING_SENTINEL
_claim_state.turn.started_ts = time.time()
self._persist_active_agents()
_run_generation = self._begin_session_run_generation(_quick_key)
try:
try:
_agent_result = await self._handle_message_with_agent(
event, source, _quick_key, _run_generation
)
except TurnLeaseTimeoutError as exc:
# A rejected message, not a completed turn: return before the /goal judge below so
# it cannot consume the resend notice and enqueue a synthetic continuation loop.
logger.error(
"Rejecting turn for routing key %s on session %s after "
"turn-lease timeout; transcript load was not started and "
"the user must resend",
_quick_key,
exc.session_id,
)
return (
"⏳ Another turn is still running on this session. To "
"protect the transcript, this message was not processed. "
"Wait for the active turn to finish, then resend it."
)
try:
await self._run_post_turn_hooks(
agent_result=_agent_result,
source=source,
is_internal=is_internal,
event=event,
)
except Exception as _goal_exc:
logger.debug("post-turn hook failed: %s", _goal_exc)
return _agent_result
finally:
# MoA one-shot restore must run on EVERY exit path: the restore data lives on the
# per-turn event, so a restore in the try block is skipped when the handler raises and
# the override leaks permanently; finally covers success, exception and interrupt.
self._restore_moa_one_shot(event, _quick_key)
self._restore_pending_one_turn_model_override(_quick_key)
# Normal completion/exception/interrupt clears this durable marker; SIGKILL/OOM skips
# finally, leaving it for the next unclean startup's recovery pass.
await self._clear_durable_active_turn(event)
# Unconditional release covers every exit path: _release_running_agent_state is idempotent
# and, without a run_generation guard, clears the slot whichever generation holds it. This
# evicts the zombie left when session_reset bumps the generation mid-flight (gen-N's
# guarded release in _run_agent returns False; a sentinel-only check would lock forever).
self._release_running_agent_state(_quick_key)
# Turn lease: release THIS turn's token — keyed by (routing key, run generation) so this
# unwind can only free the lease its own turn acquired, never a newer turn's.
self._release_turn_lease(_quick_key, _run_generation)
def _restore_moa_one_shot(self, event: "MessageEvent", quick_key: str) -> None:
"""Revert a ``/moa <prompt>`` one-shot model override after its turn.
Called from the message-handling ``finally`` so it fires on success, error or interrupt.
No-op unless ``event._moa_disable_after_turn``; ``_moa_restore_override`` holds the prior
per-session override (``None`` = clear the MoA override outright).
"""
if not getattr(event, "_moa_disable_after_turn", False):
return
try:
_restore = getattr(event, "_moa_restore_override", None)
self._session_state(quick_key).conversation.model_override = _restore
self._evict_cached_agent(quick_key)
except Exception:
pass
def _restore_pending_one_turn_model_override(self, session_key: str) -> None:
"""Restore a per-session model override after ``/model --once`` runs."""
if not session_key:
return
try:
_otr_state = self._peek_session_state(session_key)
snapshot = _otr_state.conversation.one_turn_restore if _otr_state else None
if _otr_state is not None:
_otr_state.conversation.one_turn_restore = None
if not snapshot:
return
self._restore_session_model_override(session_key, snapshot)
except Exception:
logger.debug("Failed to restore one-turn model override", exc_info=True)
def _prefix_inbound_sender_context(self, event: MessageEvent, source: SessionSource, message_text: str) -> str:
"""Attribute the sender in shared multi-user sessions and prepend history-backfill channel context."""
_group_sessions_per_user = getattr(self.config, "group_sessions_per_user", True)
_thread_sessions_per_user = getattr(self.config, "thread_sessions_per_user", False)
_is_shared_multi_user = is_shared_multi_user_session(
source,
group_sessions_per_user=_group_sessions_per_user,
thread_sessions_per_user=_thread_sessions_per_user,
)
if _is_shared_multi_user and source.user_name:
# source.user_name is the platform display name — attacker-influenceable on any
# platform that lets participants set their own name. Neutralize newlines/control chars
# before interpolating it into every message, or a hostile name can masquerade as a
# fake markdown section (mirrors build_session_context_prompt's treatment).
_safe_user_name = neutralize_untrusted_inline_text(source.user_name)
# On Slack, expose the current author's verifiable user ID next to the display name:
# "mention me again" requests need a trusted `<@U...>` target for the CURRENT speaker —
# display names are ambiguous and historical mentions may point at someone else. The
# user_id comes from the Slack event envelope (not user-editable), so no neutralization.
if source.platform == Platform.SLACK and source.user_id:
_safe_user_name = (
f"{_safe_user_name} | Slack user <@{source.user_id}>"
)
message_text = f"[{_safe_user_name}] {message_text}"
# Prepend history-backfill channel context after the sender-prefix so the prefix applies
# only to the trigger message, not the backfill block.
if getattr(event, "channel_context", None):
message_text = f"{event.channel_context}\n\n[New message]\n{message_text}"
return message_text
@staticmethod
def _classify_inbound_media(
event: MessageEvent, pending_stt_prepared: bool
) -> Tuple[list, list, list, list]:
"""Split ``event.media_urls`` into (image, STT-voice, audio-file, video) paths."""
from gateway.run import _event_media_is_audio, _event_media_is_image, _event_media_is_stt_input
image_paths: list[str] = []
audio_paths: list[str] = []
audio_file_paths: list[str] = []
video_paths: list[str] = []
if event.media_urls:
for i, path in enumerate(event.media_urls):
mtype = event.media_types[i] if i < len(event.media_types) else ""
# Classify images per-attachment: trust this attachment's own MIME, and only honour
# the message-level PHOTO type when the per-attachment MIME is unknown. Otherwise a
# document sent alongside an image gets mis-routed as an image and the provider 400s.
if _event_media_is_image(event, i):
image_paths.append(path)
# MessageType.AUDIO = audio file attachment (e.g. .mp3, .m4a) — never STT.
# Mixed DOCUMENT events also preserve audio as a file path instead of
# dropping it or treating it as a voice note.
if _event_media_is_audio(event, i):
if event.message_type in {MessageType.AUDIO, MessageType.DOCUMENT}:
audio_file_paths.append(path)
elif not pending_stt_prepared and _event_media_is_stt_input(event, i):
audio_paths.append(path)
if mtype.startswith("video/") or (not mtype and event.message_type == MessageType.VIDEO):
video_paths.append(path)
return image_paths, audio_paths, audio_file_paths, video_paths
async def _enrich_inbound_images(
self, source: SessionSource, session_key: str, message_text: str, image_paths: list[str]
) -> str:
# Decide routing: native (attach pixels) vs text (vision_analyze pre-run + prepend
# description). See agent/image_routing.py. Offloaded to a thread: the decision does
# blocking network I/O (models.dev fetch on cache miss, Ollama /api/show probe) whose
# timeout would otherwise stall the whole gateway event loop.
_img_mode = await asyncio.to_thread(
self._decide_image_input_mode,
source=source,
session_key=session_key,
)
if _img_mode == "native":
# Defer attachment to the run_conversation call site.
self._session_state(
session_key
).persistent.native_image_paths = list(image_paths)
logger.info(
"Image routing: native (model supports vision). %d image(s) will be attached inline.",
len(image_paths),
)
else:
logger.info(
"Image routing: text (mode=%s). Pre-analyzing %d image(s) via vision_analyze.",
_img_mode, len(image_paths),
)
# Vision enrichment runs before AIAgent.run_conversation(),
# so bind this session's resolved runtime explicitly rather
# than consulting process-global compatibility mirrors.
vision_runtime = None
try:
turn_model, runtime_kwargs = self._resolve_session_agent_runtime(
source=source,
session_key=session_key,
)
vision_runtime = dict(runtime_kwargs or {})
vision_runtime["model"] = turn_model
except Exception:
logger.debug(
"vision enrichment: session runtime resolution failed",
exc_info=True,
)
from agent.auxiliary_client import scoped_runtime_main
with scoped_runtime_main(vision_runtime):
message_text = await self._enrich_message_with_vision(
message_text,
image_paths,
)
return message_text
async def _enrich_inbound_voice(
self, event: MessageEvent, source: SessionSource, message_text: str, audio_paths: list[str]
) -> str:
message_text, _successful_transcripts = await self._enrich_message_with_transcription(
message_text,
audio_paths,
)
# Echo each successful transcript back to the user immediately when configured. Lets
# users verify STT quality in real-time, while allowing quiet STT for users who only
# want the agent to receive the transcription.
if _successful_transcripts and self._should_echo_stt_transcripts():
_echo_adapter = self._adapter_for_source(source)
_echo_meta = self._thread_metadata_for_source(source, self._reply_anchor_for_event(event))
if _echo_adapter:
for _tx in _successful_transcripts:
try:
await _echo_adapter.send(
source.chat_id,
f'🎙️ "{_tx}"',
metadata=_echo_meta,
)
except Exception as _echo_exc:
logger.debug(
"Transcript echo failed (non-fatal): %s", _echo_exc,
)
# On transcription failure, do NOT send a hardcoded notice here: that bypassed the
# LLM and produced two replies (one pre-canned, TTS'd in the wrong language).
# Enrichment leaves a single neutral marker so the LLM gives one localized reply.
return message_text
@staticmethod
def _prepend_inbound_media_file_notes(message_text: str, audio_file_paths: list[str], video_paths: list[str]) -> str:
if audio_file_paths:
from tools.credential_files import to_agent_visible_cache_path as _to_agent_path
for _apath in audio_file_paths:
_basename = os.path.basename(_apath)
_parts = _basename.split("_", 2)
_display = _parts[2] if len(_parts) >= 3 else _basename
_display = re.sub(r'[^\w.\- ]', '_', _display)
_agent_path = _to_agent_path(_apath)
_note = (
f"[The user sent an audio file attachment: '{_display}'. "
f"It is saved at: {_agent_path}. "
f"Its content is not inlined here. If the user's request involves "
f"what the audio contains, transcribe or process it yourself — for "
f"example by passing the path to a transcription or media tool — "
f"instead of asking the user to describe it. Only ask what to do "
f"with it if their intent is genuinely unclear.]"
)
message_text = f"{_note}\n\n{message_text}"
if video_paths:
from tools.credential_files import to_agent_visible_cache_path as _to_agent_path
for _vpath in video_paths:
_basename = os.path.basename(_vpath)
_parts = _basename.split("_", 2)
_display = _parts[2] if len(_parts) >= 3 else _basename
_display = re.sub(r'[^\w.\- ]', '_', _display)
_agent_path = _to_agent_path(_vpath)
_note = (
f"[The user sent a video attachment: '{_display}'. "
f"It is saved at: {_agent_path}. "
f"Its content is not inlined here. If the user's request involves "
f"what the video contains, inspect or process it yourself — for "
f"example by passing the path to a video analysis or media tool — "
f"instead of asking the user to describe it. Only ask what to do "
f"with it if their intent is genuinely unclear.]"
)
message_text = f"{_note}\n\n{message_text}"
return message_text
@staticmethod
def _prepend_inbound_document_notes(event: MessageEvent, message_text: str) -> str:
from gateway.run import (
_build_document_context_note,
_event_media_is_audio,
_event_media_is_image,
_event_media_is_video,
)
if event.media_urls:
import mimetypes as _mimetypes
from tools.credential_files import to_agent_visible_cache_path
_TEXT_EXTENSIONS = {".txt", ".md", ".csv", ".log", ".json", ".xml", ".yaml", ".yml", ".toml", ".ini", ".cfg"}
for i, path in enumerate(event.media_urls):
# Per-attachment document handling: skip anything already routed as image/audio/video
# above; only genuine non-media files get a context note. A document mixed into a
# PHOTO/VOICE message (message-level type != DOCUMENT) thus still reaches the agent.
if (
_event_media_is_image(event, i)
or _event_media_is_audio(event, i)
or _event_media_is_video(event, i)
):
continue
mtype = event.media_types[i] if i < len(event.media_types) else ""
if mtype in {"", "application/octet-stream"}:
_ext = os.path.splitext(path)[1].lower()
if _ext in _TEXT_EXTENSIONS:
mtype = "text/plain"
else:
guessed, _ = _mimetypes.guess_type(path)
mtype = guessed or "application/octet-stream"
# Any accepted file gets a path-pointing context note — we accept
# all file types now, so a non-text/non-application MIME (font/*,
# model/*, etc.) must still tell the agent the file exists.
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)
# Translate host cache path to in-container path if running under Docker backend.
# This ensures the agent receives a path it can open inside its sandbox, as the
# cache directories are auto-mounted at /root/.hermes/cache/* by get_cache_directory_mounts().
agent_path = to_agent_visible_cache_path(path)
inline_flags = getattr(event, "media_text_inlined", None) or []
inline_flag = inline_flags[i] if i < len(inline_flags) else None
context_note = _build_document_context_note(
display_name,
agent_path,
mtype,
content_inlined=inline_flag is not False,
)
message_text = f"{context_note}\n\n{message_text}"
return message_text
@staticmethod
def _prepend_inbound_reply_context(event: MessageEvent, source: SessionSource, message_text: str) -> str:
# Discord: surface the triggering message id per-turn on the user message rather than in the
# cached system prompt. message_id changes every turn, so baking it into
# build_session_context_prompt() would bust the agent-cache signature and rebuild the
# AIAgent every message (destroying prompt caching).
if (
source is not None
and getattr(source, "platform", None) == Platform.DISCORD
and getattr(event, "message_id", None)
):
from gateway.session import _discord_tools_loaded as _disc_tools_loaded
if _disc_tools_loaded():
message_text = (
f"[Triggering message id: `{event.message_id}` — use as "
f"`message_id` for reply/react/pin via the discord tools.]\n\n"
f"{message_text}"
)
if getattr(event, "reply_to_text", None) and event.reply_to_message_id:
# Always inject the reply-to pointer — even when the quoted text already appears in
# history. The prefix isn't deduplication, it's disambiguation: it tells the agent
# *which* prior message the user is referencing. Token overhead is minimal.
reply_snippet = event.reply_to_text[:500]
if getattr(event, "reply_to_is_own_message", False):
message_text = (
f'[Replying to your previous message: "{reply_snippet}"]\n\n'
f"{message_text}"
)
else:
message_text = f'[Replying to: "{reply_snippet}"]\n\n{message_text}'
return message_text
async def _expand_inbound_context_references(
self, source: SessionSource, session_key: str, message_text: str
) -> Optional[str]:
"""Expand ``@`` context references; returns None when the injection was refused (user notified)."""
from gateway.run import _load_gateway_config
try:
from agent.context_references import preprocess_context_references_async
from agent.model_metadata import get_model_context_length_async
try:
from tools.terminal_scope import terminal_env as _ts_env
except ImportError:
_msg_cwd = os.environ.get("TERMINAL_CWD", os.path.expanduser("~"))
else:
_msg_cwd = _ts_env("TERMINAL_CWD", os.path.expanduser("~"))
_msg_config_ctx = None
_msg_cfg = None
_msg_model_cfg = {}
_msg_custom_providers = []
try:
_msg_cfg = _load_gateway_config()
_msg_model_cfg = _msg_cfg.get("model", {})
if isinstance(_msg_model_cfg, dict):
_msg_raw_ctx = _msg_model_cfg.get("context_length")
if _msg_raw_ctx is not None:
_msg_config_ctx = int(_msg_raw_ctx)
try:
from hermes_cli.config import get_compatible_custom_providers
_msg_custom_providers = get_compatible_custom_providers(_msg_cfg)
except Exception:
_msg_custom_providers = _msg_cfg.get("custom_providers") or []
except Exception:
pass
# Resolve the session's actual model/provider/base_url as the hygiene compression
# block does; GatewayRunner has no self._model/self._base_url (AttributeError,
# silently caught below).
_msg_model, _msg_runtime = self._resolve_session_agent_runtime(
source=source,
session_key=session_key,
user_config=_msg_cfg,
)
_msg_base_url = _msg_runtime.get("base_url") or ""
# A global model.context_length belongs to the configured
# model, not a session /model or channel override. Prefer a
# matching per-custom-provider model limit when available.
_msg_configured_model = (
_msg_model_cfg.get("default") or _msg_model_cfg.get("model")
if isinstance(_msg_model_cfg, dict)
else _msg_model_cfg
)
if _msg_model != _msg_configured_model:
_msg_config_ctx = None
if _msg_config_ctx is not None and isinstance(_msg_model_cfg, dict):
try:
from hermes_cli.route_identity import should_clear_context_pin_async
if await should_clear_context_pin_async(
None, # model match already checked above
None,
_msg_model_cfg.get("base_url"),
_msg_base_url,
_msg_model_cfg.get("provider"),
_msg_runtime.get("provider"),
):
_msg_config_ctx = None
except Exception:
_msg_config_ctx = None
if _msg_custom_providers and _msg_base_url:
try:
from hermes_cli.config import get_custom_provider_context_length
_msg_custom_ctx = get_custom_provider_context_length(
model=_msg_model,
base_url=_msg_base_url,
custom_providers=_msg_custom_providers,
)
if _msg_custom_ctx:
_msg_config_ctx = _msg_custom_ctx
except Exception:
pass
_msg_ctx_len = await get_model_context_length_async(
_msg_model,
base_url=_msg_base_url,
api_key=_msg_runtime.get("api_key") or "",
config_context_length=_msg_config_ctx,
provider=_msg_runtime.get("provider") or "",
custom_providers=_msg_custom_providers,
)
_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._adapter_for_source(source)
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.warning("@ context reference expansion failed: %s", exc)
logger.debug("@ context reference expansion failure detail", exc_info=True)
return message_text
async def _prepare_inbound_message_text(
self,
*,
event: MessageEvent,
source: SessionSource,
history: List[Dict[str, Any]],
session_key: Optional[str] = None,
) -> Optional[str]:
"""Prepare inbound event text for the agent.
Shared by the normal inbound and queued follow-up paths so attribution, image enrichment,
STT, document notes, reply context and @ references behave the same. Side effect: buffers
per-session native image paths when the model supports native vision; the caller consumes
that buffer at ``run_conversation``. Empty list means the text vision path already ran.
"""
history = history or []
_pending_stt_prepared = hasattr(event, "_gateway_pending_stt_text")
message_text = (
getattr(event, "_gateway_pending_stt_text", None)
if _pending_stt_prepared
else event.text
) or ""
# Prefer the caller's resolved session key so this write key matches the consume key at the
# run_conversation site; derive it here only for tests and legacy standalone callers.
session_key = session_key or self._session_key_for_source(source)
# Reset only this session's per-call buffer; other sessions may be
# concurrently preparing multimodal turns on the same runner.
self._consume_pending_native_image_paths(session_key)
message_text = self._prefix_inbound_sender_context(event, source, message_text)
image_paths, audio_paths, audio_file_paths, video_paths = self._classify_inbound_media(
event, _pending_stt_prepared
)
if image_paths:
message_text = await self._enrich_inbound_images(source, session_key, message_text, image_paths)
if audio_paths:
message_text = await self._enrich_inbound_voice(event, source, message_text, audio_paths)
message_text = self._prepend_inbound_media_file_notes(message_text, audio_file_paths, video_paths)
message_text = self._prepend_inbound_document_notes(event, message_text)
message_text = self._prepend_inbound_reply_context(event, source, message_text)
if "@" in message_text:
message_text = await self._expand_inbound_context_references(source, session_key, message_text)
if message_text is None:
return None
return message_text
async def _prepare_profile_scoped_inbound_message_text(
self,
*,
event: MessageEvent,
source: SessionSource,
history: List[Dict[str, Any]],
session_key: Optional[str] = None,
) -> Optional[str]:
"""Run inbound preprocessing under the routed profile when multiplexed."""
from gateway.run import _async_profile_runtime_scope
if getattr(getattr(self, "config", None), "multiplex_profiles", False):
async with _async_profile_runtime_scope(
self._resolve_profile_home_for_source(source)
):
return await self._prepare_inbound_message_text(
event=event,
source=source,
history=history,
session_key=session_key,
)
return await self._prepare_inbound_message_text(
event=event,
source=source,
history=history,
session_key=session_key,
)
async def _prepare_clarify_reply_text(self, event) -> str:
"""Return raw text or successful voice transcripts for a clarify reply."""
if not self._pending_event_audio_paths(event):
return (event.text or "").strip()
_, successful_transcripts = await self._transcribe_pending_audio_event_once(
event, "",
)
return "\n\n".join(
transcript.strip()
for transcript in successful_transcripts
if transcript.strip()
)
def _consume_pending_native_image_paths(self, session_key: str) -> List[str]:
state = self._peek_session_state(session_key)
if state is None or not state.persistent.native_image_paths:
return []
paths = list(state.persistent.native_image_paths)
state.persistent.native_image_paths = []
return paths
async def _mark_durable_active_turn(
self,
event: "MessageEvent",
session_key: str,
) -> bool:
"""Persist the exact resolved routing key for this running turn."""
try:
token = await self.async_session_store.mark_turn_active(session_key)
except Exception as exc:
logger.warning(
"Could not persist active-turn marker for %s: %s",
session_key,
exc,
)
return False
if not token:
return False
# Private event attributes are process-local ownership state. Keep the
# token out of public metadata, transcripts, and platform payloads.
setattr(event, "_gateway_active_turn_session_key", session_key)
setattr(event, "_gateway_active_turn_token", token)
return True
async def _clear_durable_active_turn(self, event: "MessageEvent") -> bool:
"""Best-effort CAS clear of the marker owned by *event*."""
session_key = getattr(event, "_gateway_active_turn_session_key", None)
token = getattr(event, "_gateway_active_turn_token", None)
try:
if not session_key or not token:
return False
last_error: Optional[Exception] = None
for attempt in range(1, 4):
try:
return bool(
await self.async_session_store.clear_turn_active(
session_key, token
)
)
except Exception as exc:
last_error = exc
if attempt < 3:
logger.debug(
"Retrying active-turn marker cleanup for %s (%d/3): %s",
session_key,
attempt,
exc,
)
# Never let marker cleanup block agent/lease release; a stale marker is bounded by the
# agent timeout and the clean-start orphan-marker discard path.
logger.warning(
"Could not clear active-turn marker for %s after 3 attempts: %s",
session_key,
last_error,
)
return False
finally:
for attr in (
"_gateway_active_turn_session_key",
"_gateway_active_turn_token",
):
with suppress(AttributeError):
delattr(event, attr)
def _install_plugin_message_injector(self) -> None:
"""Publish this live gateway's plugin message scheduler."""
from hermes_cli.plugins import get_plugin_manager
get_plugin_manager().set_gateway_message_injector(
self,
self._schedule_plugin_message_injection,
)
def _clear_plugin_message_injector(self) -> None:
"""Remove this runner's scheduler without clobbering a newer owner."""
from hermes_cli.plugins import get_plugin_manager
get_plugin_manager().clear_gateway_message_injector(self)
def _schedule_plugin_message_injection(
self,
*,
session_key: str,
content: str,
plugin_id: str,
) -> bool:
"""Schedule a plugin-triggered turn on the live gateway loop."""
from gateway.run import safe_schedule_threadsafe
loop = getattr(self, "_gateway_loop", None)
if not getattr(self, "_running", False) or loop is None or loop.is_closed():
return False
coro = self._dispatch_plugin_message_injection(
session_key=session_key,
content=content,
plugin_id=plugin_id,
)
try:
current_loop = asyncio.get_running_loop()
except RuntimeError:
current_loop = None
if current_loop is loop:
try:
future = loop.create_task(coro)
except Exception:
coro.close()
logger.warning(
"Plugin message injection scheduling failed",
exc_info=True,
)
return False
self._background_tasks.add(future)
future.add_done_callback(self._background_tasks.discard)
else:
future = safe_schedule_threadsafe(
coro,
loop,
logger=logger,
log_message="Plugin message injection scheduling failed",
log_level=logging.WARNING,
)
if future is None:
return False
def _log_result(completed) -> None:
try:
accepted = completed.result()
except (asyncio.CancelledError, concurrent.futures.CancelledError):
return
except Exception:
logger.warning(
"Plugin message injection failed: plugin=%s session=%s",
plugin_id,
session_key,
exc_info=True,
)
return
if not accepted:
logger.warning(
"Plugin message injection was not routed: plugin=%s session=%s",
plugin_id,
session_key,
)
future.add_done_callback(_log_result)
return True
async def _dispatch_plugin_message_injection(
self,
*,
session_key: str,
content: str,
plugin_id: str,
) -> bool:
"""Route a plugin-triggered turn through the session's live adapter."""
if not getattr(self, "_running", False) or getattr(self, "_draining", False):
return False
entry = await self.async_session_store.lookup_by_session_key(session_key)
if entry is None or entry.origin is None:
return False
if not getattr(self, "_running", False) or getattr(self, "_draining", False):
return False
source = dataclasses.replace(entry.origin)
try:
if not self._is_user_authorized(
source,
allow_adapter_delegation=False,
):
logger.warning(
"Plugin message injection denied by current gateway authorization: "
"plugin=%s session=%s",
plugin_id,
session_key,
)
return False
except Exception:
logger.warning(
"Plugin message injection authorization check failed: "
"plugin=%s session=%s",
plugin_id,
session_key,
exc_info=True,
)
return False
adapter = self._adapter_for_source(source)
if adapter is None:
return False
event = MessageEvent(
text=content,
message_type=MessageType.TEXT,
source=source,
internal=True,
allow_gateway_control=False,
metadata={
"hermes_plugin_id": plugin_id,
"hermes_plugin_injection": True,
"gateway_session_key": session_key,
"gateway_session_id": entry.session_id,
"gateway_session_strict": True,
},
)
await adapter.handle_message(event)
logger.info(
"Plugin message injection dispatched: plugin=%s session=%s session_id=%s",
plugin_id,
session_key,
entry.session_id,
)
return True
def _decide_image_input_mode(
self,
*,
source: Optional[SessionSource] = None,
session_key: Optional[str] = None,
user_config: Optional[dict] = None,
provider: Optional[str] = None,
model: Optional[str] = None,
) -> str:
"""Resolve image-input routing for the effective model this turn.
Returns ``"native"`` (attach pixels on the user turn) or ``"text"`` (pre-analyze with
vision_analyze and prepend the description); see agent/image_routing.py. Gateway sessions
can carry /model overrides and image preprocessing runs before AIAgent sets the
auxiliary_client runtime globals, so resolve the per-session runtime bundle the upcoming
turn will use, not just the persisted default.
"""
try:
from agent.image_routing import decide_image_input_mode
from agent.auxiliary_client import _read_main_model, _read_main_provider
from hermes_cli.config import load_config
cfg = user_config if isinstance(user_config, dict) else load_config()
resolved_provider = (provider or "").strip()
resolved_model = (model or "").strip()
resolved_requested_provider = ""
needs_session_runtime = not resolved_provider or not resolved_model
has_session_identity = source is not None or session_key
if needs_session_runtime and has_session_identity:
try:
turn_model, runtime_kwargs = self._resolve_session_agent_runtime(
source=source,
session_key=session_key,
user_config=cfg,
)
if not resolved_model and isinstance(turn_model, str):
resolved_model = turn_model.strip()
runtime_provider = runtime_kwargs.get("provider") if isinstance(runtime_kwargs, dict) else None
runtime_requested_provider = (
runtime_kwargs.get("requested_provider")
if isinstance(runtime_kwargs, dict)
else None
)
if not resolved_provider and isinstance(runtime_provider, str):
resolved_provider = runtime_provider.strip()
if isinstance(runtime_requested_provider, str):
resolved_requested_provider = runtime_requested_provider.strip()
except Exception as exc:
logger.debug(
"image_routing: session runtime resolution failed, falling back to config — %s",
exc,
)
if not resolved_provider:
resolved_provider = _read_main_provider()
if not resolved_model:
resolved_model = _read_main_model()
return decide_image_input_mode(
resolved_provider,
resolved_model,
cfg,
requested_provider=resolved_requested_provider,
)
except Exception as exc:
logger.debug("image_routing: decision failed, falling back to text — %s", exc)
return "text"
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.
Description *and* local cache path are injected so the model understands the image without
a tool call and can re-examine it with vision_analyze. Returns the enriched message string.
"""
from tools.vision_tools import vision_analyze_tool
from agent.memory_manager import sanitize_context
analysis_prompt = (
"Concisely describe this image in 2-4 sentences "
"(~200 Chinese characters or ~150 English words). "
"Cover the main subject, key visible text/data/code, and overall context. "
"If it is a chart, diagram, or scientific figure, include the important "
"labels, legend, and key values. Skip decorative details."
)
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", "")
description = sanitize_context(description)
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],
) -> tuple[str, List[str]]:
"""Auto-transcribe user voice/audio messages using the configured STT provider and prepend
the transcript to the message text.
Returns ``(enriched_text, successful_transcripts)``: the message with transcription
wrappers prepended, and the raw transcripts of successfully transcribed clips in input
order (empty if every clip failed or STT is disabled) so callers can echo them back to
the user before the agent loop.
"""
from gateway.run import _probe_audio_duration
seen = set()
audio_paths = [p for p in audio_paths if p not in seen and not seen.add(p)]
if not getattr(self.config, "stt_enabled", True):
notes = []
for path in audio_paths:
abs_path = os.path.abspath(path)
duration_str = await _probe_audio_duration(abs_path)
if duration_str:
notes.append(
f"[The user sent a voice message: {abs_path} (duration: {duration_str})]"
)
else:
notes.append(f"[The user sent a voice message: {abs_path}]")
if not notes:
return user_text, []
prefix = "\n\n".join(notes)
_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, []
try:
from tools.transcription_tools import (
transcribe_audio,
transcribe_audio_local_fallback,
)
except ModuleNotFoundError as e:
logger.error("Transcription module unavailable: %s", e)
unavailable_note = "[voice message could not be transcribed]"
_placeholder = "(The user sent a message with no text content)"
if user_text and user_text.strip() == _placeholder:
return unavailable_note, []
if user_text:
return f"{unavailable_note}\n\n{user_text}", []
return unavailable_note, []
enriched_parts = []
successful_transcripts: List[str] = []
for path in audio_paths:
try:
logger.debug("Transcribing user voice: %s", path)
result = await asyncio.to_thread(
transcribe_audio, path, None, "gateway",
)
if not result.get("success"):
fallback = await asyncio.to_thread(
transcribe_audio_local_fallback,
path,
)
if fallback.get("success"):
logger.info(
"Configured STT failed for %s; recovered with local STT",
path,
)
result = fallback
if result["success"]:
transcript = result["transcript"]
# STT may return success=True with an empty/whitespace transcript (silence, cut-off);
# empty quotes make the agent reply to nothing and can loop, so emit a sentinel note.
if not (transcript or "").strip():
enriched_parts.append(
"[The user sent a voice message but it came through "
"empty or inaudible — speech-to-text returned no "
"words. Do not guess at the content; ask the user "
"to resend or type it out.]"
)
continue
successful_transcripts.append(transcript)
# Pass the transcript as a plain quoted line: a "The user sent a voice message..."
# wrapper read as a meta-instruction and made the LLM comment on voice mode instead.
enriched_parts.append(f'"{transcript}"')
else:
error = result.get("error", "unknown error")
# All failure branches: one minimal neutral marker. Never mention "no STT provider",
# setup steps, or a DM sent — persisted in history they poison later turns (the model
# keeps volunteering STT-setup advice). Cause is logged for operators, not the prompt.
logger.info("Voice transcription failed for %s: %s", path, error)
from tools.credential_files import to_agent_visible_cache_path
agent_path = to_agent_visible_cache_path(os.path.abspath(path))
enriched_parts.append(
"[voice message could not be transcribed automatically; "
f"the audio is available at: {agent_path}]"
)
except Exception as e:
logger.error("Transcription error: %s", e)
from tools.credential_files import to_agent_visible_cache_path
agent_path = to_agent_visible_cache_path(os.path.abspath(path))
enriched_parts.append(
"[voice message could not be transcribed automatically; "
f"the audio is available at: {agent_path}]"
)
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, successful_transcripts
if user_text:
return f"{prefix}\n\n{user_text}", successful_transcripts
return prefix, successful_transcripts
return user_text, successful_transcripts
def _pending_event_audio_paths(self, event) -> List[str]:
"""Return STT-eligible paths from a pending voice message."""
from gateway.run import _event_media_is_stt_input
audio_paths: List[str] = []
media_urls = getattr(event, "media_urls", None) or []
for i, path in enumerate(media_urls):
if _event_media_is_stt_input(event, i):
audio_paths.append(path)
return audio_paths
async def _transcribe_pending_audio_event_once(
self,
event,
user_text: Optional[str] = None,
) -> tuple[str | None, List[str]]:
"""Transcribe a pending audio event once and cache the result on the event.
The interrupt monitor and the pending-drain path both need the transcript; caching keeps
it to one STT call and one transcript echo per platform message.
"""
if hasattr(event, "_gateway_pending_stt_text"):
cached_text = getattr(event, "_gateway_pending_stt_text")
cached_transcripts = getattr(event, "_gateway_pending_stt_transcripts", []) or []
return cached_text, list(cached_transcripts)
audio_paths = self._pending_event_audio_paths(event)
if not audio_paths:
return user_text if user_text is not None else (getattr(event, "text", None) or None), []
text = user_text if user_text is not None else (getattr(event, "text", "") or "")
enriched_text, successful_transcripts = await self._enrich_message_with_transcription(
text,
audio_paths,
)
setattr(event, "_gateway_pending_stt_text", enriched_text)
setattr(event, "_gateway_pending_stt_transcripts", list(successful_transcripts))
return enriched_text, successful_transcripts
async def _echo_pending_stt_transcripts_once(
self,
event,
adapter,
source,
transcripts: List[str],
*,
metadata=None,
log_context: str = "Transcript",
) -> None:
"""Echo pending-event STT transcripts to the chat at most once.
Tracked as a COUNT (not a set — identical transcripts are distinct deliveries):
``merge_pending_message_event`` can append a second voice note and invalidate the cache,
and the re-run returns earlier transcripts as a prefix, so only the unsent tail is echoed.
"""
if (
not transcripts
or not self._should_echo_stt_transcripts()
or adapter is None
):
return
already_echoed = int(getattr(event, "_gateway_pending_stt_echoed", 0) or 0)
unsent = transcripts[already_echoed:]
setattr(event, "_gateway_pending_stt_echoed", already_echoed + len(unsent))
for tx in unsent:
try:
await adapter.send(
source.chat_id,
f'🎙️ "{tx}"',
metadata=metadata,
)
except Exception as echo_exc:
logger.debug("%s echo failed (non-fatal): %s", log_context, echo_exc)
async def _transcribe_and_echo_pending_voice(
self,
event,
adapter,
source,
text: str,
*,
log_context: str,
metadata=_UNSET,
) -> tuple[str, List[str]]:
"""Transcribe a pending voice event and echo transcripts once.
Returns ``(enriched_text, transcripts)`` for ``agent.interrupt()`` or the pending-drain
flow; ``(text, [])`` unchanged when there is no STT-eligible media (caller owns the
``_build_media_placeholder`` fallback for empty ``text`` with non-audio media).
"""
if not self._pending_event_audio_paths(event):
return text, []
try:
enriched_text, transcripts = await self._transcribe_pending_audio_event_once(
event,
text,
)
echo_meta = self._thread_metadata_for_source(
source,
self._reply_anchor_for_event(event),
) if metadata is _UNSET else metadata
await self._echo_pending_stt_transcripts_once(
event,
adapter,
source,
transcripts,
metadata=echo_meta,
log_context=log_context,
)
return enriched_text or text, transcripts
except Exception as trans_exc:
logger.warning("%s transcription failed: %s", log_context, trans_exc)
return text, []