From 1cdad299a5d61bfa9d138e6bbb257edf3222e8e2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:16:03 -0700 Subject: [PATCH 1/6] =?UTF-8?q?refactor(gateway):=20run=5Fgoals=20?= =?UTF-8?q?=E2=80=94=20shared=20synthetic-event=20builder,=20heartbeat/loo?= =?UTF-8?q?p-wakeup=20per-item=20helpers,=20tighter=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gateway/run_goals.py | 333 ++++++++++++++++++------------------------- 1 file changed, 138 insertions(+), 195 deletions(-) diff --git a/gateway/run_goals.py b/gateway/run_goals.py index f48ccbbe9d..eef4a70442 100644 --- a/gateway/run_goals.py +++ b/gateway/run_goals.py @@ -7,13 +7,13 @@ 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 logging import time from contextlib import suppress +from typing import TYPE_CHECKING, Any + from gateway.platforms.base import MessageEvent, MessageType -from typing import Any if TYPE_CHECKING: # string annotations only; never imported at runtime (cycle) from gateway.run import GatewayRunner, TurnRunner # noqa: F401 @@ -25,14 +25,12 @@ logger = logging.getLogger("gateway.run") class GatewayGoalsMixin: """Goal/heartbeat continuation, post-turn hooks and loop-wakeup watcher methods for GatewayRunner.""" - # ──────────────────────────────────────────────────────────────── - # /goal — persistent cross-turn goals (Ralph-style loop) - # ──────────────────────────────────────────────────────────────── + # ── /goal — persistent cross-turn goals (Ralph-style loop) ────────── def _goal_max_turns_from_config(self) -> int: - """Resolve the configured /goal turn budget for gateway sessions. + """Resolve the configured /goal turn budget. - GatewayRunner.config is a GatewayConfig dataclass, not the full user config mapping, so - top-level blocks such as ``goals`` are only reachable via hermes_cli.config.load_config(). + GatewayRunner.config is a GatewayConfig dataclass, not the full user config, so the + top-level ``goals`` block is only reachable via hermes_cli.config.load_config(). """ try: goals_cfg = ( @@ -51,10 +49,9 @@ class GatewayGoalsMixin: async def _warm_goals_session_db(self, label: str) -> None: """Warm the goals SessionDB cache off-loop (best-effort). - A cold cache runs the state.db init on the loop thread and freezes the loop for the init - duration. The executor hop keeps the profile home override alive under multiplex, so the - warm cache belongs to the caller's profile. On failure the caller falls back to the - bootstrap windows, so a dropped warm-up is a bounded stall, never a crash. + A cold cache runs the state.db init on the loop thread and freezes the loop. The executor + hop keeps the profile home override alive under multiplex. On failure the caller falls + back to the bootstrap windows, so a dropped warm-up is a bounded stall, never a crash. """ try: from hermes_cli.goals import _get_session_db as _warm_goals_db @@ -66,15 +63,14 @@ class GatewayGoalsMixin: async def _session_entry_for_manager(self, event: "MessageEvent", label: str): """Session entry for a /goal or /heartbeat manager, or None when lookup fails. - Warms the SessionDB cache off-loop first: a cold cache freezes the loop for the init - duration and drops the first write while the reply claims it was set. Internal events look - the session up WITHOUT touching activity so they never advance the idle/daily reset clock. + Warms the SessionDB cache first (a cold cache drops the first write while the reply + claims it was set). Internal events never touch activity, so they don't advance the + idle/daily reset clock. """ await self._warm_goals_session_db(label) try: session_entry = await self.async_session_store.get_or_create_session( - event.source, - touch_activity=not bool(getattr(event, "internal", False)), + event.source, touch_activity=not bool(getattr(event, "internal", False)), ) except Exception as exc: logger.debug("%s: session lookup failed: %s", label, exc) @@ -108,6 +104,11 @@ class GatewayGoalsMixin: return None, None return HeartbeatManager(session_id=session_entry.session_id), session_entry + @staticmethod + def _synthetic_prompt_event(source: Any, text: str, *, internal: bool = False) -> MessageEvent: + """Build the TEXT event used to inject a goal/heartbeat/loop prompt into a session.""" + return MessageEvent(text=text, message_type=MessageType.TEXT, source=source, internal=internal) + def _register_heartbeat_watch(self, quick_key: str, source: Any, session_id: str) -> None: """Track a session with an active heartbeat and start the poller. @@ -128,6 +129,32 @@ class GatewayGoalsMixin: if watch: watch.pop(quick_key, None) + async def _heartbeat_poll_once(self, watch: dict) -> None: + """One heartbeat poll pass: enqueue every due prompt of a non-busy watched session.""" + # Warm the cache off-loop once per poll: this only covers the degraded path where the + # /heartbeat command's own warm-up failed. + await self._warm_goals_session_db("heartbeat poll") + for quick_key, (source, session_id) in list(watch.items()): + try: + # Busy sessions coalesce their tick to the next idle poll. + if quick_key in self._running_agents: + continue + from hermes_cli.heartbeat import HeartbeatManager + + mgr = HeartbeatManager(session_id=session_id) + if not mgr.has_heartbeat(): + watch.pop(quick_key, None) + continue + prompt = mgr.due_prompt() + if not prompt: + continue + adapter = self._adapter_for_source(source) + if adapter is None: + continue + self._enqueue_fifo(quick_key, self._synthetic_prompt_event(source, prompt), adapter) + except Exception as exc: + logger.debug("heartbeat poll for %s failed: %s", quick_key, exc) + def _start_heartbeat_poller(self) -> None: """Start the single gateway-wide heartbeat poll task (idempotent).""" existing = getattr(self, "_heartbeat_poll_task", None) @@ -140,46 +167,14 @@ class GatewayGoalsMixin: while True: await asyncio.sleep(POLL_SECONDS) watch = getattr(self, "_heartbeat_watch", None) - if not watch: - continue - # Warm the cache off-loop once per poll. A watch can only be registered through the - # warmed /heartbeat command, so this covers only the degraded path where that warm- - # up failed. - await self._warm_goals_session_db("heartbeat poll") - for quick_key, (source, session_id) in list(watch.items()): - try: - # Busy sessions coalesce their tick to the next idle poll. - if quick_key in self._running_agents: - continue - from hermes_cli.heartbeat import HeartbeatManager - - mgr = HeartbeatManager(session_id=session_id) - if not mgr.has_heartbeat(): - watch.pop(quick_key, None) - continue - prompt = mgr.due_prompt() - if not prompt: - continue - adapter = self._adapter_for_source(source) - if adapter is None: - continue - hb_event = MessageEvent( - text=prompt, - message_type=MessageType.TEXT, - source=source, - message_id=None, - channel_prompt=None, - ) - self._enqueue_fifo(quick_key, hb_event, adapter) - except Exception as exc: - logger.debug("heartbeat poll for %s failed: %s", quick_key, exc) + if watch: + await self._heartbeat_poll_once(watch) try: task = asyncio.create_task(_poll_loop()) self._heartbeat_poll_task = task - # PERMANENT once started (an infinite while-True loop, no exit condition) — same as a - # _spawn_supervised watcher. Tag it so _scale_to_zero_has_live_background_work() doesn't - # treat a gateway with an active heartbeat watch as busy forever. + # PERMANENT once started (infinite loop) — same as a _spawn_supervised watcher. Tag it + # so _scale_to_zero_has_live_background_work() doesn't treat the gateway as busy forever. task._hermes_supervised_watcher = True # type: ignore[attr-defined] _bg = getattr(self, "_background_tasks", None) if _bg is not None: @@ -194,17 +189,14 @@ class GatewayGoalsMixin: if not adapter: logger.debug("goal continuation: no adapter for %s", getattr(source, "platform", None)) return - try: metadata = self._thread_metadata_for_source(source) except Exception: metadata = None - result = await adapter.send(source.chat_id, message, metadata=metadata) if result is not None and not getattr(result, "success", True): logger.warning( - "goal continuation: status send failed: %s", - getattr(result, "error", "unknown error"), + "goal continuation: status send failed: %s", getattr(result, "error", "unknown error"), ) async def _defer_goal_status_notice_after_delivery(self, source: Any, message: str) -> None: @@ -236,11 +228,7 @@ class GatewayGoalsMixin: active = getattr(adapter, "_active_sessions", {}).get(session_key) if active is not None: generation = getattr(active, "_hermes_run_generation", None) - adapter.register_post_delivery_callback( - session_key, - _deliver, - generation=generation, - ) + adapter.register_post_delivery_callback(session_key, _deliver, generation=generation) return except Exception as exc: logger.debug("goal continuation: post-delivery callback registration failed: %s", exc) @@ -248,14 +236,9 @@ class GatewayGoalsMixin: await _deliver() async def _post_turn_goal_continuation( - self, - *, - session_entry: Any, - source: Any, - final_response: str, + self, *, session_entry: Any, source: Any, final_response: str, ) -> None: - """Run the goal judge after a gateway turn and, if still active, enqueue a continuation - prompt for the same session. + """Run the goal judge after a gateway turn and, if still active, enqueue a continuation. Called at turn boundary AFTER delivery. Uses the adapter's pending-message/FIFO machinery so a simultaneous real user message is handled by the same queue and takes priority. @@ -271,9 +254,8 @@ class GatewayGoalsMixin: return max_turns = self._goal_max_turns_from_config() - - # Warm the SessionDB cache off-loop: a cold cache runs the state.db init on the loop thread - # at the turn boundary; a slow init can drop the goal read and silently end the goal loop. + # Cold cache at the turn boundary: a slow state.db init on the loop thread can drop the + # goal read and silently end the goal loop. await self._warm_goals_session_db("goal continuation") mgr = GoalManager(session_id=sid, default_max_turns=max_turns) @@ -286,23 +268,19 @@ class GatewayGoalsMixin: except Exception: _bg_procs = None - # evaluate_after_turn calls judge_goal(), a synchronous HTTP request to the auxiliary LLM; - # on the event-loop thread it blocks Discord heartbeats 10-40 s and flaps connections, so it - # is offloaded to a thread-pool executor. _run_in_executor_with_context (not bare - # run_in_executor): the profile secret scope and aux runtime context are contextvars; a - # default-executor hop drops them and aux credential resolution fails under multiplexing. + # evaluate_after_turn → judge_goal() is a synchronous aux-LLM HTTP call; on the loop thread + # it blocks Discord heartbeats 10-40 s. _run_in_executor_with_context (not bare + # run_in_executor) carries the profile secret scope / aux runtime contextvars, without which + # aux credential resolution fails under multiplexing. decision = await self._run_in_executor_with_context( lambda: mgr.evaluate_after_turn( - final_response or "", - user_initiated=True, - background_processes=_bg_procs, + final_response or "", user_initiated=True, background_processes=_bg_procs, ), ) msg = decision.get("message") or "" - # Defer the status line until after the adapter has delivered the agent's visible final - # response. The judge runs after the response is produced but before BasePlatformAdapter - # sends it, so sending here would show "✓ Goal achieved" before the answer itself. + # Status line is deferred until the adapter has delivered the visible final response, + # otherwise "✓ Goal achieved" would show before the answer itself. if msg and source is not None: await self._defer_goal_status_notice_after_delivery(source, msg) @@ -313,59 +291,41 @@ class GatewayGoalsMixin: if not prompt or source is None: return - # Enqueue via the adapter's FIFO so a user message already in - # flight preempts the continuation naturally. + # Enqueue via the adapter's FIFO so a user message already in flight preempts naturally. try: adapter = self._adapter_for_source(source) _quick_key = self._session_key_for_source(source) if adapter and _quick_key: - cont_event = MessageEvent( - text=prompt, - message_type=MessageType.TEXT, - source=source, - message_id=None, - channel_prompt=None, - ) - self._enqueue_fifo(_quick_key, cont_event, adapter) + self._enqueue_fifo(_quick_key, self._synthetic_prompt_event(source, prompt), adapter) except Exception as exc: logger.debug("goal continuation: enqueue failed: %s", exc) async def _run_post_turn_hooks( - self, - *, - agent_result: Any, - source: Any, - is_internal: bool, - event: Any = None, + self, *, agent_result: Any, source: Any, is_internal: bool, event: Any = None, ) -> None: """Run goal and loop bookkeeping after an agent turn returns.""" final_text = self._final_text_for_post_turn_hooks(agent_result, event) try: session_entry = await self.async_session_store.get_or_create_session( - source, - touch_activity=not is_internal, + source, touch_activity=not is_internal, ) except Exception as exc: logger.debug("post-turn session resolution failed: %s", exc) return - # Empty interrupted/errored responses must not drive /goal, but an - # in-flight /loop tick still needs to be released and rescheduled. + # Empty interrupted/errored responses must not drive /goal, but an in-flight /loop tick + # still needs to be released and rescheduled. if final_text.strip(): try: await self._post_turn_goal_continuation( - session_entry=session_entry, - source=source, - final_response=final_text, + session_entry=session_entry, source=source, final_response=final_text, ) except Exception as exc: logger.debug("goal continuation hook failed: %s", exc) try: await self._post_turn_loop_completion( - session_entry=session_entry, - source=source, - final_response=final_text, + session_entry=session_entry, source=source, final_response=final_text, ) except Exception as exc: logger.debug("loop completion hook failed: %s", exc) @@ -374,7 +334,7 @@ class GatewayGoalsMixin: def _final_text_for_post_turn_hooks(agent_result, event=None) -> str: """Text for /goal and /loop after a gateway turn. - Streamed turns return None from _handle_message_with_agent (already_sent). The delivered + Streamed turns return None from _handle_message_with_agent (already_sent); the delivered reply is stashed on the event so those hooks still see it. """ text = "" @@ -390,11 +350,7 @@ class GatewayGoalsMixin: return text async def _post_turn_loop_completion( - self, - *, - session_entry: Any, - source: Any, - final_response: str, + self, *, session_entry: Any, source: Any, final_response: str, ) -> None: """Complete a /loop wakeup tick after a gateway turn. @@ -412,8 +368,7 @@ class GatewayGoalsMixin: if not sid: return - # Warm the SessionDB cache off-loop: a cold cache at the turn boundary stalls the loop for - # the init duration and can drop the tick-completion write (the /goal continuation seam). + # Cold cache at the turn boundary can drop the tick-completion write (see /goal above). await self._warm_goals_session_db("loop completion") mgr = LoopManager(session_id=sid) @@ -429,6 +384,69 @@ class GatewayGoalsMixin: if msg and source is not None: await self._defer_goal_status_notice_after_delivery(source, msg) + async def _loop_wakeup_fire_one(self, sid: str, state: Any, now: float, warned_no_route: set) -> None: + """Inject one due /loop wakeup into its session, applying every deferral rule.""" + from hermes_cli.loops import LoopManager, goal_blocks_loop_tick + + if state.awaiting_response or now < state.next_due_at: + return + route = state.route or {} + platform_name = route.get("platform", "") + chat_id = route.get("chat_id", "") + if not platform_name or not chat_id: + return # CLI / TUI-owned loop — their own schedulers drive it. + adapter = next((a for p, a in self.adapters.items() if p.value == platform_name), None) + if adapter is None: + if sid not in warned_no_route: + warned_no_route.add(sid) + logger.debug( + "loop wakeup: no adapter for platform %r (session %s)", platform_name, sid, + ) + return + + # Build the source + session key to check business. + source = self._build_process_event_source({ + "session_key": "", + "platform": platform_name, + "chat_id": chat_id, + "chat_type": route.get("chat_type", ""), + "thread_id": route.get("thread_id", ""), + "user_id": route.get("user_id", ""), + "user_name": route.get("user_name", ""), + }) + if source is None: + return + try: + session_key = self._session_key_for_source(source) + except Exception: + session_key = None + if session_key and session_key in self._running_agents: + return # busy — stays due, next scan retries + if goal_blocks_loop_tick(sid): + return + + mgr = LoopManager(session_id=sid) + if not mgr.is_due(now): + return + wakeup = mgr.fire_tick() + if not wakeup: + return + try: + logger.info( + "loop wakeup #%s — injecting for %s chat=%s thread=%s", + mgr.state.ticks_fired if mgr.state else "?", + platform_name, source.chat_id, source.thread_id, + ) + await adapter.handle_message(self._synthetic_prompt_event(source, wakeup, internal=True)) + # Slash-command loops dispatch through the command path and never hit the post-turn + # completion hook — complete the tick immediately (caps + scheduling). + if wakeup.lstrip().startswith("/"): + mgr.complete_tick("") + except Exception as exc: + logger.warning("loop wakeup injection failed for %s: %s", sid, exc) + with suppress(Exception): + mgr.abandon_tick() + async def _loop_wakeup_watcher(self, interval: float = 15.0) -> None: """Fire due /loop wakeups for idle gateway sessions. @@ -443,11 +461,7 @@ class GatewayGoalsMixin: warned_no_route: set = set() while self._running: try: - from hermes_cli.loops import ( - LoopManager, - goal_blocks_loop_tick, - list_active_loops, - ) + from hermes_cli.loops import list_active_loops # Warm the cache off-loop once per scan: the scan reads every persisted loop, so a # cold cache would run the state.db init on the loop thread before the first read. @@ -455,78 +469,7 @@ class GatewayGoalsMixin: now = time.time() for sid, state in list_active_loops(): - if state.awaiting_response or now < state.next_due_at: - continue - route = state.route or {} - platform_name = route.get("platform", "") - chat_id = route.get("chat_id", "") - if not platform_name or not chat_id: - # CLI / TUI-owned loop — their own schedulers drive it. - continue - adapter = None - for p, a in self.adapters.items(): - if p.value == platform_name: - adapter = a - break - if adapter is None: - if sid not in warned_no_route: - warned_no_route.add(sid) - logger.debug( - "loop wakeup: no adapter for platform %r (session %s)", - platform_name, sid, - ) - continue - - # Build the source + session key to check business. - evt_stub = { - "session_key": "", - "platform": platform_name, - "chat_id": chat_id, - "chat_type": route.get("chat_type", ""), - "thread_id": route.get("thread_id", ""), - "user_id": route.get("user_id", ""), - "user_name": route.get("user_name", ""), - } - source = self._build_process_event_source(evt_stub) - if source is None: - continue - try: - session_key = self._session_key_for_source(source) - except Exception: - session_key = None - if session_key and session_key in self._running_agents: - continue # busy — stays due, next scan retries - if goal_blocks_loop_tick(sid): - continue - - mgr = LoopManager(session_id=sid) - if not mgr.is_due(now): - continue - wakeup = mgr.fire_tick() - if not wakeup: - continue - try: - synth_event = MessageEvent( - text=wakeup, - message_type=MessageType.TEXT, - source=source, - internal=True, - ) - logger.info( - "loop wakeup #%s — injecting for %s chat=%s thread=%s", - mgr.state.ticks_fired if mgr.state else "?", - platform_name, source.chat_id, source.thread_id, - ) - await adapter.handle_message(synth_event) - # Slash-command loops dispatch through the command - # path and never hit the post-turn completion hook — - # complete the tick immediately (caps + scheduling). - if wakeup.lstrip().startswith("/"): - mgr.complete_tick("") - except Exception as exc: - logger.warning("loop wakeup injection failed for %s: %s", sid, exc) - with suppress(Exception): - mgr.abandon_tick() + await self._loop_wakeup_fire_one(sid, state, now, warned_no_route) except Exception as exc: logger.debug("loop wakeup watcher error: %s", exc) await asyncio.sleep(interval) From 2f2a860676ad3cd72c6af76f7ea2fc2126ed7b07 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:27:35 -0700 Subject: [PATCH 2/6] =?UTF-8?q?refactor(gateway):=20run=5Finbound=20?= =?UTF-8?q?=E2=80=94=20split=20admit/estop/busy/canonical-command=20god=20?= =?UTF-8?q?methods=20into=20phase=20helpers,=20dict=20dispatch=20for=20can?= =?UTF-8?q?onical=20+=20slash-confirm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gateway/run_inbound.py | 1927 ++++++++++++++++++++-------------------- 1 file changed, 953 insertions(+), 974 deletions(-) diff --git a/gateway/run_inbound.py b/gateway/run_inbound.py index 8b014b1d57..02043bd792 100644 --- a/gateway/run_inbound.py +++ b/gateway/run_inbound.py @@ -38,6 +38,88 @@ logger = logging.getLogger("gateway.run") class GatewayInboundMixin: """Inbound message pipeline (_handle_message, text/media preparation, durable-turn markers, plugin injection) for GatewayRunner.""" + def _hm_pre_gateway_dispatch_hook( + self, event: "MessageEvent", source: SessionSource + ) -> Optional["MessageEvent"]: + """Run the ``pre_gateway_dispatch`` plugin hook; None = drop, else the (maybe rewritten) event. + + 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. + """ + 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) + break + if _action == "allow": + break + return event + + async def _hm_offer_pairing_code(self, source: SessionSource) -> None: + """DM an unauthorized sender a pairing code (rate-limited; groups never reach here).""" + 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 + # Rate-limit ALL pairing responses (code or rejection) so a burst of DMs doesn't spam. + if pairing_store._is_rate_limited(platform_name, source.user_id): + return + code = pairing_store.generate_code(platform_name, source.user_id, source.user_name or "") + adapter = self._adapter_for_source(source) + if code: + 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: + 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) + async def _hm_admit_event( self, event: "MessageEvent" ) -> Optional[Tuple["MessageEvent", SessionSource, bool]]: @@ -51,19 +133,17 @@ class GatewayInboundMixin: 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. + # copies the spawning context: a concurrent message may already have bound ITS + # HERMES_SESSION_* ContextVars, and until _set_session_env binds ours any subprocess would + # read the foreign identity. 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. + # Most adapters resolve profile routes in build_source(); internal/voice paths construct + # SessionSource directly, so resolve those here as the shared fail-closed ingress gate. if ( getattr(getattr(self, "config", None), "multiplex_profiles", False) and not getattr(source, "profile", None) @@ -76,9 +156,8 @@ class GatewayInboundMixin: 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. + # SessionSource owns a strict boolean marker; require the literal value so duck-typed + # test/internal sources with dynamic attributes are not mistaken for a rejection. if getattr(source, "profile_route_rejected", False) is True: logger.warning( "Dropping inbound message because its explicit profile route " @@ -86,8 +165,7 @@ class GatewayInboundMixin: ) return None - # Internal events (e.g. background-process completion notifications) - # are system-generated and must skip user authorization. + # Internal events (e.g. background-process notifications) skip user authorization. is_internal = bool(getattr(event, "internal", False)) # Ignored-channel guard runs FIRST — before startup-restore queueing, plugin hooks, auth, @@ -114,58 +192,20 @@ class GatewayInboundMixin: 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: + # scale-to-zero: only real user-originated inbound stamps the last-inbound clock; + # counting internal/system events would keep a genuinely idle gateway awake. 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 + event = self._hm_pre_gateway_dispatch_hook(event, source) + if event is None: + return None + source = event.source 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. + # 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. if not self._is_user_authorized_for_source(source): logger.debug("Ignoring message with no user_id from %s", source.platform.value) return None @@ -174,197 +214,145 @@ class GatewayInboundMixin: # 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" + 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) + await self._hm_offer_pairing_code(source) return None return event, source, is_internal + def _hm_estop_turn_allowed(self, event: "MessageEvent", source: SessionSource) -> bool: + """Whether a turn may bypass the global emergency stop. + + Pause blocks NEW agent turns, never running work or control traffic: recognized slash + commands (/status, /approve, ... and /pause off as the in-band resume path) and replies + owned by in-flight work — pending update prompt, steering a running session, pending + slash-confirm, dangerous-command approval — all pass through. + """ + 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 + if _resolve_estop_cmd(_estop_cmd) is not None: + return True + except Exception: + pass + 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: + return True + if self._is_session_running(_estop_key): + # Steering / interrupting in-flight work (also covers pending clarify + tool + # approvals held by the running agent). + return True + from tools import slash_confirm as _estop_confirm_mod + if _estop_confirm_mod.get_pending(_estop_key): + return True + from tools.approval import has_blocking_approval as _estop_has_approval + if _estop_has_approval(_estop_key): + return True + except Exception: + pass + return False + 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 the global emergency-stop (`hermes pause`) notice when this turn must be blocked. + + Placed after auth so unauthorized senders can't probe pause state. Internal events from + in-flight work always pass. + """ + if is_internal: + return None + try: + from agent.estop import paused_reply as _estop_paused_reply + _paused_notice = _estop_paused_reply() + except ImportError: + return None + if _paused_notice is None or self._hm_estop_turn_allowed(event, source): + return None + 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 + + @staticmethod + def _hm_write_update_response(response_text: str) -> Optional[str]: + """Atomically hand *response_text* to the detached update process; returns the OSError str.""" + from gateway.run import _hermes_home + 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: + return str(e) 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. + """Consume a reply to a pending ``/update`` prompt; None when nothing was consumed. + + Routes the reply 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 ( + if not ( 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" + return None + raw = (event.text or "").strip() + # Accept /approve and /deny as shorthand for yes/no + cmd = event.get_command() + _recognized_cmd = None + if cmd in {"approve", "yes"}: + response_text = "y" + elif cmd in {"deny", "no"}: + response_text = "n" + else: + if cmd: + try: + from hermes_cli.commands import resolve_command as _resolve_update_cmd + _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: + err = self._hm_write_update_response(response_text) + if err is not None: + logger.warning("Failed to write update response: %s", err) + return f"✗ Failed to send response to update process: {err}" + _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: + err = self._hm_write_update_response("") + if err is None: + logger.info( + "Recognized /%s during pending update prompt for %s; " + "cancelled prompt with default and dispatching command", + _recognized_cmd, + _quick_key, + ) 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 + logger.warning("Failed to write cancel response for pending update prompt: %s", err) + _up_state.persistent.update_prompt_pending = False return None async def _hm_clarify_reply( @@ -446,14 +434,28 @@ class GatewayInboundMixin: ) return None + # Reply → choice for a pending slash-confirm prompt; the command spelling wins over the + # bang/slash-stripped free-text spelling. + _SLASH_CONFIRM_CMD_CHOICES = { + "approve": "once", "yes": "once", "ok": "once", "confirm": "once", + "always": "always", "remember": "always", + "cancel": "cancel", "no": "cancel", "deny": "cancel", "nevermind": "cancel", + } + _SLASH_CONFIRM_TEXT_CHOICES = { + "approve": "once", "approve once": "once", "once": "once", + "always": "always", "always approve": "always", + "cancel": "cancel", "nevermind": "cancel", "no": "cancel", + } + 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. + """Resolve a reply to a pending slash-confirm prompt; None when the message falls through. + + Accepts /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. + """ from tools import slash_confirm as _slash_confirm_mod _pending_confirm = _slash_confirm_mod.get_pending(_quick_key) _tool_approval_live = False @@ -462,324 +464,238 @@ class GatewayInboundMixin: _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 - # `!` — 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) + if not (allow_gateway_control and _pending_confirm and not _tool_approval_live): + return None + # Accept bang-prefixed replies (`!always`, `!cancel`) verbatim: Slack/Matrix show the `!` + # prefix (typed `/` is blocked in Slack threads) and adapters only rewrite + # `!` — confirm keywords aren't commands, so the `!` survives to here. + _norm_reply = (event.text or "").strip().lstrip("!/").lower() + _confirm_choice = ( + self._SLASH_CONFIRM_CMD_CHOICES.get(event.get_command()) + or self._SLASH_CONFIRM_TEXT_CHOICES.get(_norm_reply) + ) + 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.""" + def _hm_evict_idle_stale_agent(self, _quick_key: str) -> None: + """Evict a leaked lock from a hung/crashed handler. + + 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). The pending sentinel is never evicted: it has no get_activity_summary(), so the + idle check would read inf and race the async setup 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): + if _quick_state is None or _quick_state.turn.agent is None or not _stale_ts: + return + _stale_age = time.time() - _stale_ts + _stale_agent = _quick_state.turn.agent + _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: - _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) + _sa = _stale_agent.get_activity_summary() + from gateway.session_stall import resolve_session_idle_seconds_from_activity - 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, + _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 + _sa_d = _sa if isinstance(_sa, dict) else {} + _stale_detail = ( + f" | last_activity={_sa_d.get('last_activity_desc', 'unknown')} " + f"({_stale_idle:.0f}s ago) " + f"| iteration={_sa_d.get('api_call_count', 0)}/{_sa_d.get('max_iterations', 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) ) - # 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. + 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) + + def _hm_evict_reaped_agent(self, _quick_key: str) -> None: + """Evict the in-memory turn slot of a session whose durable row was ended while the gateway lived. + + (``ws_orphan_reap`` / ``agent_close``): otherwise the fast-path queues every next message + into the dead runtime. The cold path then re-attaches via ``get_or_create_session`` → + ``reopen`` or creates a fresh session. + """ + try: + _reap_store = getattr(self, "session_store", None) + # 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 — the + # isinstance() and ``is True`` gates keep this 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) + + def _hm_evict_stale_running_agent(self, _quick_key: str) -> None: + """Evict a leaked/reaped ``_running_agents`` slot before the busy-session fast-path.""" + self._hm_evict_idle_stale_agent(_quick_key) + if self._is_session_running(_quick_key): + self._hm_evict_reaped_agent(_quick_key) + + def _hm_merge_pending_for_source( + self, source: SessionSource, _quick_key: str, event: "MessageEvent", *, merge_text: bool = False + ) -> None: + """Merge *event* into the source adapter's pending slot (no-op without an adapter).""" + from gateway.run import merge_pending_message_event + adapter = self._adapter_for_source(source) + if adapter: + merge_pending_message_event(adapter._pending_messages, _quick_key, event, merge_text=merge_text) + + async def _hm_busy_slash_or_photo( + self, event: "MessageEvent", source: SessionSource, _quick_key: str + ) -> Tuple[bool, Optional[str]]: + """Slash-command and photo-burst handling on the running-agent fast-path. + + Returns ``(handled, result)``. Each command's mid-run behavior is declared on its CommandDef + (busy_policy / busy_handler in hermes_cli/commands.py) — 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. + # /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) + return True, await self._handle_status_command(event) if _cmd_def_inner and _cmd_def_inner.name == "context": - return await self._handle_context_command(event) + return True, 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. + # Slash access control mirrors the cold-path gate so non-admins can't bypass gating just + # because an agent is busy. /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 + return True, _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. + # Any recognized slash command dispatches per its declared busy_policy (dispatch / + # interrupt_then_dispatch / reject). Unrecognized commands and plain text fall through. if _cmd_def_inner: - return await self._dispatch_busy_slash_command( - event, _cmd_def_inner, _quick_key, source, - ) + return True, await self._dispatch_busy_slash_command(event, _cmd_def_inner, _quick_key, source) + # Telegram photo bursts arrive as near-simultaneous updates — never interrupt for a + # photo-only follow-up; adapter-level batching absorbs them. 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 + self._hm_merge_pending_for_source(source, _quick_key, event) + return True, None + return False, None - effective_busy_input_mode = self._effective_busy_input_mode(source) - _telegram_followup_grace = float( - os.getenv("HERMES_TELEGRAM_FOLLOWUP_GRACE_SECONDS", "3.0") - ) + def _hm_busy_telegram_grace_queue( + self, event: "MessageEvent", source: SessionSource, _quick_key: str, effective_busy_input_mode: str + ) -> bool: + """Queue a Telegram text follow-up that lands within the post-start grace window.""" + _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 ( + if not ( 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 + return False + 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: + self._hm_merge_pending_for_source(source, _quick_key, event, merge_text=True) + return True - _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 + def _hm_busy_steer(self, event: "MessageEvent", running_agent: Any, _quick_key: str) -> None: + """Steer mode: inject text mid-run via ``agent.steer()``, else fall back to queue semantics.""" + 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 + logger.debug("PRIORITY steer-fallback-to-queue for session %s", _quick_key) + self._queue_or_replace_pending_event(_quick_key, event) + + async def _hm_busy_interrupt( + self, event: "MessageEvent", source: SessionSource, running_agent: Any, _quick_key: str + ) -> None: + """Interrupt path: redirect text-only corrections when supported, else ``agent.interrupt()``.""" + from gateway.run import _build_media_placeholder # 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 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 + return except Exception as exc: - logger.warning( - "PRIORITY redirect failed for session %s: %s", - _quick_key, - 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 [] @@ -796,8 +712,156 @@ class GatewayInboundMixin: 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. + + 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. + + Default is to interrupt immediately so user text/stop messages are handled with minimal + latency; busy_input_mode queue/steer, subagent and compression protection demote to queue. + """ + from gateway.run import _AGENT_PENDING_SENTINEL + _handled, _result = await self._hm_busy_slash_or_photo(event, source, _quick_key) + if _handled: + return _result + + effective_busy_input_mode = self._effective_busy_input_mode(source) + if self._hm_busy_telegram_grace_queue(event, source, _quick_key, effective_busy_input_mode): + 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. + self._hm_merge_pending_for_source(source, _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": + self._hm_busy_steer(event, running_agent, _quick_key) + return None + # Subagent protection: an interrupt cascades through ``_active_children`` and aborts + # in-flight delegate_task work, so demote to queue 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: an interrupt would start a new turn on the pre-rotation parent + # while compression rotates the id away, forking orphaned siblings. + 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 + await self._hm_busy_interrupt(event, source, running_agent, _quick_key) return None + def _hm_quick_commands(self) -> dict: + """User-defined ``quick_commands`` mapping from config (empty dict when unset/malformed).""" + if isinstance(self.config, dict): + quick_commands = self.config.get("quick_commands", {}) or {} + else: + quick_commands = getattr(self.config, "quick_commands", {}) or {} + return quick_commands if isinstance(quick_commands, dict) else {} + + @staticmethod + def _hm_expand_alias_quick_command(event: "MessageEvent", qcmd: dict) -> Optional[str]: + """Rewrite ``event.text`` to an alias quick command's target; returns the new command name.""" + target = (qcmd.get("target") or "").strip() + if not target: + return None + 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() + return target_command.split()[0] if target_command else target_command + + async def _hm_command_hooks( + self, event: "MessageEvent", source: SessionSource, _quick_key: str, command: str, canonical: str + ) -> Tuple[bool, Optional[str], Optional[str]]: + """Fire ``pre_command`` (observer) and ``command:`` (interceptor) hooks. + + Returns ``(handled, result, new_command)``; ``new_command`` is set when a handler rewrote + the command. The running-agent intercept path deliberately does NOT fire these — a slow or + hostile plugin must not interfere with the operator's escape hatches for a live agent. + """ + 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) + + # Handlers may return ``{"decision": "deny" | "handled" | "rewrite", ...}`` to intercept + # dispatch; handlers returning nothing behave as plain observers. + 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 + message = hook_result.get("message") + message = message if isinstance(message, str) and message else None + if decision == "deny": + return True, message or f"Command `/{command}` was blocked by a hook.", None + if decision == "handled": + return True, message, None + 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() + return False, None, event.get_command() + return False, None, None + async def _hm_resolve_command( self, event: "MessageEvent", source: SessionSource, _quick_key: str ) -> Tuple[bool, Optional[str], Optional[str], Optional[str]]: @@ -806,123 +870,229 @@ class GatewayInboundMixin: 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, - ) + 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. + # Resolve aliases to canonical name so dispatch and hook names don't depend on the alias. _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 + # --provider openrouter reach the /model handler. Built-ins keep 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:`` 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() + quick_commands = self._hm_quick_commands() + qcmd = quick_commands[command] if command in quick_commands else None + if qcmd is not None and qcmd.get("type") == "alias": + new_command = self._hm_expand_alias_quick_command(event, qcmd) + if new_command is not None: + command = new_command _cmd_def = _resolve_cmd(command) if command else None canonical = _cmd_def.name if _cmd_def else command - break + if not (command and canonical and is_gateway_known_command(canonical)): + return False, None, command, canonical + + # Per-platform slash access control: only active when the operator set ``allow_admin_from`` + # for the source's scope; then non-admins get ``user_allowed_commands`` plus the + # /help, /whoami floor. Plain chat is never gated. + _denied = self._check_slash_access(source, canonical) + if _denied is not None: + return True, _denied, command, canonical + + _handled, _result, new_command = await self._hm_command_hooks( + event, source, _quick_key, command, canonical + ) + if _handled: + return True, _result, command, canonical + if new_command is not None: + command = new_command + _cmd_def = _resolve_cmd(command) if command else None + canonical = _cmd_def.name if _cmd_def else command return False, None, command, canonical + async def _hm_cmd_new(self, event, source, _quick_key): + 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, + ) + + async def _hm_cmd_start(self, event, source, _quick_key): + logger.info("Ignoring /start platform ping for session %s", _quick_key) + return True, "" + + async def _hm_cmd_egress(self, event, source, _quick_key): + from hermes_cli.proxy_cli import format_status_text + + return True, format_status_text() + + async def _hm_rewrite_turn_to_prompt(self, event, source, name: str, ack: str, build) -> Tuple[bool, Optional[str]]: + """Ack, then rewrite the turn to ``build()`` and fall through to normal agent processing. + + Mirrors the /blueprint fall-through so role alternation is preserved; no engine, works on + any backend. A failing builder replies with a retry hint instead. + """ + await self._send_command_ack(source, ack, name) + try: + event.text = build() + except Exception: + return True, f"Could not start /{name} — please try again." + return False, None + + async def _hm_cmd_learn(self, event, source, _quick_key): + 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…" + ) + return await self._hm_rewrite_turn_to_prompt( + event, source, "learn", _ack, lambda: build_learn_prompt(_learn_req) + ) + + async def _hm_cmd_plan(self, event, source, _quick_key): + 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…" + ) + return await self._hm_rewrite_turn_to_prompt( + event, source, "plan", _ack, lambda: build_plan_prompt(_plan_task) + ) + + async def _hm_cmd_init(self, event, source, _quick_key): + # /init builds the prompt first: the ack wording depends on whether AGENTS.md exists. + 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 + return False, None + + async def _hm_cmd_blueprint(self, event, source, _quick_key): + _blueprint_result = await self._handle_blueprint_command(event) + _blueprint_seed = getattr(_blueprint_result, "agent_seed", None) + if not _blueprint_seed: + return True, getattr(_blueprint_result, "text", "") or None + # Blueprint matched — rewrite the turn to the seed and fall through so the agent collects + # each slot value conversationally, then calls the cronjob tool (the /steer 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 + return False, None + + async def _hm_cmd_undo(self, event, source, _quick_key): + 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, + ) + + async def _hm_cmd_queue(self, event, source, _quick_key): + queue_payload = event.get_command_args().strip() + if not queue_payload: + return True, "Usage: /queue " + with suppress(Exception): + event.text = queue_payload + return False, None + + async def _hm_cmd_steer(self, event, source, _quick_key): + # No active agent — /steer has nothing to inject into. Strip the prefix so the rewritten + # text is sent to the agent as a regular user turn; an empty payload surfaces the usage hint. + steer_payload = event.get_command_args().strip() + if not steer_payload: + return True, "Usage: /steer (no agent is running; sending as a normal message)" + with suppress(Exception): + event.text = steer_payload + return False, None + + async def _hm_cmd_moa(self, event, source, _quick_key): + # /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 + + # Idle-path built-ins with bespoke flow (confirmations, prompt rewrites, one-shot MoA). Each + # returns ``(handled, result)``; ``(False, None)`` falls through to the agent. + _HM_CANONICAL_COMMANDS = { + "new": "_hm_cmd_new", + "start": "_hm_cmd_start", + "egress": "_hm_cmd_egress", + "learn": "_hm_cmd_learn", + "plan": "_hm_cmd_plan", + "init": "_hm_cmd_init", + "blueprint": "_hm_cmd_blueprint", + "undo": "_hm_cmd_undo", + "queue": "_hm_cmd_queue", + "steer": "_hm_cmd_steer", + "moa": "_hm_cmd_moa", + } + async def _hm_dispatch_canonical_command( self, event: "MessageEvent", @@ -941,183 +1111,36 @@ class GatewayInboundMixin: ) 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 " - 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 (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." - + bespoke = self._HM_CANONICAL_COMMANDS.get(canonical) + if bespoke is not None: + return await getattr(self, bespoke)(event, source, _quick_key) return False, None + async def _hm_run_exec_quick_command(self, command: str, exec_cmd: str) -> str: + """Run a ``type: exec`` quick command in the gateway process (30 s cap, sanitized env).""" + try: + # Sanitize env to prevent credential leakage — the gateway process 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 output if output else "Command returned no output." + except asyncio.TimeoutError: + return "Quick command timed out (30s)." + except Exception as e: + return f"Quick command error: {e}" + async def _hm_dispatch_quick_and_plugin_commands( self, event: "MessageEvent", source: SessionSource, command: Optional[str] ) -> Tuple[bool, Optional[str], Optional[str]]: @@ -1129,62 +1152,28 @@ class GatewayInboundMixin: 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 {} + quick_commands = self._hm_quick_commands() + if command and command in quick_commands: + qcmd = quick_commands[command] + # Quick commands are slash capabilities too — and type:exec ones run a shell command in + # the gateway process. They are never in the registry, so the early gate never fires for + # them; apply the same admin/user policy to the raw typed name here. + _denied = self._check_slash_access(source, command) + if _denied is not None: + return True, _denied, command + qtype = qcmd.get("type") + if qtype == "exec": + exec_cmd = qcmd.get("command", "") + if not exec_cmd: + return True, f"Quick command '/{command}' has no command defined.", command + return True, await self._hm_run_exec_quick_command(command, exec_cmd), command + if qtype == "alias": + new_command = self._hm_expand_alias_quick_command(event, qcmd) + if new_command is None: + return True, f"Quick command '/{command}' has no target defined.", command + command = new_command # Fall through to normal command dispatch below 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 + return True, f"Quick command '/{command}' has unsupported type (supported: 'exec', 'alias').", command # Plugin-registered slash commands if command: @@ -1204,6 +1193,66 @@ class GatewayInboundMixin: return False, None, command + def _hm_bundle_slash_rewrite( + self, event: "MessageEvent", source: SessionSource, _quick_key: str, command: str + ) -> bool: + """Rewrite ``/`` to the bundle invocation message; True when handled. + + Skill bundles take precedence over individual skill commands (mirrors CLI dispatch). + """ + 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 None: + return False + user_instruction = event.get_command_args().strip() + # Pass the platform explicitly: bundle skill loading bypasses get_skill_commands()' + # scan-time disabled filter, and one gateway process serves several platforms, 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 not bundle_result: + return False + msg, _loaded, missing = bundle_result + event.text = msg + if missing: + logger.info("Bundle %s skipped missing skills: %s", bundle_key, ", ".join(missing)) + return True # Fall through to normal message processing with bundle content + except Exception as exc: + logger.warning("Bundle dispatch failed: %s", exc) + return False + + @staticmethod + def _hm_unknown_slash_reply(command: str, source: SessionSource) -> Optional[str]: + """Reply for a /command that is not built-in/plugin/skill; None when it is known.""" + from gateway.run import _check_unavailable_skill + from hermes_cli.commands import GATEWAY_KNOWN_COMMANDS + # Known-but-disabled or uninstalled skill → actionable guidance. + _unavail_msg = _check_unavailable_skill(command) + if _unavail_msg: + return _unavail_msg + # Genuinely unrecognized: 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 the resolved def can be stale. + if command.replace("_", "-") in GATEWAY_KNOWN_COMMANDS: + return None + 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." + ) + def _hm_skill_slash_rewrite( self, event: "MessageEvent", @@ -1214,145 +1263,75 @@ class GatewayInboundMixin: """Rewrite ``/`` / ``/`` invocations into the skill prompt on ``event.text``. Returns a reply string when the command is disabled/unknown/failed, else None. + resolve_skill_command_key() handles the Telegram underscore/hyphen round-trip so + /claude_code from Telegram autocomplete still resolves to the claude-code skill. """ - 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 — - # / 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 not command or self._hm_bundle_slash_rewrite(event, source, _quick_key, command): + return None + 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 None: + return self._hm_unknown_slash_reply(command, source) + # Per-platform disabled check: get_skill_commands() only applies the *global* disabled + # list at scan time and its 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`" ) - 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): + 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 ( - get_skill_commands, - build_skill_invocation_message, - resolve_skill_command_key, + build_stacked_skill_invocation_message as _build_stacked, + split_stacked_skill_commands, ) - 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) + 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 checks each extra token is a KNOWN skill; it + # has no per-platform view, so re-check every stacked skill against the disabled list. + 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 not stacked_result: + return f"Failed to load stacked skills for /{command}." + msg, _loaded, _missing = stacked_result + event.text = msg + 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 + 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]: From 8a323df49f7d1cefc7c8bca55a2e4e29d8ab55e4 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:31:03 -0700 Subject: [PATCH 3/6] =?UTF-8?q?refactor(gateway):=20run=5Finbound=20?= =?UTF-8?q?=E2=80=94=20unify=20attachment=20notes,=20STT=20prefix/failure?= =?UTF-8?q?=20notes,=20context-length=20resolver=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gateway/run_inbound.py | 431 +++++++++++++++++++---------------------- 1 file changed, 195 insertions(+), 236 deletions(-) diff --git a/gateway/run_inbound.py b/gateway/run_inbound.py index 02043bd792..1eb873986c 100644 --- a/gateway/run_inbound.py +++ b/gateway/run_inbound.py @@ -1698,100 +1698,79 @@ class GatewayInboundMixin: 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}" + def _inbound_attachment_display_name(path: str) -> Tuple[str, str]: + """``(display_name, agent_visible_path)`` for a downloaded attachment. - 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) + The cache filename is ``__``; the agent path is translated to the + in-container path under a Docker backend (cache dirs are auto-mounted there). + """ + from tools.credential_files import to_agent_visible_cache_path + basename = os.path.basename(path) + parts = basename.split("_", 2) + display_name = parts[2] if len(parts) >= 3 else basename + return re.sub(r'[^\w.\- ]', '_', display_name), to_agent_visible_cache_path(path) + + @classmethod + def _prepend_inbound_media_file_notes(cls, message_text: str, audio_file_paths: list[str], video_paths: list[str]) -> str: + """Prepend a path-pointing note per audio-file / video attachment (content is not inlined).""" + for kind, noun, verb, tool, paths in ( + ("an audio file attachment", "audio", "transcribe or process", "a transcription or media tool", audio_file_paths), + ("a video attachment", "video", "inspect or process", "a video analysis or media tool", video_paths), + ): + for _path in paths: + _display, _agent_path = cls._inbound_attachment_display_name(_path) _note = ( - f"[The user sent a video attachment: '{_display}'. " + f"[The user sent {kind}: '{_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"what the {noun} contains, {verb} it yourself — for " + f"example by passing the path to {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: + @classmethod + def _prepend_inbound_document_notes(cls, event: MessageEvent, message_text: str) -> str: + """Prepend a context note per non-media attachment (anything not routed as image/audio/video).""" 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 + if not event.media_urls: + return message_text + import mimetypes as _mimetypes - _TEXT_EXTENSIONS = {".txt", ".md", ".csv", ".log", ".json", ".xml", ".yaml", ".yml", ".toml", ".ini", ".cfg"} - for i, path in enumerate(event.media_urls): - # 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}" + _TEXT_EXTENSIONS = {".txt", ".md", ".csv", ".log", ".json", ".xml", ".yaml", ".yml", ".toml", ".ini", ".cfg"} + for i, path in enumerate(event.media_urls): + # A document mixed into a PHOTO/VOICE message (message-level type != DOCUMENT) still + # reaches the agent; only genuine non-media files get a note. + 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" + # Every accepted file gets a note — a non-text/non-application MIME (font/*, model/*) + # must still tell the agent the file exists. + display_name, agent_path = cls._inbound_attachment_display_name(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 @@ -1827,14 +1806,87 @@ class GatewayInboundMixin: message_text = f'[Replying to: "{reply_snippet}"]\n\n{message_text}' return message_text + async def _inbound_model_context_length(self, source: SessionSource, session_key: str) -> int: + """Context length of the model this session's turn will run on (for @-reference budgeting). + + A global ``model.context_length`` pin belongs to the configured model, not a session /model + or channel override; a matching per-custom-provider limit wins when available. + """ + from gateway.run import _load_gateway_config + from agent.model_metadata import get_model_context_length_async + + _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 + # GatewayRunner has no self._model/self._base_url; resolve the session's actual runtime. + _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 "" + _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 + return 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, + ) + 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 @@ -1842,80 +1894,7 @@ class GatewayInboundMixin: _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, - ) + _msg_ctx_len = await self._inbound_model_context_length(source, session_key) _ctx_result = await preprocess_context_references_async( message_text, cwd=_msg_cwd, @@ -2377,18 +2356,65 @@ class GatewayInboundMixin: return prefix return user_text + _EMPTY_TEXT_PLACEHOLDER = "(The user sent a message with no text content)" + + @classmethod + def _prepend_media_prefix(cls, prefix: str, user_text: str) -> str: + """``prefix`` + the user's text; the Discord empty-content placeholder is dropped as redundant.""" + if user_text and user_text.strip() != cls._EMPTY_TEXT_PLACEHOLDER: + return f"{prefix}\n\n{user_text}" + return prefix + + @staticmethod + def _untranscribed_audio_note(path: str) -> str: + """One minimal neutral marker for every STT failure. + + 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). + """ + from tools.credential_files import to_agent_visible_cache_path + + agent_path = to_agent_visible_cache_path(os.path.abspath(path)) + return ( + "[voice message could not be transcribed automatically; " + f"the audio is available at: {agent_path}]" + ) + + async def _transcribe_one_clip(self, path: str, transcribe_audio, transcribe_audio_local_fallback) -> Tuple[Optional[str], str]: + """``(transcript_or_None, note)`` for one clip via configured STT with local fallback.""" + 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 not result["success"]: + logger.info("Voice transcription failed for %s: %s", path, result.get("error", "unknown error")) + return None, self._untranscribed_audio_note(path) + 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(): + return None, ( + "[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.]" + ) + # 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. + return transcript, f'"{transcript}"' + 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. + """Transcribe voice clips with the configured STT provider and prepend the transcripts. - 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. + Returns ``(enriched_text, successful_transcripts)``: transcripts of successfully + transcribed clips in input order (empty if every clip failed or STT is disabled) so callers + can echo them back before the agent loop. """ from gateway.run import _probe_audio_duration seen = set() @@ -2398,21 +2424,11 @@ class GatewayInboundMixin: 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}]") + suffix = f" (duration: {duration_str})" if duration_str else "" + notes.append(f"[The user sent a voice message: {abs_path}{suffix}]") 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, [] + return self._prepend_media_prefix("\n\n".join(notes), user_text), [] try: from tools.transcription_tools import ( @@ -2421,82 +2437,25 @@ class GatewayInboundMixin: ) 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, [] + return self._prepend_media_prefix("[voice message could not be transcribed]", user_text), [] 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", + transcript, note = await self._transcribe_one_clip( + path, transcribe_audio, transcribe_audio_local_fallback, ) - 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 + if transcript is not None: 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}]" - ) + enriched_parts.append(note) 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}]" - ) + enriched_parts.append(self._untranscribed_audio_note(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 self._prepend_media_prefix("\n\n".join(enriched_parts), user_text), successful_transcripts return user_text, successful_transcripts def _pending_event_audio_paths(self, event) -> List[str]: From cca4b8972430bdb948763a3e194954b83067de95 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:50:20 -0700 Subject: [PATCH 4/6] =?UTF-8?q?refactor(gateway):=20run=5Finbound=20?= =?UTF-8?q?=E2=80=94=20=5Fhandle=5Fmessage=20phase=20helpers,=20shared=20S?= =?UTF-8?q?TT=20echo,=20plugin-injection/vision/reply-context=20compaction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gateway/run_inbound.py | 661 +++++++++++++++++------------------------ 1 file changed, 268 insertions(+), 393 deletions(-) diff --git a/gateway/run_inbound.py b/gateway/run_inbound.py index 1eb873986c..695c419b5c 100644 --- a/gateway/run_inbound.py +++ b/gateway/run_inbound.py @@ -362,76 +362,64 @@ class GatewayInboundMixin: _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). + """Intercept a reply to a pending clarify prompt; None when the message falls through. + + Open-ended prompts and "Other" responses are free text; direct replies to multi-choice + prompts are accepted too ("2" → second option). Resolved/retained replies return "" so + adapters that emit the agent's response don't double-post — the agent itself produces the + next user-facing message. + """ _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, - ) + _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, - "", - ) + if not (allow_gateway_control and _pending_clarify is not None and _clarify_mod is not None): + return 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 "" + # 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 not _raw_clarify_reply or _raw_clarify_reply.startswith("/"): + return None + _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; the active agent resumes now, so re-enable its indicator. + _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) + 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 reject unmatched prose so it continues through normal busy + # 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 # Reply → choice for a pending slash-confirm prompt; the command spelling wins over the @@ -1334,6 +1322,70 @@ class GatewayInboundMixin: logger.debug("Skill command check failed (non-fatal): %s", e) return None + async def _hm_pending_reply_intercepts( + self, event: "MessageEvent", source: SessionSource, _quick_key: str + ) -> Optional[str]: + """Replies owned by in-flight work: pending /update prompt, clarify, slash-confirm.""" + allow_gateway_control = event.allow_gateway_control + _reply = self._hm_update_prompt_reply(event, _quick_key, allow_gateway_control) + if _reply is not None: + return _reply + _reply = await self._hm_clarify_reply(event, source, _quick_key, allow_gateway_control) + if _reply is not None: + return _reply + return await self._hm_slash_confirm_reply(event, _quick_key, allow_gateway_control) + + async def _hm_dispatch_idle_commands( + self, event: "MessageEvent", source: SessionSource, _quick_key: str + ) -> Tuple[bool, Optional[str]]: + """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 True, _result + _handled, _result = await self._hm_dispatch_canonical_command(event, source, _quick_key, canonical) + if _handled: + return True, _result + _handled, _result, command = await self._hm_dispatch_quick_and_plugin_commands(event, source, command) + if _handled: + return True, _result + _skill_reply = self._hm_skill_slash_rewrite(event, source, _quick_key, command) + if _skill_reply is not None: + return True, _skill_reply + return False, None + + def _hm_rescue_orphaned_fifo( + self, event: "MessageEvent", source: SessionSource, is_internal: bool, _quick_key: str + ) -> Tuple["MessageEvent", SessionSource, bool]: + """FIFO orphan rescue: re-stage overflow events left behind by an idle session. + + 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. The oldest orphan runs as + THIS turn and the incoming event is parked behind the chain. 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: + # 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) + return event, source, is_internal + async def _handle_message(self, event: MessageEvent) -> Optional[str]: """Handle an incoming message from any platform. @@ -1350,121 +1402,49 @@ class GatewayInboundMixin: 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 + _reply = await self._hm_pending_reply_intercepts(event, source, _quick_key) + if _reply is not None: + return _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 - ) + _handled, _result = await self._hm_dispatch_idle_commands(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 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. + # 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. 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, - ) + 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, - ) + # 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. + _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, - ) + 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, - ) + event, source, is_internal = self._hm_rescue_orphaned_fifo(event, source, is_internal, _quick_key) _claim_state = self._session_state(_quick_key) if _active_session_lease is not None: @@ -1480,8 +1460,8 @@ class GatewayInboundMixin: 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. + # A rejected message, not a completed turn: return before the /goal judge 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 " @@ -1496,30 +1476,25 @@ class GatewayInboundMixin: ) try: await self._run_post_turn_hooks( - agent_result=_agent_result, - source=source, - is_internal=is_internal, - event=event, + 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. + # MoA one-shot restore must run on EVERY exit path (success, exception, interrupt): + # the restore data lives on the per-turn event and would leak permanently otherwise. 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. + # SIGKILL/OOM skips finally, leaving the durable marker 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). + # Unconditional, idempotent release without a run_generation guard: 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. + # Turn lease is 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: @@ -1555,31 +1530,24 @@ class GatewayInboundMixin: 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, + group_sessions_per_user=getattr(self.config, "group_sessions_per_user", True), + thread_sessions_per_user=getattr(self.config, "thread_sessions_per_user", False), ) 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). + # The display name is attacker-influenceable on platforms where participants set their + # own name: neutralize newlines/control chars 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. + # Slack: expose the current author's verifiable user ID so "mention me again" requests + # have a trusted `<@U...>` target for the CURRENT speaker (display names are ambiguous). + # The user_id comes from the 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}>" - ) + _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. + # After the sender-prefix so the prefix applies only to the trigger message, not the backfill. if getattr(event, "channel_context", None): message_text = f"{event.channel_context}\n\n[New message]\n{message_text}" return message_text @@ -1588,113 +1556,92 @@ class GatewayInboundMixin: 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.""" + """Split ``event.media_urls`` into (image, STT-voice, audio-file, video) paths. + + Per-attachment MIME wins over the message-level type (a document sent alongside an image + must not be routed as an image). MessageType.AUDIO / mixed DOCUMENT audio is a file + attachment, never STT. + """ 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) + for i, path in enumerate(event.media_urls or []): + mtype = event.media_types[i] if i < len(event.media_types) else "" + if _event_media_is_image(event, i): + image_paths.append(path) + 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. + """Route images natively (attach pixels at run_conversation) or pre-analyze them into text.""" + # 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) that would stall the event loop. _img_mode = await asyncio.to_thread( - self._decide_image_input_mode, - source=source, - session_key=session_key, + 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) + 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), + return message_text + 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 enrichment runs before AIAgent.run_conversation(), - # so bind this session's resolved runtime explicitly rather - # than consulting process-global compatibility mirrors. - vision_runtime = None + 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): + return await self._enrich_message_with_vision(message_text, image_paths) + + async def _echo_stt_transcripts( + self, adapter, source: SessionSource, transcripts: List[str], *, metadata=None, log_context: str = "Transcript" + ) -> None: + """Send each transcript back as ``🎙️ "…"`` (best-effort; failures are logged, never raised).""" + for tx in transcripts: 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 + 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 _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, + 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. + # Echo each successful transcript back immediately when configured so users can verify STT + # quality in real time (quiet STT stays available for users who only want the agent to + # receive it). On transcription failure do NOT send a hardcoded notice: that bypassed the + # LLM and produced two replies; enrichment leaves one neutral marker for a localized reply. 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. + await self._echo_stt_transcripts(_echo_adapter, source, _successful_transcripts, metadata=_echo_meta) return message_text @staticmethod @@ -1775,10 +1722,10 @@ class GatewayInboundMixin: @staticmethod def _prepend_inbound_reply_context(event: MessageEvent, source: SessionSource, message_text: str) -> str: + """Prepend the Discord triggering-message id and the reply-to pointer.""" # 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). + # cached system prompt — message_id changes every turn and would bust the agent-cache + # signature (rebuilding the AIAgent every message destroys prompt caching). if ( source is not None and getattr(source, "platform", None) == Platform.DISCORD @@ -1793,15 +1740,11 @@ class GatewayInboundMixin: ) 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. + # Always inject the reply-to pointer even when the quoted text is already in history: + # it's disambiguation (*which* prior message), not deduplication. 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}" - ) + message_text = f'[Replying to your previous message: "{reply_snippet}"]\n\n{message_text}' else: message_text = f'[Replying to: "{reply_snippet}"]\n\n{message_text}' return message_text @@ -1973,22 +1916,11 @@ class GatewayInboundMixin: ) -> Optional[str]: """Run inbound preprocessing under the routed profile when multiplexed.""" from gateway.run import _async_profile_runtime_scope + kwargs = dict(event=event, source=source, history=history, session_key=session_key) 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 with _async_profile_runtime_scope(self._resolve_profile_home_for_source(source)): + return await self._prepare_inbound_message_text(**kwargs) + return await self._prepare_inbound_message_text(**kwargs) async def _prepare_clarify_reply_text(self, event) -> str: """Return raw text or successful voice transcripts for a clarify reply.""" @@ -2036,7 +1968,11 @@ class GatewayInboundMixin: return True async def _clear_durable_active_turn(self, event: "MessageEvent") -> bool: - """Best-effort CAS clear of the marker owned by *event*.""" + """Best-effort CAS clear of the marker owned by *event* (3 attempts). + + Never lets marker cleanup block agent/lease release; a stale marker is bounded by the + agent timeout and the clean-start orphan-marker discard path. + """ session_key = getattr(event, "_gateway_active_turn_session_key", None) token = getattr(event, "_gateway_active_turn_token", None) try: @@ -2045,33 +1981,20 @@ class GatewayInboundMixin: 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 - ) - ) + 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, + 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, + "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", - ): + for attr in ("_gateway_active_turn_session_key", "_gateway_active_turn_token"): with suppress(AttributeError): delattr(event, attr) @@ -2097,16 +2020,14 @@ class GatewayInboundMixin: content: str, plugin_id: str, ) -> bool: - """Schedule a plugin-triggered turn on the live gateway loop.""" + """Schedule a plugin-triggered turn on the live gateway loop (thread-safe).""" 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, + session_key=session_key, content=content, plugin_id=plugin_id, ) try: current_loop = asyncio.get_running_loop() @@ -2118,10 +2039,7 @@ class GatewayInboundMixin: future = loop.create_task(coro) except Exception: coro.close() - logger.warning( - "Plugin message injection scheduling failed", - exc_info=True, - ) + 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) @@ -2144,16 +2062,12 @@ class GatewayInboundMixin: except Exception: logger.warning( "Plugin message injection failed: plugin=%s session=%s", - plugin_id, - session_key, - exc_info=True, + 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, + "Plugin message injection was not routed: plugin=%s session=%s", plugin_id, session_key, ) future.add_done_callback(_log_result) @@ -2167,35 +2081,28 @@ class GatewayInboundMixin: 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 + def _accepting() -> bool: + return getattr(self, "_running", False) and not getattr(self, "_draining", False) - entry = await self.async_session_store.lookup_by_session_key(session_key) - if entry is None or entry.origin is None: + if not _accepting(): return False - if not getattr(self, "_running", False) or getattr(self, "_draining", False): + entry = await self.async_session_store.lookup_by_session_key(session_key) + if entry is None or entry.origin is None or not _accepting(): return False source = dataclasses.replace(entry.origin) try: - if not self._is_user_authorized( - source, - allow_adapter_delegation=False, - ): + 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, + 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, + "Plugin message injection authorization check failed: plugin=%s session=%s", + plugin_id, session_key, exc_info=True, ) return False @@ -2220,9 +2127,7 @@ class GatewayInboundMixin: 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, + plugin_id, session_key, entry.session_id, ) return True @@ -2235,13 +2140,11 @@ class GatewayInboundMixin: provider: Optional[str] = None, model: Optional[str] = None, ) -> str: - """Resolve image-input routing for the effective model this turn. + """Resolve image-input routing (``"native"`` / ``"text"``) 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. + 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 @@ -2254,22 +2157,16 @@ class GatewayInboundMixin: 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: + if needs_session_runtime and (source is not None or session_key): try: turn_model, runtime_kwargs = self._resolve_session_agent_runtime( - source=source, - session_key=session_key, - user_config=cfg, + 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 - ) + rk = runtime_kwargs if isinstance(runtime_kwargs, dict) else {} + runtime_provider = rk.get("provider") + runtime_requested_provider = rk.get("requested_provider") if not resolved_provider and isinstance(runtime_provider, str): resolved_provider = runtime_provider.strip() if isinstance(runtime_requested_provider, str): @@ -2286,10 +2183,7 @@ class GatewayInboundMixin: resolved_model = _read_main_model() return decide_image_input_mode( - resolved_provider, - resolved_model, - cfg, - requested_provider=resolved_requested_provider, + 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) @@ -2300,11 +2194,10 @@ class GatewayInboundMixin: 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. + """Auto-analyze user-attached images with the vision tool and prepend the descriptions. 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. + a tool call and can re-examine it with vision_analyze. """ from tools.vision_tools import vision_analyze_tool from agent.memory_manager import sanitize_context @@ -2321,14 +2214,10 @@ class GatewayInboundMixin: 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 = 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) + description = sanitize_context(result.get("analysis", "")) enriched_parts.append( f"[The user sent an image~ Here's what I can see:\n{description}]\n" f"[If you need a closer look, use vision_analyze with " @@ -2348,13 +2237,11 @@ class GatewayInboundMixin: 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 + # Vision descriptions first, then the user's original text + if not enriched_parts: + return user_text + prefix = "\n\n".join(enriched_parts) + return f"{prefix}\n\n{user_text}" if user_text else prefix _EMPTY_TEXT_PLACEHOLDER = "(The user sent a message with no text content)" @@ -2512,24 +2399,12 @@ class GatewayInboundMixin: ``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 - ): + 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) + await self._echo_stt_transcripts(adapter, source, unsent, metadata=metadata, log_context=log_context) async def _transcribe_and_echo_pending_voice( self, From 68318b8776d1a6a4e6ad2058b608bd779b56c35d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:55:29 -0700 Subject: [PATCH 5/6] =?UTF-8?q?refactor(gateway):=20run=5Finbound=20?= =?UTF-8?q?=E2=80=94=20AST-neutral=20bracket=20packing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gateway/run_inbound.py | 191 ++++++++++------------------------------- 1 file changed, 43 insertions(+), 148 deletions(-) diff --git a/gateway/run_inbound.py b/gateway/run_inbound.py index 695c419b5c..3e38a9b36f 100644 --- a/gateway/run_inbound.py +++ b/gateway/run_inbound.py @@ -21,9 +21,7 @@ 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, + SessionSource, is_shared_multi_user_session, neutralize_untrusted_inline_text ) from gateway.turn_lease import TurnLeaseTimeoutError from typing import Any, Dict, List, Optional, Tuple @@ -68,8 +66,7 @@ class GatewayInboundMixin: 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", + _result.get("reason"), source.platform.value if source.platform else "unknown", source.chat_id or "unknown", ) return None @@ -356,10 +353,7 @@ class GatewayInboundMixin: return None async def _hm_clarify_reply( - self, - event: "MessageEvent", - source: SessionSource, - _quick_key: str, + 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. @@ -553,8 +547,7 @@ class GatewayInboundMixin: "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, + _quick_key, _reap_sid, ) self._invalidate_session_run_generation(_quick_key, reason="reaped_session_eviction") self._release_running_agent_state(_quick_key) @@ -631,8 +624,7 @@ class GatewayInboundMixin: return False logger.debug( "Telegram follow-up arrived %.2fs after run start for %s — queueing without interrupt", - time.time() - _started_at, - _quick_key, + time.time() - _started_at, _quick_key, ) adapter = self._adapter_for_source(source) if adapter: @@ -689,10 +681,7 @@ class GatewayInboundMixin: _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 "", + event, self._adapter_for_source(source), source, event.text or "", log_context="Voice-priority-interrupt", ) elif not _interrupt_text and _media_urls: @@ -802,11 +791,8 @@ class GatewayInboundMixin: 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, + 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: @@ -906,9 +892,7 @@ class GatewayInboundMixin: 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", + event=event, command="new", title="/new", detail="This starts a fresh session and discards the current conversation history.", execute=_do_reset, ) @@ -1082,10 +1066,7 @@ class GatewayInboundMixin: } async def _hm_dispatch_canonical_command( - self, - event: "MessageEvent", - source: SessionSource, - _quick_key: str, + 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, ...). @@ -1112,9 +1093,7 @@ class GatewayInboundMixin: 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, + exec_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=sanitized_env, ) stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30) @@ -1190,8 +1169,7 @@ class GatewayInboundMixin: """ try: from agent.skill_bundles import ( - build_bundle_invocation_message, - resolve_bundle_command_key, + build_bundle_invocation_message, resolve_bundle_command_key ) bundle_key = resolve_bundle_command_key(command) if bundle_key is None: @@ -1231,8 +1209,7 @@ class GatewayInboundMixin: return None logger.warning( "Unrecognized slash command /%s from %s — replying with unknown-command notice", - command, - source.platform.value if source.platform else "?", + command, source.platform.value if source.platform else "?", ) return ( f"Unknown command `/{command}`. " @@ -1242,11 +1219,7 @@ class GatewayInboundMixin: ) def _hm_skill_slash_rewrite( - self, - event: "MessageEvent", - source: SessionSource, - _quick_key: str, - command: Optional[str], + self, event: "MessageEvent", source: SessionSource, _quick_key: str, command: Optional[str] ) -> Optional[str]: """Rewrite ``/`` / ``/`` invocations into the skill prompt on ``event.text``. @@ -1258,9 +1231,7 @@ class GatewayInboundMixin: return None try: from agent.skill_commands import ( - get_skill_commands, - build_skill_invocation_message, - resolve_skill_command_key, + get_skill_commands, build_skill_invocation_message, resolve_skill_command_key ) skill_cmds = get_skill_commands() cmd_key = resolve_skill_command_key(command) @@ -1466,8 +1437,7 @@ class GatewayInboundMixin: "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, + _quick_key, exc.session_id, ) return ( "⏳ Another turn is still running on this session. To " @@ -1531,8 +1501,7 @@ class GatewayInboundMixin: 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.""" _is_shared_multi_user = is_shared_multi_user_session( - source, - group_sessions_per_user=getattr(self.config, "group_sessions_per_user", True), + source, group_sessions_per_user=getattr(self.config, "group_sessions_per_user", True), thread_sessions_per_user=getattr(self.config, "thread_sessions_per_user", False), ) if _is_shared_multi_user and source.user_name: @@ -1682,9 +1651,7 @@ class GatewayInboundMixin: def _prepend_inbound_document_notes(cls, event: MessageEvent, message_text: str) -> str: """Prepend a context note per non-media attachment (anything not routed as image/audio/video).""" from gateway.run import ( - _build_document_context_note, - _event_media_is_audio, - _event_media_is_image, + _build_document_context_note, _event_media_is_audio, _event_media_is_image, _event_media_is_video, ) if not event.media_urls: @@ -1816,11 +1783,8 @@ class GatewayInboundMixin: except Exception: pass return 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 "", + _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, ) @@ -1839,10 +1803,7 @@ class GatewayInboundMixin: _msg_cwd = _ts_env("TERMINAL_CWD", os.path.expanduser("~")) _msg_ctx_len = await self._inbound_model_context_length(source, session_key) _ctx_result = await preprocess_context_references_async( - message_text, - cwd=_msg_cwd, - context_length=_msg_ctx_len, - allowed_root=_msg_cwd, + message_text, cwd=_msg_cwd, context_length=_msg_ctx_len, allowed_root=_msg_cwd ) if _ctx_result.blocked: _adapter = self._adapter_for_source(source) @@ -1860,11 +1821,7 @@ class GatewayInboundMixin: return message_text async def _prepare_inbound_message_text( - self, - *, - event: MessageEvent, - source: SessionSource, - history: List[Dict[str, Any]], + self, *, event: MessageEvent, source: SessionSource, history: List[Dict[str, Any]], session_key: Optional[str] = None, ) -> Optional[str]: """Prepare inbound event text for the agent. @@ -1907,11 +1864,7 @@ class GatewayInboundMixin: return message_text async def _prepare_profile_scoped_inbound_message_text( - self, - *, - event: MessageEvent, - source: SessionSource, - history: List[Dict[str, Any]], + 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.""" @@ -1927,13 +1880,9 @@ class GatewayInboundMixin: if not self._pending_event_audio_paths(event): return (event.text or "").strip() - _, successful_transcripts = await self._transcribe_pending_audio_event_once( - event, "", - ) + _, successful_transcripts = await self._transcribe_pending_audio_event_once(event, "") return "\n\n".join( - transcript.strip() - for transcript in successful_transcripts - if transcript.strip() + transcript.strip() for transcript in successful_transcripts if transcript.strip() ) def _consume_pending_native_image_paths(self, session_key: str) -> List[str]: @@ -1944,20 +1893,12 @@ class GatewayInboundMixin: state.persistent.native_image_paths = [] return paths - async def _mark_durable_active_turn( - self, - event: "MessageEvent", - session_key: str, - ) -> bool: + 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, - ) + logger.warning("Could not persist active-turn marker for %s: %s", session_key, exc) return False if not token: return False @@ -2003,8 +1944,7 @@ class GatewayInboundMixin: from hermes_cli.plugins import get_plugin_manager get_plugin_manager().set_gateway_message_injector( - self, - self._schedule_plugin_message_injection, + self, self._schedule_plugin_message_injection ) def _clear_plugin_message_injector(self) -> None: @@ -2014,11 +1954,7 @@ class GatewayInboundMixin: get_plugin_manager().clear_gateway_message_injector(self) def _schedule_plugin_message_injection( - self, - *, - session_key: str, - content: str, - plugin_id: str, + self, *, session_key: str, content: str, plugin_id: str ) -> bool: """Schedule a plugin-triggered turn on the live gateway loop (thread-safe).""" from gateway.run import safe_schedule_threadsafe @@ -2045,10 +1981,7 @@ class GatewayInboundMixin: future.add_done_callback(self._background_tasks.discard) else: future = safe_schedule_threadsafe( - coro, - loop, - logger=logger, - log_message="Plugin message injection scheduling failed", + coro, loop, logger=logger, log_message="Plugin message injection scheduling failed", log_level=logging.WARNING, ) if future is None: @@ -2074,11 +2007,7 @@ class GatewayInboundMixin: return True async def _dispatch_plugin_message_injection( - self, - *, - session_key: str, - content: str, - plugin_id: str, + self, *, session_key: str, content: str, plugin_id: str ) -> bool: """Route a plugin-triggered turn through the session's live adapter.""" def _accepting() -> bool: @@ -2132,12 +2061,8 @@ class GatewayInboundMixin: 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, + 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 (``"native"`` / ``"text"``) for the effective model this turn. @@ -2189,11 +2114,7 @@ class GatewayInboundMixin: 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: + 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. Description *and* local cache path are injected so the model understands the image without @@ -2293,9 +2214,7 @@ class GatewayInboundMixin: return transcript, f'"{transcript}"' async def _enrich_message_with_transcription( - self, - user_text: str, - audio_paths: List[str], + self, user_text: str, audio_paths: List[str] ) -> tuple[str, List[str]]: """Transcribe voice clips with the configured STT provider and prepend the transcripts. @@ -2319,8 +2238,7 @@ class GatewayInboundMixin: try: from tools.transcription_tools import ( - transcribe_audio, - transcribe_audio_local_fallback, + transcribe_audio, transcribe_audio_local_fallback ) except ModuleNotFoundError as e: logger.error("Transcription module unavailable: %s", e) @@ -2356,9 +2274,7 @@ class GatewayInboundMixin: return audio_paths async def _transcribe_pending_audio_event_once( - self, - event, - user_text: Optional[str] = None, + 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. @@ -2376,21 +2292,14 @@ class GatewayInboundMixin: 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, + 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, + 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. @@ -2407,14 +2316,7 @@ class GatewayInboundMixin: await self._echo_stt_transcripts(adapter, source, unsent, metadata=metadata, log_context=log_context) async def _transcribe_and_echo_pending_voice( - self, - event, - adapter, - source, - text: str, - *, - log_context: str, - metadata=_UNSET, + self, event, adapter, source, text: str, *, log_context: str, metadata=_UNSET ) -> tuple[str, List[str]]: """Transcribe a pending voice event and echo transcripts once. @@ -2426,20 +2328,13 @@ class GatewayInboundMixin: return text, [] try: enriched_text, transcripts = await self._transcribe_pending_audio_event_once( - event, - text, + event, text ) echo_meta = self._thread_metadata_for_source( - source, - self._reply_anchor_for_event(event), + 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, + event, adapter, source, transcripts, metadata=echo_meta, log_context=log_context ) return enriched_text or text, transcripts except Exception as trans_exc: From 7e1d6f1ca625fad32e4585c311db02ad3098b5a1 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:04:10 -0700 Subject: [PATCH 6/6] =?UTF-8?q?refactor(gateway):=20run=5Finbound=20?= =?UTF-8?q?=E2=80=94=20compact=20helper=20docstrings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gateway/run_inbound.py | 78 ++++++++++++------------------------------ 1 file changed, 22 insertions(+), 56 deletions(-) diff --git a/gateway/run_inbound.py b/gateway/run_inbound.py index 3e38a9b36f..72ced13593 100644 --- a/gateway/run_inbound.py +++ b/gateway/run_inbound.py @@ -120,12 +120,8 @@ class GatewayInboundMixin: 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``. - """ + """Ingress gates for ``_handle_message``; None when dropped, else ``(event, source, is_internal)`` + (the ``pre_gateway_dispatch`` hook may have rewritten ``event``).""" from gateway.run import _is_slack_ignored_channel source = event.source @@ -259,11 +255,8 @@ class GatewayInboundMixin: def _hm_estop_gate( self, event: "MessageEvent", source: SessionSource, is_internal: bool ) -> Optional[str]: - """Return the global emergency-stop (`hermes pause`) notice when this turn must be blocked. - - Placed after auth so unauthorized senders can't probe pause state. Internal events from - in-flight work always pass. - """ + """Global emergency-stop (`hermes pause`) notice when this turn must be blocked, else None. + Placed after auth so unauthorized senders can't probe pause state.""" if is_internal: return None try: @@ -572,11 +565,8 @@ class GatewayInboundMixin: async def _hm_busy_slash_or_photo( self, event: "MessageEvent", source: SessionSource, _quick_key: str ) -> Tuple[bool, Optional[str]]: - """Slash-command and photo-burst handling on the running-agent fast-path. - - Returns ``(handled, result)``. Each command's mid-run behavior is declared on its CommandDef - (busy_policy / busy_handler in hermes_cli/commands.py) — no per-command if-chain here. - """ + """Slash-command / photo-burst handling on the busy fast-path → ``(handled, result)``. Each + command's mid-run behavior is declared on its CommandDef (busy_policy / busy_handler).""" 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 @@ -693,11 +683,8 @@ class GatewayInboundMixin: 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. - - Default is to interrupt immediately so user text/stop messages are handled with minimal - latency; busy_input_mode queue/steer, subagent and compression protection demote to queue. - """ + """Fast-path while this session's agent is running: interrupt by default (minimal latency); + busy_input_mode queue/steer, subagent and compression protection demote to queue.""" from gateway.run import _AGENT_PENDING_SENTINEL _handled, _result = await self._hm_busy_slash_or_photo(event, source, _quick_key) if _handled: @@ -839,11 +826,8 @@ class GatewayInboundMixin: 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:`` hooks). - - Returns ``(handled, result, command, canonical)``; when ``handled`` the caller returns - ``result`` as-is (it may legitimately be None). - """ + """Resolve the slash command (aliases, access gate, hooks) → ``(handled, result, command, + canonical)``; when ``handled`` the caller returns ``result`` as-is (may be None).""" command = event.get_command() from hermes_cli.commands import is_gateway_known_command, resolve_command as _resolve_cmd @@ -907,11 +891,8 @@ class GatewayInboundMixin: return True, format_status_text() async def _hm_rewrite_turn_to_prompt(self, event, source, name: str, ack: str, build) -> Tuple[bool, Optional[str]]: - """Ack, then rewrite the turn to ``build()`` and fall through to normal agent processing. - - Mirrors the /blueprint fall-through so role alternation is preserved; no engine, works on - any backend. A failing builder replies with a retry hint instead. - """ + """Ack, then rewrite the turn to ``build()`` and fall through to the agent (keeps role + alternation; works on any backend). A failing builder replies with a retry hint.""" await self._send_command_ack(source, ack, name) try: event.text = build() @@ -1069,11 +1050,8 @@ class GatewayInboundMixin: 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. - """ + """Dispatch built-in idle-path commands → ``(handled, result)``; prompt-rewriting commands + 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) @@ -1615,11 +1593,8 @@ class GatewayInboundMixin: @staticmethod def _inbound_attachment_display_name(path: str) -> Tuple[str, str]: - """``(display_name, agent_visible_path)`` for a downloaded attachment. - - The cache filename is ``__``; the agent path is translated to the - in-container path under a Docker backend (cache dirs are auto-mounted there). - """ + """``(display_name, agent_visible_path)``: cache filename is ``__``; the + path is translated to the in-container mount under a Docker backend.""" from tools.credential_files import to_agent_visible_cache_path basename = os.path.basename(path) parts = basename.split("_", 2) @@ -1717,11 +1692,8 @@ class GatewayInboundMixin: return message_text async def _inbound_model_context_length(self, source: SessionSource, session_key: str) -> int: - """Context length of the model this session's turn will run on (for @-reference budgeting). - - A global ``model.context_length`` pin belongs to the configured model, not a session /model - or channel override; a matching per-custom-provider limit wins when available. - """ + """Context length of the model this turn runs on. A global ``model.context_length`` pin + belongs to the configured model, not a /model or channel override; custom-provider limits win.""" from gateway.run import _load_gateway_config from agent.model_metadata import get_model_context_length_async @@ -1909,11 +1881,8 @@ class GatewayInboundMixin: return True async def _clear_durable_active_turn(self, event: "MessageEvent") -> bool: - """Best-effort CAS clear of the marker owned by *event* (3 attempts). - - Never lets marker cleanup block agent/lease release; a stale marker is bounded by the - agent timeout and the clean-start orphan-marker discard path. - """ + """Best-effort CAS clear of the marker owned by *event* (3 attempts; never blocks agent/lease + release — a stale marker is bounded by the agent timeout and clean-start discard).""" session_key = getattr(event, "_gateway_active_turn_session_key", None) token = getattr(event, "_gateway_active_turn_token", None) try: @@ -2175,11 +2144,8 @@ class GatewayInboundMixin: @staticmethod def _untranscribed_audio_note(path: str) -> str: - """One minimal neutral marker for every STT failure. - - 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). - """ + """One minimal neutral marker for every STT failure. Never mention "no STT provider" or setup + steps — persisted in history they make the model keep volunteering STT-setup advice.""" from tools.credential_files import to_agent_visible_cache_path agent_path = to_agent_visible_cache_path(os.path.abspath(path))