Files
hermes-agent/gateway/run_busy.py
Teknium 9bcbe7b5df feat(i18n): pluggable, layered language packs across core, Desktop and TUI (#126296)
* feat(i18n): layered catalogs — plugin packs and user overlay over bundled locales

* feat(tui): i18n layer — en catalog, nanostore runtime, RPC pack loader, _keys.tui.json emitter

ui-tui/src/i18n/: en.ts (facade over topical siblings under en/), types.ts
(Translations + dotted TranslationKey derived from en), runtime.ts ($locale/
$catalog atoms, translateFrom active→en→key, pack merge with string→fn
wrapping for {0}/{1} placeholders), loader.ts (display.language →
i18n.catalog {lang, surface:'tui'}, English when the method is missing),
useT()/useLocale() hooks, t() for non-React code. useConfigSync feeds the
loader from the existing config.get full hydration. `npm run i18n:keys`
writes locales/_keys.tui.json (sorted flat key list) and runs before build.

* feat(plugins): provides_locales manifest field, ctx.register_locale/register_locale_dir, manifest-only language packs

* chore(tui): split en catalog siblings by lane (slash sibling)

* feat(plugins): validate language packs — parse, text-only, key-subset WARN against en / _keys exports

* feat(tui_gateway): i18n.languages / i18n.catalog RPC + regenerated contracts

* feat(config): display.language accepts any supported_languages() id, refuses unknown ids with the list

* docs(i18n): language packs user guide, pluggable display.language, plugin developer section, AGENTS notes

* feat(plugins): report language-pack layers in the mid-run activation summary

* feat(tui): wire status bar, composer placeholders, hotkey help and approval/clarify/confirm prompts through i18n

StatusRule maps compared state values (ready/running…/summoning) to catalog
text at render via displayStatus(); hotkeys()/placeholder() resolve lazily so
a pack that arrives after boot applies. Catalog grows to 81 keys.

* feat(desktop): pluggable app locales — registry, host.i18n.registerAppLocale, backend packs, keys emitter

- Locale widens to string (BundledLocale keeps the union); TRANSLATIONS stays
  the bundled record and every consumer resolves through the registry.
- src/i18n/registry.ts: registerAppLocale(id, {endonym, rtl, translations})
  layers partial packs (nested or flat dotted) over bundled/en via
  mergeTranslations; a string over a function-valued en entry becomes a
  positional {0}/{1} formatter; $appLocaleVersion bumps so translators
  re-render; per-source disposers + replaceAppLocaleSource for atomic swaps.
- Backend packs: i18n.languages + i18n.catalog {surface:'desktop'} feed the
  registry as source 'backend' (method-not-found is silent); re-synced on
  socket open, display.language change and profile switch. A saved pack-only
  language is promoted once its pack registers.
- SDK: host.i18n.registerAppLocale / languageOptions; ctx.i18n.registerAppLocale
  tracked for unload. Docs in the desktop plugin SDK guide + skill reference.
- Language switcher lists bundled ∪ registered ∪ backend, endonym-only; RTL
  from the registry (applyDocumentLocale takes rtl).
- npm run i18n:keys emits locales/_keys.desktop.json (wired into build).

* i18n(cli): route /topup + /subscription copy through t() (cli.billing.*, cli.subscription.*)

Module-level copy tables and modal choice tuples in cli_billing_mixin.py froze
English at import, before display.language was known. They are now key tables /
builder functions evaluated at call time; every user-facing line in the /usage
balance block, /subscription and the five /topup screens reads the catalog.
Choice VALUES stay English identifiers. Fragment-assembled status lines
(Plan: … → cancels · $x left · renews …) become full templates.

* i18n(gateway): exec-approval card contract + base/run/run_busy/run_inbound replies through t()

- base_exec_approval: EA_* English constants stay; add ea_header_text()/ea_reason_label_text()/
  ea_smart_deny_line_text()/ea_default_reason_text()/ea_action_labels()/approval_timed_out_notice()
  accessors; deadline + timed-out notice resolve via gateway.exec_approval.*
- BasePlatformAdapter._EA_HEADER/_EA_REASON_LABEL/_EA_SMART_DENY_LINE/_EA_ACTION_LABELS become
  properties (adapters still shadow them with markup class attrs)
- run.py: provider error replies table holds catalog keys; _CONTEXT_OVERFLOW_REPLY -> _context_overflow_reply()
- run_busy/run_inbound: typed approval + slash-confirm matchers accept English ∪ approval.inputs.* (t())
- locales/en.yaml: gateway.exec_approval/busy/errors/... namespaces

* i18n(cli): wire modal, loops, agent-setup mixins through t() (cli.* keys)

* i18n(platforms): route Slack, Matrix and Feishu user-facing text through t()

Exec-approval markup overrides (_EA_HEADER/_EA_REASON_LABEL/_EA_SMART_DENY_LINE/
_EA_ACTION_LABELS) become per-call properties over the shared
gateway.exec_approval.* contract keys, so Slack's 3000-char section budget
measures the resolved template. Slack _APPROVAL_DECISIONS/_CONFIRM_DECISIONS,
Feishu _APPROVAL_LABEL_MAP and Matrix _EA_LEGEND/_EA_TYPED_HINT turn into
key tables resolved at click time; the Matrix typed hints become whole
sentences per offered tier instead of spliced fragments. Slack button labels
are cut to 75 chars and select placeholders to 150 after translation; the
model-facing clarify fallback answer ('choice N') stays English while the
card copy localizes.

locales/en.yaml gains the gateway.exec_approval.* contract keys plus the
platform.shared.* / platform.slack.* / platform.matrix.* / platform.feishu.*
namespaces (and the keys for the other adapters wired in follow-up commits).

* i18n(gateway): run_turn / run_turn_runner / approval-settle copy through t()

- status hints, proxy errors, background task notices, progress heartbeats, session info lines
- tool progress chrome (tool_head/tool_pending/tool_preview/tool_verbose) shared by base.format_tool_event
- run_turn_runner:1406 Chinese clarify placeholder -> gateway.clarify.native_stream_placeholder (zh text kept in zh.yaml)
- _UNEXPECTED_SILENCE_REPLY/_CLARIFY_EXPIRED_NOTICE -> accessor functions

* i18n(platforms): route Google Chat and Teams user-facing text through t()

Google Chat clarify card, typing placeholder, orphan-card labels and the whole
/setup-files reply set (module constants become platform.google_chat.setup_files.*
keys resolved at reply time). The attachment-fallback notice that shipped
hardcoded in Spanish is keyed with an English en value; es.yaml carries the
original Spanish text for those four keys.

Teams approval card header/reason use the gateway.exec_approval.* contract,
_APPROVAL_LABELS becomes a key table resolved at click time, and the meeting
summary writer resolves its section headings/fallbacks per render.

* i18n(platforms): route LINE, WeCom, email, DingTalk, IRC and Home Assistant text through t()

LINE default copy constants become catalog keys resolved in __init__ (the
LINE_*_TEXT / extra.* operator overrides still win); the busy-ack bypass
matcher keys on the leading emoji marker only, so it keeps firing once the
gateway busy heads are localized. WeCom media size/format notices that shipped
hardcoded in Chinese are keyed with English en values and zh.yaml carries the
original Chinese text. DingTalk emotion bubbles resolve per send.

* i18n(cli): route /model switch output and -q status lines through t() (cli.model.*, cli.single_query.*)

Switch-summary labels shared with the gateway reuse gateway.model.* keys
(provider/context/max-output/capabilities/prompt-caching); CLI-only variants
(glyph or no-backtick forms) live under cli.model.*. The hand-padded /model usage
block becomes a (form, description-key) table padded at render time so the
command syntax stays fixed while descriptions translate. -q 'Error:' reuses
gateway.model.error_prefix.

* i18n(cli): route TUI panel/hint/placeholder copy through t() (cli.tui.*)

_APPROVAL_CHOICE_LABELS and _TUI_MODAL_HINTS become key tables resolved at
render time; vault/sudo panel bodies are one catalog value per panel split on
newline; inline plurals use <key>_one/<key>_other. Adds the cli.* namespace
(shared/tui/voice/render/subagents/dock) to locales/en.yaml.

* i18n(cli): voice/wake-word CLI copy through t() (cli.voice.*)

RuntimeError texts raised in _voice_start_recording are human copy (callers
print {e}) and are keyed; the Termux requirement-check match stays English.
Wake state ids stay internal, only their labels localize.

* i18n(cli): live-work dock, subagent monitor and render copy through t()

cli.subagents.* / cli.dock.* / cli.render.*; count fragments pluralize via
_one/_other keys, verdict table holds keys resolved at paint time so width
clipping measures the translated text.

* i18n(gateway): unauthorized/pairing, voice, topics, shutdown, startup, notifications, kanban pings through t()

* i18n(cli): move tips + composer placeholders into the catalog (tips.tNNN / tips.placeholder.pNN)

get_random_tip()/get_random_composer_placeholder() pick a key from the English catalog
(the parity baseline, probed once per process) and resolve it through t() for the active
language, so language packs translate tips like any other string. Also lands the cli.*
en.yaml namespace consumed by the CLI info/help/error-copy wiring in the next commit.

* i18n(cli): wire chat-turn + session mixins through t(); kanban log trimmer matches t() output

* test(cli): assert TUI/dock/voice copy via t(key); prove labels resolve at render time

Pinned-English assertions in the approval-UI, live-work dock and voice tests now
go through the catalog. New test swaps the catalog after import and checks the
approval panel + hint row follow it (the reason _APPROVAL_CHOICE_LABELS and
_TUI_MODAL_HINTS became key tables).

* i18n(cli): route CLI info/help/error copy through t() (cli.* namespace)

cli_info_mixin: /help consumes CommandDef.describe() (added to commands.py: slash.<name>.description
with fallback to .description), section titles/skill/quick-command headers, /tools, /toolsets,
/usage labels, /context, /whoami, /insights, /gateway status, tool-progress labels, bang-shell
denials, MCP config-watch + /reload-mcp confirm/reload lines, /reload-skills, and the session-store
warning all read the catalog at call time (module-level label tables became functions so the
active language is honoured after startup). cli.py: worktree cleanup, tirith warning, show_config
(labels re-padded at print time), quick/plugin/skill slash-command errors, ambiguous-command hint,
stdin error, gateway start, profile warning. cli_chat_error_copy / cli_unknown_command /
cli_output / cli_init_mixin: chat error panel copy, did-you-mean lines, n-more / yes-no prompt
(localized affirmative initial alongside 'y'), unknown-toolsets warning.

* i18n(w1a): wire agent display/explainers/approval + slash registry/help through t()

- hermes_cli/commands.py: CommandDef.describe() resolves slash.<name>.description
  at call time; category labels via slash.category.*; help/alias/usage suffixes
  via slash.shared.*; gateway_help_lines and commands_platforms/slash_exec use them.
- agent/display.py: display.verb.* resolved at call time via get_tool_verb();
  bridge/spinner/thinking-verb/cute-row/failure/preview/diff text via display.*.
- agent/turn_explainers.py: exit-reason / persistence-cause tables become call-time
  lookups (explainer.exit.*, explainer.persistence.*, explainer.file_mutation.*).
- agent/background_review.py, session_activity.py, context_breakdown.py,
  status_output.py: review summaries, iteration progress, context notices.
- tools/approval.py, approval_context.py: approval.summary.*, approval.noun.*,
  approval.window.* pluralized keys.
- gateway/slash_commands*.py: remaining raw strings (busy, whoami, platform,
  bundles, memory, skills, approvals, set_home, diff, update, debug, profile,
  heartbeat, refine, review, subgoal, loop, retry, compress codex path, save,
  sessions, model guard/errors, agents rows, topup, login). HISTORY_UNREADABLE
  keeps its English constant; callers use history_unreadable() ->
  gateway.shared.history_unreadable.
- locales/en.yaml: new approval/display/explainer/slash blocks + gateway leftovers.

* i18n(telegram): route adapter chat copy through t()

Approval card (header/reason/smart-deny as HTML-escaped properties), inline
button labels, callback toasts (cut at Telegram's 200-char cap), model/choice
pickers, clarify/update/slash-confirm prompts, gmail-triage labels and the
inbound-media failure notice now come from the catalog. _UNAUTHORIZED is a
lazy _unauthorized() so the import no longer binds a language. The command
menu carries a language+payload fingerprint (forum scopes re-register on
change) and BotCommand descriptions are cut at 256.

Adds gateway.exec_approval.* (WAVE2 contract), platform.telegram.*,
platform.discord.* and the slash.*.description keys the Discord table shares
with the CLI registry to locales/en.yaml.

* i18n(gateway/platforms): whatsapp_cloud, yuanbao, weixin, signal, api_server copy through t()

- whatsapp_cloud: clarify list/buttons, approve/deny + slash-confirm labels via platform.whatsapp.* (t()-then-truncate at 20/24/72 caps); _EA_HEADER becomes a property wrapping ea_header_text()
- yuanbao: SLOW_RESPONSE_MESSAGE -> slow_response_message() (platform.yuanbao.slow_response_notice; zh keeps the original text); cron-wrapper markers centralized as module constants for strip_cron_wrapper
- api_server: PROVIDER_AUTH_FAILED_LABEL/PROVIDER_RATE_LIMITED_LABEL stay English for run.py matchers; user_text() renders via t()
- signal/_format_wait, weixin voice caption, openai_routes transformed notice
- run_turn: second _UNEXPECTED_SILENCE_REPLY consumer -> accessor

* i18n(discord): route adapter chat copy through t()

Native slash-command table becomes _NATIVE_SLASH_COMMAND_SPECS holding catalog
keys; _native_slash_commands() resolves descriptions, parameter descriptions
and Choice names for the active language, each cut at Discord's 100-char cap,
and the app-command sync fingerprint now includes get_language() so a
display.language change re-syncs. Exec-approval card (gateway.exec_approval.*
contract), slash-confirm / clarify / update views, model+choice pickers,
thread creation, forum titles, voice acks, the response-truncation notice,
the unauthorized-slash security alert and the media upload-size notices all
read from platform.discord.*. Decorator-declared button labels are relabelled
in __init__ (80-char cap); embed titles cut at 256, select placeholders at
150, option label/description at 100. _UNAUTHORIZED is a lazy _unauthorized().

* i18n(cli): wire status-bar, stream, terminal mixins + terminal_input through t(); rename kwargs that shadow t(key)

* i18n: wire hermes_cli/cli_commands_mixin.py slash-command copy through t()

- 431 new leaves under cli.commands.<cmd>.* in locales/en.yaml; 12 rows reuse
  existing gateway.* keys (rollback, diff, resume, branch, btw, model, reasoning)
  via a _gt() helper so CLI and gateway replies stay identical.
- Module-level English tables (_BUSY_MODE_*, _REASONING_TOGGLES, _HATCH_PROGRESS,
  _DIFF_LABELS, _LOCAL_ENGINE_LINES) become call-time catalog lookups keyed by id.
- Verb tables (Enabling/Disabling, Paused/Resumed/Triggered, planned/done,
  Updating/Generating) are one full template per variant; plurals use
  <key>_one/<key>_other via _tn(); hand-padded column labels (/snapshot list)
  translate the value and re-pad at the call site.
- Multi-line usage blocks are single catalog values split with _lines().
- Model-facing system notes and DB-stored reasons stay English (EXCLUDED).

* tests: assert /handoff, /worktree, /login CLI copy via t(key) instead of pinned English

* test(i18n): pin Telegram/Discord adapter catalog wiring

Lazy unauthorized notice, exec-approval contract keys, HTML escaping before
Telegram <b> wrapping, 200-char toast / 256-char BotCommand caps, Discord
100-char app-command text and 80-char button caps, and language-bearing
command-menu fingerprints on both platforms.

* i18n: reconcile cli.shared on/off vs enabled/disabled after lane merge

* i18n: describe() in TUI-gateway slash listings; localize TUI exit resume hint

* i18n(tr): translate bundled catalog + tui pack

* i18n(ja): translate bundled catalog + tui pack

* i18n(ko): translate bundled catalog + tui pack

* i18n(zh): translate bundled catalog + tui pack

* i18n(fr): translate bundled catalog + tui pack

* i18n(af): translate bundled catalog + tui pack

* i18n(uk): translate bundled catalog + tui pack

* i18n(ar): translate bundled catalog + tui pack

* i18n(pt): translate bundled catalog + tui pack

* i18n(it): translate bundled catalog + tui pack

* i18n(es): translate bundled catalog + tui pack

* i18n(zh-hant): translate bundled catalog + tui pack

* i18n(ru): translate bundled catalog + tui pack

* i18n(hu): translate bundled catalog + tui pack

* i18n(hu): translate pre-existing English-valued leftovers (kanban wake, /context, /status, fast labels)

* i18n(de): translate bundled catalog + tui pack

* i18n(ga): translate bundled catalog + tui pack

* test(i18n): fixture matches _normalize_lang(lang, home) signature

* i18n(tui): scaffold userMessages/slashCmd en siblings

* i18n(tui): wire secure prompts + content tables

* feat(tui): i18n — wire billing, subscription, connection-setup and journey overlays

Adds en siblings billing.ts / subscription.ts / connection.ts (namespaces
billing, subscription, connection, journey) and routes every user-facing
literal in billingOverlay, subscriptionOverlay, connectionSetupOverlay and
journey through useT()/messages(). Module-level label tables became lazy
(scopeStillDeniedResult(), verbOf(T, action)); auto-reload rows dispatch on
stable ids instead of label text. Regenerates locales/_keys.tui.json.

* i18n(tui): wire slash ops/wake replies

* i18n(tui): wire pickers (modelPicker, activeSessionSwitcher, petPicker)

* i18n(tui): wire slash core/debug/setup replies

* i18n(tui): wire hubs (agents overlay/panel/controls, skills, plugins)

* i18n(tui): wire slash session/topup/subscription replies

* i18n(tui): wire chat bits (branding, thinking, messageLine, loaders, todo, queued, banner, entry)

* i18n(tui): register t3 siblings (pickers, hubs, secure, content, chatBits) and regenerate keys

* i18n(tui): wire userMessages copy through the userMessages namespace

* i18n(tui): lazy-copy test for userMessages, regenerate _keys.tui.json

* i18n(tui): wire session/gateway/lib text through the TUI catalog (lane t2)

Adds en siblings session.ts, gatewayMsg.ts, libText.ts (namespaces session,
gatewayMsg, libText) and routes user-facing literals in app/{useMainApp,
useSessionLifecycle,useInputHandlers,turnController,createServerRequestHandler,
setupHandoff,createGatewayEventHandler}.ts, gatewayClient displayed reasons,
lib/*, domain/*, hooks/* through t()/messages(). Status-bar state values that
code compares against, backend-matched strings, log lines, model-bound text,
and machine 'error:' prefixes stay literal. Regenerates locales/_keys.tui.json
(232 keys).

* i18n: serve bundled locales/<lang>.tui.yaml under overlay/packs; TUI pack parity test; regen _keys.tui.json (1250)

* i18n: translate pre-existing English stubs in bundled locales (424 leaves, 14 locales)

* tui: i18n-export-en script (English templates for pack translators)

* docs(i18n): bundled TUI packs are the bottom layer of the tui surface

* i18n(ru): translate TUI pack

* i18n(ar): translate TUI pack

* i18n(es): translate TUI pack

* i18n(pt): translate TUI pack

* i18n(ko): translate TUI pack

* i18n(de): translate TUI pack

* i18n(ja): translate TUI pack

* i18n(fr): translate TUI pack

* i18n(tr): translate TUI pack

* i18n(it): translate TUI pack

* i18n(zh): translate TUI pack

* i18n(zh-hant): translate TUI pack

* i18n(hu): translate TUI pack

* i18n(uk): translate TUI pack

1,169 missing keys translated; 81 pre-existing kept byte-identical. Parity OK missing=0 extra=0 placeholder_mismatch=0 empty=0.

Deliberately identical to en: chatBits.branding.mcpSummary ({0} MCP), chatBits.thinking.agentsHint ((/agents)), session.main.voiceStt (◉ STT), session.main.voiceTtsSuffix ( [tts]), slashCmd.core.help.tuiSection (TUI), slashCmd.core.history.hermesTag (Hermes #{0}), slashCmd.debug.heapdump.heapPath (heapdump: {0}), slashCmd.debug.mem.rss (rss), subscription.stepUp.title (Remote Spending — product feature name, as in core catalog), content.faces.* (glyph-only kaomoji).

* i18n(ga): translate TUI pack

* i18n(af): translate TUI pack

* plugin_guard: locale catalogs in language packs step down the agent-config family

A translated status line such as "Updating AGENTS.md" in locales/<lang>.yaml is UI text the loader
reads as a string leaf; it cannot edit a file. The bundled en.yaml itself tripped agent_config_mod
at critical, making any faithful language pack uninstallable. Injection shapes keep full severity.

* plugin_validate_locales: read key exports with utf-8-sig (Windows footgun lint)

* i18n(relay): route relay adapter prompt copy through t(); drop dead import-bound approval header

Adds platform.relay.* (5 keys) to en and all 16 bundled locales, reusing the sibling platform
translations for the confirm buttons and the Other option.

* ci: fix TUI import order, MDX table pipe, main's overflow-warning wording in all locales; fresh-install fixture carries the i18n kernel

- ui-tui/src/i18n/en.ts: perfectionist/sort-imports (slash before slashCmd)
- docs plugins/index.md: escape the | inside the provides_locales table cell (MDX parsed <id> as JSX)
- display.notice.uncompressed_context_overflow: adopt main's wording (names compression.enabled: false
  and /compact) in en + 16 locales; the guardrail test pins that phrase
- tests/scripts/test_fresh_source_install.py: the installer tail now resolves CLI text through
  agent.i18n, so the fixture tree carries the i18n kernel + en.yaml (not the agent runtime)

* docs(desktop-plugin-sdk): double-backtick the template-literal example (MDX evaluated ${n})

* test(e2e): display.language is validated against the live language set; exclude it from the arbitrary-string set property

* commands: keep the localized COMMANDS/COMMANDS_BY_CATEGORY module __getattr__ after the compat block removal

* build: never write locales/_keys.*.json from the desktop/TUI builds; regenerate the committed desktop key export

The desktop build regenerated locales/_keys.desktop.json in the checkout, so a
hermes update that rebuilt the app left the tree dirty (Desktop update E2E:
'M locales/_keys.desktop.json'). The key exports are committed artifacts pinned
to en.ts by apps/desktop/scripts/i18n-keys.test.mjs and ui-tui i18n:keys:check;
builds read them, never write them. Regenerated after main's new desktop strings.

* test: unbreak two main-red timing tests the PR merge-ref inherits

- test_local_runtime racing fake publishes the modern state record (legacy pid-only
  records are rejected since 65ff3ad353; main has been red on this test since)
- test_run_progress_topics ManyProgressLinesAgent waits for the first bubble instead of
  a fixed 0.35s, which a loaded CI runner does not always meet

* chore(i18n): regenerate desktop key catalog for main's new strings (model pricing, copy changelog)

* test(e2e): torture-chamber fd monitor confirms a deleted sidecar is still held before calling it a leak

SQLite's WAL last-close unlinks -shm before closing its descriptor (unixShmUnmap, then
unixShmPurge), so a healthy close shows a (deleted) -shm for microseconds; the 20ms poll
occasionally caught that window on the short-lived opener and failed the episode.

* chore(i18n): regenerate desktop key catalog for main's telemetry/consent strings

* chore(i18n): regenerate desktop key catalog after main sync

---------

Co-authored-by: Teknium <teknium@nousresearch.com>
2026-09-28 14:16:18 -07:00

1421 lines
76 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Busy-session queueing, slot claims, slash dispatch tables and destructive-slash confirmation
for GatewayRunner (mixin bound via the MRO).
``gateway.run`` internals are imported lazily inside method bodies (import cycle), so
``patch("gateway.run.X")`` keeps intercepting them at call time.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
import asyncio
import contextlib
import json
import os
import time
from agent.i18n import DEFAULT_LANGUAGE, t
from agent.session_activity import format_iteration_progress
from gateway.config import Platform
from gateway.platforms.base import EphemeralReply
from gateway.platforms.event import MessageEvent, MessageType
from gateway.session import SessionSource
from gateway.whatsapp_identity import canonical_whatsapp_identifier
from typing import Any, Dict, List, Optional, Tuple, Union
if TYPE_CHECKING: # string annotations only; never imported at runtime (cycle)
from gateway.run import GatewayRunner # noqa: F401
from gateway.run_turn_runner import TurnRunner # noqa: F401
def approval_input_words(input_key: str) -> Tuple[str, ...]:
"""Typed-reply synonyms for ``approval.inputs.<input_key>`` (comma-lists): the English words
ALWAYS match, plus the active language's list, so a Polish pack can add "tak" without losing
"yes". Lower-cased, de-duplicated, order preserved (English first)."""
seen: Dict[str, None] = {}
key = f"approval.inputs.{input_key}"
for raw in (t(key, lang=DEFAULT_LANGUAGE), t(key)):
if raw == key: # catalog miss: t() echoes the key
continue
for word in raw.split(","):
word = word.strip().lower()
if word:
seen.setdefault(word, None)
return tuple(seen)
# Log-record parity with the origin module.
logger = logging.getLogger("gateway.run")
def _strip_slot(text: str, slot: str) -> Optional[str]:
"""Remainder after ``slot`` when ``text`` starts with it as a WHOLE slot, else None.
The ``:``-delimited slot layout is ``build_session_key``'s: an id that merely starts with another
must never match, so a whole-slot comparison is what every caller here uses. A text that IS the
slot yields ``""``."""
if text == slot:
return ""
if text.startswith(slot + ":"):
return text[len(slot) + 1:]
return None
def _tail_has_slot(tail: str, slot: str) -> bool:
"""True when ``tail``'s FIRST slot is ``slot`` (``tail`` is ``""`` when the key ends at the
chat id)."""
return _strip_slot(tail, slot) is not None
def _same_chat_key_slots(
key: str, *, prefix: str, chat_id: str, scope_id: Optional[str],
) -> Optional[Tuple[str, str]]:
"""``(chat_type, tail)`` when ``key`` names the SAME chat as ``prefix`` + ``chat_id``, else None.
``prefix`` is the key's fixed-shape head, ``agent:<profile>:<platform>:``. Everything after it is
matched as TEXT, because ids may themselves contain ``:`` (Matrix ``!room:example.org``).
``scope_id`` is Slack's workspace slot — ``build_session_key`` emits it there alone — and a key
without it still names the same chat; a key carrying a DIFFERENT known scope is another
workspace's chat. ``tail`` is ``""`` when the key ends at the chat id.
"""
if not key.startswith(prefix):
return None
chat_type, _, rem = key[len(prefix):].partition(":")
candidates = (f"{scope_id}:{chat_id}", chat_id) if scope_id else (chat_id,)
for candidate in candidates:
tail = _strip_slot(rem, candidate)
if tail is not None:
return chat_type, tail
return None
class GatewayBusySessionMixin:
"""Busy-session queueing, slot claims, slash dispatch tables, destructive-slash confirmation."""
def _queue_during_drain_enabled(self, busy_input_mode: Optional[str] = None) -> bool:
# "queue"/"steer" mean messages survive a restart (queued for the new process); "interrupt" drops.
mode = busy_input_mode or self._busy_input_mode
return self._restart_requested and mode in {"queue", "steer"}
def _overflow_queue(self, session_key: str):
"""The session's FIFO overflow list, or None when no session state exists yet."""
state = self._peek_session_state(session_key)
return state.conversation.queued_events if state else None
def _enqueue_fifo(self, session_key: str, queued_event: "MessageEvent", adapter: Any) -> None:
"""Append a /queue event to the FIFO chain for a session."""
pending_slot = getattr(adapter, "_pending_messages", None) if adapter is not None else None
if pending_slot is None:
return
if session_key in pending_slot:
self._session_state(session_key).conversation.queued_events.append(queued_event)
else:
pending_slot[session_key] = queued_event
queued_event._gateway_accepted = True
def _promote_queued_event(
self, session_key: str, adapter: Any, pending_event: Optional["MessageEvent"]
) -> Optional["MessageEvent"]:
"""Promote the next overflow item after the slot drained.
``pending_event`` None → the overflow head becomes the pending event; otherwise the head is
staged into the slot for the NEXT recursion. Returns the (possibly updated) pending_event.
"""
overflow = self._overflow_queue(session_key)
if not overflow:
return pending_event
if pending_event is None:
return overflow.pop(0)
if adapter is not None and hasattr(adapter, "_pending_messages"):
adapter._pending_messages[session_key] = overflow.pop(0)
# else: no adapter — leave the head in place so we don't silently drop it.
return pending_event
def _queue_depth(self, session_key: str, *, adapter: Any = None) -> int:
"""Total pending /queue items for a session — slot + overflow."""
depth = len(self._overflow_queue(session_key) or ())
if adapter is not None and session_key in getattr(adapter, "_pending_messages", {}):
depth += 1
return depth
def _rescue_orphaned_overflow(self, session_key: str, adapter: Any) -> Optional["MessageEvent"]:
"""Pop the oldest orphaned FIFO overflow event for an idle session (None if nothing to rescue).
``queued_events`` drains only at the post-turn promotion site; a busy window ending without
it (early exit, exception/interrupt/generation-bump) orphans the overflow. On a NEW event for
a NON-busy session the oldest orphan runs as THIS turn, the next is staged into the slot so
arrival order holds, and the caller enqueues the incoming event behind it. The returned
event is REMOVED from both stores, else the post-turn dequeue would run it twice.
See #28503.
"""
try:
overflow = self._overflow_queue(session_key)
if not overflow:
return None
pending_slot = getattr(adapter, "_pending_messages", None)
if not isinstance(pending_slot, dict) or pending_slot.get(session_key):
return None # slot occupied (busy) or no slot storage — promotion owns this
head = overflow.pop(0)
# Keep the slot occupied so the drain promotes in order and a mid-chain arrival routes
# to overflow instead of jumping the queue (same invariant as _promote_queued_event).
if overflow:
pending_slot[session_key] = overflow.pop(0)
logger.warning(
"Rescued orphaned FIFO overflow event for idle session "
"%s — it was queued during a busy window but the post-turn "
"drain never promoted it (#99882)", session_key,
)
if overflow:
logger.warning(
"%d overflow event(s) still queued for session %s after "
"rescue staging (will drain via normal promotion)", len(overflow), session_key,
)
return head
except Exception:
logger.debug("FIFO overflow rescue failed for %s", session_key, exc_info=True)
return None
@staticmethod
def _is_goal_continuation_event(event_or_text: Any) -> bool:
"""True for synthetic /goal continuation turns (so pause/clear can spare real /queue items)."""
text = getattr(event_or_text, "text", event_or_text) or ""
return str(text).startswith("[Continuing toward your standing goal]\nGoal:")
def _clear_goal_pending_continuations(self, session_key: str, adapter: Any) -> int:
"""Remove queued synthetic /goal continuations for one session; real /queue items are kept."""
removed = 0
pending_slot = getattr(adapter, "_pending_messages", None) if adapter is not None else None
if isinstance(pending_slot, dict):
pending_event = pending_slot.get(session_key)
if self._is_goal_continuation_event(pending_event):
pending_slot.pop(session_key, None)
removed += 1
overflow = self._overflow_queue(session_key)
if overflow:
kept = [e for e in overflow if not self._is_goal_continuation_event(e)]
removed += len(overflow) - len(kept)
self._peek_session_state(session_key).conversation.queued_events = kept
return removed
def _goal_still_active_for_session(self, session_id: str) -> bool:
"""Best-effort fresh DB check before running a queued continuation."""
if not session_id:
return False
try:
from hermes_cli.goals import GoalManager
return GoalManager(session_id=session_id).is_active()
except Exception as exc:
logger.debug("goal continuation: active-state recheck failed: %s", exc)
return False
def _get_max_concurrent_sessions(self) -> Optional[int]:
"""Return the configured active chat session cap, if enabled."""
try:
from hermes_cli.active_sessions import resolve_max_concurrent_sessions
return resolve_max_concurrent_sessions(getattr(self, "config", None))
except Exception:
return None
def _active_session_limit_message(self, session_key: str) -> Optional[str]:
"""Return a user-facing rejection when starting a new session exceeds the cap."""
max_sessions = self._get_max_concurrent_sessions()
if max_sessions is None or self._is_session_running(session_key):
return None
active_count = self._running_agent_count()
if active_count < max_sessions:
return None
from hermes_cli.active_sessions import active_session_limit_message
return active_session_limit_message(active_count, max_sessions)
def _claim_active_session_slot(
self, session_key: str, source: SessionSource
) -> tuple[Any, Optional[str]]:
"""Claim a cross-process active-session slot for a new gateway turn."""
if self._is_session_running(session_key):
return None, None
limit_message = self._active_session_limit_message(session_key)
if limit_message is not None:
return None, limit_message
try:
from hermes_cli.active_sessions import try_acquire_active_session
platform = source.platform.value if source and source.platform else "gateway"
return try_acquire_active_session(
session_id=session_key,
surface=f"gateway:{platform}",
config=getattr(self, "config", None),
metadata={
"platform": platform,
"chat_id": getattr(source, "chat_id", "") or "",
"user_id": getattr(source, "user_id", "") or "",
# Writer identity: a leaked lease from this process is re-acquired by the next
# turn rather than fencing it out forever (pruning only reclaims dead PROCESSES).
# Writer identity for re-entrancy (#94595): if this process leaks a lease for this
# session (exception path skipped release), the next turn re-acquires its own entry
# instead of being fenced out of it forever — pruning only reclaims entries whose
# PROCESS died.
"live_session_id": str(session_key),
},
)
except Exception as exc:
logger.warning("Failed to claim active session slot: %s", exc)
return None, None
@staticmethod
def _agent_has_active_subagents(running_agent: Any) -> bool:
"""True when *running_agent* is driving subagents (callers demote interrupt → queue;
``interrupt()`` would cascade through ``_active_children``). Fail-safe False on any error."""
from gateway.run import _AGENT_PENDING_SENTINEL
if running_agent is None or running_agent is _AGENT_PENDING_SENTINEL:
return False
children = getattr(running_agent, "_active_children", None)
# Real collections only — a ``MagicMock()._active_children`` auto-attr must not demote.
if not isinstance(children, (list, tuple, set)) or not children:
return False
lock = getattr(running_agent, "_active_children_lock", None)
try:
with lock if lock is not None else contextlib.nullcontext():
return bool(children)
except Exception:
return False
@staticmethod
def _steer_active_subagents(running_agent: Any, text: str) -> int:
"""Queue *text* into every live child of *running_agent*; returns how many accepted it.
A parent blocked inside ``delegate_task`` only drains its own steer queue after the tool
returns, i.e. after the child finishes — so a steer aimed at a looping child would sit
unread for the whole delegation (#112095, Telegram). Children are the parent's own
``_active_children`` (identity-scoped, same snapshot ``interrupt()`` fans out to)."""
children = getattr(running_agent, "_active_children", None)
if not isinstance(children, (list, tuple, set)) or not children:
return 0
lock = getattr(running_agent, "_active_children_lock", None)
try:
with lock if lock is not None else contextlib.nullcontext():
snapshot = list(children)
except Exception:
return 0
accepted = 0
for child in snapshot:
steer = getattr(child, "steer", None)
if not callable(steer):
continue
try:
accepted += bool(steer(text))
except Exception as exc:
logger.warning("Steer into subagent %r failed: %s", getattr(child, "_delegate_id", child), exc)
return accepted
def _steer_running_agent(self, running_agent: Any, text: str) -> bool:
"""``running_agent.steer(text)`` plus fan-out to its active subagents (see
:meth:`_steer_active_subagents`); True when the parent or any child queued it."""
accepted = bool(running_agent.steer(text))
return bool(self._steer_active_subagents(running_agent, text)) or accepted
async def _session_has_compression_in_flight(self, session_key: str) -> bool:
"""True when a compression lock is held for this session's id (callers demote interrupt →
queue, else a follow-up against the pre-rotation parent orphans compression siblings).
Both blocking reads run in a worker thread so a large state.db never freezes the loop.
Context compression is interrupt-protected (#23975) but gateway ``interrupt`` busy-input mode can
still start a follow-up turn against the pre-rotation parent while compression is mid-flight,
producing orphaned compression siblings (#56391).
"""
session_store = getattr(self, "session_store", None)
if not session_key or session_store is None:
return False
def _assume_active(what: str, ident) -> bool:
logger.warning(
"Compression in-flight check failed while reading %s %s; treating compression as "
"active to avoid interrupting a possible parent-session rotation", what, ident, exc_info=True,
)
return True
try:
session_id = await asyncio.to_thread(
self._lookup_session_id_under_store_lock, session_store, session_key
)
except (AttributeError, TypeError):
return False
except Exception:
return _assume_active("session", session_key)
session_db = getattr(self, "_session_db", None)
if not session_id or session_db is None:
return False
raw_db = getattr(session_db, "_db", session_db)
try:
holder = await asyncio.to_thread(raw_db.get_compression_lock_holder, str(session_id))
# Production returns Optional[str]. Reject non-strings so a MagicMock auto-attr (or any
# unexpected truthy) cannot look like a held lock and skip hygiene.
# See #96953.
return isinstance(holder, str) and bool(holder)
except (AttributeError, TypeError):
return False
except Exception:
return _assume_active("lock holder for session", session_id)
@staticmethod
def _lookup_session_id_under_store_lock(session_store, session_key: str):
"""Sync helper run in the thread pool: read session_id under the store lock."""
# noqa: SLF001 — intentional private access; runs off the event loop.
with session_store._lock: # noqa: SLF001
session_store._ensure_loaded_locked() # noqa: SLF001
entry = session_store._entries.get(session_key) # noqa: SLF001
return getattr(entry, "session_id", None) if entry is not None else None
# Metadata that must match for two pending events to merge into one slot.
_SECURITY_METADATA_KEYS = (
"hermes_plugin_id", "hermes_plugin_injection", "gateway_session_key",
"gateway_session_id", "gateway_session_strict",
"notification_category",
)
def _queue_or_replace_pending_event(self, session_key: str, event: MessageEvent) -> None:
from gateway.platforms.base import merge_pending_message_event
adapter = self._delivery_adapter_for(event.source)
if not adapter:
return
# FIFO so each follow-up gets its own turn in arrival order (the single pending slot used to
# be silently OVERWRITTEN). Photo bursts still merge into the head slot (album semantics).
pending_slot = getattr(adapter, "_pending_messages", None)
# #28503 — Previously this called ``merge_pending_message_event`` with the default
# ``merge_text=False``, which silently OVERWROTE the single pending slot when consecutive text
# messages arrived in ``busy_input_mode: queue``.
existing = pending_slot.get(session_key) if isinstance(pending_slot, dict) else None
same_security_context = existing is not None and (
getattr(existing, "internal", False) == getattr(event, "internal", False)
and getattr(existing, "allow_gateway_control", True)
== getattr(event, "allow_gateway_control", True)
and all(
(getattr(existing, "metadata", None) or {}).get(key)
== (getattr(event, "metadata", None) or {}).get(key)
for key in self._SECURITY_METADATA_KEYS
)
)
# Only a photo burst (PHOTO on either side, the other side TEXT or PHOTO) merges into the
# head slot. Every other media follow-up — voice, audio, video, document — is an
# independent message and takes its own FIFO turn like text does; merging on *any*
# ``media_urls`` collapsed three voice notes into one turn (#114363). Telegram albums
# (``media_group_id``, photos and videos) are already coalesced by the adapter upstream.
merge_types = {
getattr(existing, "message_type", None),
getattr(event, "message_type", None),
}
if (
same_security_context
and MessageType.PHOTO in merge_types
and merge_types <= {MessageType.TEXT, MessageType.PHOTO}
):
merge_pending_message_event(
adapter._pending_messages, session_key, event,
merge_text=event.message_type == MessageType.TEXT,
)
event._gateway_accepted = True
return
if self._queue_depth(session_key, adapter=adapter) >= self._BUSY_QUEUE_MAX_PENDING:
logger.warning(
"Dropping busy-mode follow-up for session %s — pending queue at cap (%d).",
session_key, self._BUSY_QUEUE_MAX_PENDING,
)
return
self._enqueue_fifo(session_key, event, adapter)
async def _prepare_busy_steer_text(self, event: MessageEvent) -> str:
"""Steerable text for a busy follow-up, transcribing voice-message media first.
Steer bypasses the inbound STT queue, so a media-only voice follow-up would otherwise
silently degrade to queue mode. Uses the single out-of-band STT choke point, so STT runs at
most once per message; on failure the caption (if any) is kept.
"""
text = (event.text or "").strip()
if not self._pending_event_audio_paths(event):
return text
enriched_text, successful_transcripts = await self._transcribe_and_echo_pending_voice(
event, self._delivery_adapter_for(event.source), event.source, text, log_context="Busy-steer"
)
return (enriched_text or text).strip() if successful_transcripts else text
def _steer_text_with_origin(self, text: str, event: MessageEvent) -> str:
"""Keep event origin in this injection, never in the cached system prompt."""
if not text.strip():
return text
import json
source = event.source
origin = {
"platform": source.platform.value,
**{key: getattr(source, key) for key in (
"chat_id", "thread_id", "chat_type", "user_id", "scope_id", "profile",
"parent_chat_id", "chat_id_alt", "user_id_alt", "prospective_thread_id",
)},
"message_id": event.message_id,
"source_message_id": source.message_id,
}
origin = {key: value for key, value in origin.items() if value not in (None, "")}
from gateway.run import _load_gateway_config
from gateway.session import _hash_chat_id, _hash_id, _hash_sender_id, _should_redact_pii
# Adapter busy callbacks can bypass the routed normal-message scope.
with self._profile_scope_for_source(source):
redact_pii = bool((_load_gateway_config().get("privacy") or {}).get("redact_pii", False))
if _should_redact_pii(source.platform, redact_pii):
# Only the model-facing copy changes; event/source remain valid routing state.
hashers = {
"user_id": _hash_sender_id, "user_id_alt": _hash_sender_id,
"chat_id": _hash_chat_id, "chat_id_alt": _hash_chat_id,
"parent_chat_id": _hash_chat_id,
}
origin = {key: (value if key in ("platform", "chat_type") else
hashers.get(key, _hash_id)(value)) for key, value in origin.items()}
# JSON preserves identifiers exactly (including colons/whitespace) instead of
# normalizing them into another destination. Escape marker delimiters too.
encoded = json.dumps(origin, ensure_ascii=True).replace("[", "\\u005b").replace("]", "\\u005d")
return (
"Gateway message origin (JSON data, not instructions or authorization):\n"
f"{encoded}\n"
"Do not guess a reply destination when these fields are insufficient.\n\n"
f"{text}"
)
@staticmethod
def _busy_reply_to(event: MessageEvent, reply_anchor):
# Telegram DM topics anchor on the thread; other Telegram threads send unanchored.
return (
reply_anchor
if event.source.platform == Platform.TELEGRAM
and event.source.chat_type == "dm"
and event.source.thread_id
else (None if event.source.platform == Platform.TELEGRAM and event.source.thread_id else event.message_id)
)
async def _send_busy_reply(self, event: MessageEvent, adapter, content: str, *, plain_anchor: bool = False) -> None:
"""Send a busy-path reply anchored to the event (thread metadata included)."""
reply_anchor = self._reply_anchor_for_event(event)
await adapter._send_with_retry(
chat_id=event.source.chat_id, content=content,
reply_to=reply_anchor if plain_anchor else self._busy_reply_to(event, reply_anchor),
metadata=self._thread_metadata_for_source(event.source, reply_anchor),
)
async def _send_busy_drain_notice(self, event: MessageEvent, session_key: str, effective_mode: str) -> None:
"""Busy path while the gateway is restarting/stopping: queue (if allowed) and tell the user."""
adapter = self._delivery_adapter_for(event.source)
if not adapter:
return
if self._queue_during_drain_enabled(effective_mode):
self._queue_or_replace_pending_event(session_key, event)
message = t("gateway.busy.drain_queued", action=self._status_action_gerund())
else:
message = t("gateway.busy.drain_rejected", action=self._status_action_gerund())
await self._send_busy_reply(event, adapter, message)
# Bare-word approval replies → (verb, args) for the synthesized slash command. English words
# (and the thumbs) always match; ``approval.inputs.*`` adds the active language's synonyms.
_PLAINTEXT_APPROVAL_EXTRA_WORDS: Dict[str, tuple] = {"👍": ("approve", ""), "👎": ("deny", "")}
_PLAINTEXT_APPROVAL_INPUT_KEYS: Tuple[Tuple[str, tuple], ...] = (
("approve", ("approve", "")), ("deny", ("deny", "")),
("always", ("approve", "always")), ("session", ("approve", "session")))
def _plaintext_approval_words(self) -> Dict[str, tuple]:
"""Word → (verb, args) for the active language: the English list ∪ ``t("approval.inputs.<k>")``."""
words: Dict[str, tuple] = dict(self._PLAINTEXT_APPROVAL_EXTRA_WORDS)
for input_key, verb_args in self._PLAINTEXT_APPROVAL_INPUT_KEYS:
for word in approval_input_words(input_key):
words.setdefault(word, verb_args)
return words
async def _route_plaintext_approval_while_busy(self, event: MessageEvent, session_key: str) -> bool:
"""Route a bare "yes"/"no" to the approval handlers while a dangerous-command approval blocks.
Returns True when the message was consumed as an approval response.
"""
# A bare "yes" while blocked on a dangerous-command approval must reach the approval handler,
# not queue behind a turn that can't start until it resolves (auto-deny deadlock). Gated on
# has_blocking_approval so a conversational "yes" never fires a command.
try:
from tools.approval import has_blocking_approval
# --- Approval response routing (#46866) --- When the agent is blocked waiting for a
# dangerous-command approval, plain-text responses like "yes" or "approve" must be routed to the
# approval handler instead of being steered/queued/interrupted. Slash forms (/approve, /deny)
# already bypass to the runner at the base-adapter guard. This handles the bare-word forms
# (Signal/SMS users naturally type "yes" rather than "/approve"). Gating on
# has_blocking_approval(session_key) is the disambiguator that keeps a conversational "yes" from
# triggering a dangerous command when no approval is actually pending (design intent — see
# run.py "Pending exec approvals are handled by /approve and /deny" note). We reuse the
# canonical /approve and /deny handlers rather than re-deriving the resolution + i18n messaging:
# they resolve the waiting thread, resume typing, AND return a localized confirmation string.
# The busy-handler path does not auto-send that return, so we deliver it ourselves (mirroring
# the draining-case send above).
if event.allow_gateway_control and has_blocking_approval(session_key):
_raw_text = (event.text or "").strip().lower()
_match = self._plaintext_approval_words().get(_raw_text)
if _match is not None:
_verb, _normalized_args = _match
_approval_handler = (
self._handle_approve_command if _verb == "approve" else self._handle_deny_command
)
# Synthesize "/approve [args]" / "/deny" so the slash handlers parse modifiers via
# event.get_command_args(). Always a literal "/": is_command()/get_command_args()
# don't recognize per-platform display prefixes ("!" on Slack/Matrix).
event.text = f"/{_verb} {_normalized_args}".rstrip()
_reply = await _approval_handler(event)
logger.info(
"Approval response via plain text: session=%s verb=%s args=%r",
session_key, _verb, _normalized_args,
)
_adapter = self._delivery_adapter_for(event.source)
if _adapter and _reply:
_text, _eph_ttl = _adapter._unwrap_ephemeral(_reply)
if _text:
await self._send_busy_reply(event, _adapter, _text, plain_anchor=True)
return True
except Exception:
logger.warning(
"Plain-text approval routing failed for session %s; "
"falling through to busy handling", session_key, exc_info=True,
)
return False
async def _resolve_busy_steer_or_redirect(
self, event: MessageEvent, session_key: str, effective_mode: str, running_agent: Any
) -> "GatewayRunner._BusySteerOutcome":
"""Apply interrupt->queue demotions, then attempt steer (steer mode) or redirect (interrupt mode)."""
from gateway.run import _AGENT_PENDING_SENTINEL
# Steer injects mid-run via running_agent.steer(), falling back to queue (nothing lost) when
# the agent isn't running yet, lacks steer(), or the payload is empty. Interrupt is demoted
# to queue while subagents run (interrupt() would abort them); /stop and /new still cancel all.
demoted_for_subagents = (
effective_mode == "interrupt" and self._agent_has_active_subagents(running_agent)
)
if demoted_for_subagents:
effective_mode = self._demote_interrupt(session_key, "the running agent has active subagents (#30170)")
demoted_for_compression = (
effective_mode == "interrupt" and await self._session_has_compression_in_flight(session_key)
)
if demoted_for_compression:
effective_mode = self._demote_interrupt(session_key, "context compression is in flight (#56391)")
steered = redirected = False
agent_live = running_agent is not None and running_agent is not _AGENT_PENDING_SENTINEL
plain_text = (
event.message_type == MessageType.TEXT and not event.media_urls and not event.media_types
)
if effective_mode == "steer":
steer_text = await self._prepare_busy_steer_text(event)
# Steerable: plain text, OR every attachment is voice media folded into steer_text.
# A follow-up qualifies for steering when it is plain text, OR when every attachment is
# STT-eligible voice media whose transcript was just folded into steer_text — otherwise a voice
# note in steer mode silently degrades to queue mode (#58780).
_steer_media_urls = getattr(event, "media_urls", None) or []
_steer_all_voice = bool(_steer_media_urls) and (
len(self._pending_event_audio_paths(event)) == len(_steer_media_urls)
)
if steer_text and (plain_text or _steer_all_voice) and agent_live and hasattr(running_agent, "steer"):
steered = self._try_agent_verb(
running_agent, "steer", steer_text, session_key, event=event
)
if steered:
self._fold_into_running_turn(running_agent, session_key, event)
else:
effective_mode = "queue"
elif (
effective_mode == "interrupt" and plain_text and agent_live
and getattr(running_agent, "_supports_active_turn_redirect", False) is True
and hasattr(running_agent, "redirect")
):
redirected = self._redirect_active_turn(
running_agent, (event.text or "").strip(), session_key, event
)
return self._BusySteerOutcome(
effective_mode=effective_mode, demoted_for_subagents=demoted_for_subagents,
demoted_for_compression=demoted_for_compression, steered=steered, redirected=redirected,
)
@staticmethod
def _demote_interrupt(session_key: str, why: str) -> str:
logger.info("Demoting busy_input_mode 'interrupt' to 'queue' for session %s because %s", session_key, why)
return "queue"
def _try_agent_verb(
self, running_agent, verb: str, text: str, session_key: str, *, event: Optional[MessageEvent] = None
) -> bool:
"""Call ``running_agent.<verb>(text)`` (steer/redirect); False + warning on failure."""
try:
call_text = self._steer_text_with_origin(text, event) if event else text
if verb == "steer":
return self._steer_running_agent(running_agent, call_text)
return bool(getattr(running_agent, verb)(call_text))
except Exception as exc:
logger.warning("Gateway %s failed for session %s: %s", verb, session_key, exc)
return False
def _redirect_active_turn(self, running_agent, text: str, session_key: str, event: MessageEvent) -> bool:
"""``redirect()`` the running turn onto *event* and re-anchor its delivery to that message.
The turn's reply anchor and ledger identity were bound to the message that OPENED it, and
the final send is bracketed against that event; after a successful redirect the answer is
to *event*, so the reply must quote it (#115001). Both redirect entry points (busy
interrupt mode and the priority path) go through here.
"""
if not self._try_agent_verb(running_agent, "redirect", text, session_key, event=event):
return False
turn = self._fold_into_running_turn(running_agent, session_key, event)
if turn is None:
return True # a newer turn already owns the slot; never re-anchor it
anchor = self._reply_anchor_for_event(event)
inbound_id = str(event.message_id) if event.message_id else None
if turn.event is not None and turn.event is not event:
turn.event.reply_anchor_override = anchor
turn.event.ledger_message_id = inbound_id
if turn.ctx is not None:
turn.ctx.event_message_id = anchor
turn.ctx.inbound_message_id = inbound_id
return True
def _fold_into_running_turn(self, running_agent, session_key: str, event: MessageEvent):
"""The running turn now answers *event* too (steer, redirect): if *event* was addressed to
the bot, a bare silence marker must not end the turn. Returns the turn, or None when a newer
turn already owns the slot."""
turn = self._session_state(session_key).turn
if turn.agent is not running_agent:
return None
if turn.event is not None and turn.event is not event:
turn.event.absorb_reply_expected(event)
if turn.ctx is not None:
turn.ctx.reply_expected = turn.event.reply_expected
return turn
async def _interrupt_running_agent_for_busy_event(self, event: MessageEvent, adapter, running_agent) -> None:
"""Interrupt mode: abort in-flight tool calls; the agent loop exits at its next check point."""
from gateway.run import _build_media_placeholder
try:
_interrupt_text = event.text
_media_urls = getattr(event, "media_urls", None) or []
if self._pending_event_audio_paths(event):
_interrupt_text, _ = await self._transcribe_and_echo_pending_voice(
event, adapter, event.source, event.text or "", log_context="Voice-busy-interrupt",
)
elif not _interrupt_text and _media_urls:
_interrupt_text = _build_media_placeholder(event)
running_agent.interrupt(_interrupt_text)
except Exception:
pass # don't let interrupt failure block the ack
def _busy_steer_ack_enabled(self, event: MessageEvent, session_key: str) -> bool:
# Some mobile chat setups want silent steering — keep the behavior, drop the bubble.
from gateway.run import _load_gateway_config, _platform_config_key
from gateway.display_config import resolve_display_setting
steer_ack_env = os.environ.get("HERMES_GATEWAY_BUSY_STEER_ACK_ENABLED")
if steer_ack_env is not None:
steer_ack_enabled = steer_ack_env.strip().lower() in {"1", "true", "yes", "on"}
else:
steer_ack_enabled = bool(
resolve_display_setting(
_load_gateway_config(), _platform_config_key(event.source.platform),
"busy_steer_ack_enabled", True,
)
)
if not steer_ack_enabled:
logger.debug("Busy steer ack suppressed for session %s", session_key)
return steer_ack_enabled
@property
def _BUSY_DEMOTED_TAIL(self) -> str: # noqa: N802 — long-standing mixin attr name
return t("gateway.busy.demoted_tail")
def _compose_busy_ack_message(
self, event: MessageEvent, now: float, _busy_state, running_agent: Any, *,
is_steer_mode: bool, is_queue_mode: bool, is_redirect_mode: bool,
demoted_for_subagents: bool, demoted_for_compression: bool,
) -> str:
from gateway.run import (
_AGENT_PENDING_SENTINEL, _hermes_home, _load_gateway_config, _platform_config_key
)
from gateway.display_config import resolve_display_setting
# Terse by default; iteration/tool detail opts in via display.platforms.<p>.busy_ack_detail.
status_parts = []
busy_ack_detail_enabled = bool(
resolve_display_setting(
_load_gateway_config(), _platform_config_key(event.source.platform),
"busy_ack_detail", True,
)
)
if busy_ack_detail_enabled and running_agent and running_agent is not _AGENT_PENDING_SENTINEL:
try:
summary = running_agent.get_activity_summary()
elapsed_min = 0
if _busy_state and _busy_state.turn.started_ts:
elapsed_min = int((now - _busy_state.turn.started_ts) / 60)
if elapsed_min > 0:
status_parts.append(t("gateway.busy.elapsed_min", minutes=elapsed_min))
if summary.get("max_iterations", 0):
status_parts.append(
format_iteration_progress(
summary.get("api_call_count", 0), summary.get("max_iterations", 0)
)
)
if summary.get("current_tool"):
status_parts.append(t("gateway.busy.running_tool", tool=summary.get("current_tool")))
except Exception:
pass
status_detail = (t("gateway.busy.ack_detail_wrap", detail=t("gateway.busy.ack_detail_joiner").join(status_parts))
if status_parts else "")
if is_steer_mode and self._agent_has_active_subagents(running_agent):
head = t("gateway.busy.steered_subagents_head")
tail = t("gateway.busy.steered_subagents_tail")
elif is_steer_mode:
head, tail = t("gateway.busy.steered_head"), t("gateway.busy.steered_tail")
elif is_redirect_mode:
head, tail = t("gateway.busy.redirected_head"), t("gateway.busy.redirected_tail")
elif is_queue_mode and demoted_for_subagents:
# Explain the demotion: the follow-up didn't kill the subagent; /stop is the escape hatch.
head, tail = t("gateway.busy.subagent_working_head"), self._BUSY_DEMOTED_TAIL
elif is_queue_mode and demoted_for_compression:
head, tail = t("gateway.busy.compressing_head"), self._BUSY_DEMOTED_TAIL
elif is_queue_mode:
head, tail = t("gateway.busy.queued_head"), t("gateway.busy.queued_tail")
else:
head, tail = t("gateway.busy.interrupting_head"), t("gateway.busy.interrupting_tail")
message = t("gateway.busy.ack", head=head, detail=status_detail, tail=tail)
# One-time onboarding hint about the queue/interrupt knob (flag persisted to config.yaml).
try:
from agent.onboarding import (BUSY_INPUT_FLAG, busy_input_hint_gateway, is_seen, mark_seen)
if not is_seen(_load_gateway_config(), BUSY_INPUT_FLAG):
_hint_mode = (
"steer" if is_steer_mode
else "queue" if is_queue_mode
else "redirect" if is_redirect_mode
else "interrupt"
)
message = f"{message}\n\n{busy_input_hint_gateway(_hint_mode)}"
mark_seen(_hermes_home / "config.yaml", BUSY_INPUT_FLAG)
except Exception as _onb_err:
logger.debug("Failed to apply busy-input onboarding hint: %s", _onb_err)
return message
async def _send_busy_ack_reply(self, event: MessageEvent, adapter, message: str) -> None:
try:
await self._send_busy_reply(event, adapter, message)
except Exception as e:
logger.debug("Failed to send busy-ack: %s", e)
async def _handle_active_session_busy_message(self, event: MessageEvent, session_key: str) -> bool:
# Gateway wakes have no external user identity. Admit them before auth/drain/approval
# handling, without merging their text into an already queued human message.
if event.internal and event.allow_gateway_control:
adapter = self._delivery_adapter_for(event.source)
if adapter and session_key in getattr(adapter, "_pending_messages", {}):
self._queue_or_replace_pending_event(session_key, event)
return True
return False # base adapter queues silently behind the active turn
# Same authorization gate as the cold path, else unauthorized users in shared threads
# inject messages into a session they don't own.
from gateway.run import _AGENT_PENDING_SENTINEL
# See #17775. A primary transport can route a turn into a secondary
# profile, so authorize in the stamped transport scope.
if not self._is_user_authorized_for_source(event.source):
logger.warning(
"Dropping message from unauthorized user in active session: "
"user=%s (%s), platform=%s, session=%s", event.source.user_id, event.source.user_name,
event.source.platform.value if event.source.platform else "unknown", session_key,
)
return True # handled (silently dropped); do not fall through
# A steered or queued follow-up never reaches _hm_admit_event, so the budget is charged here.
if not self._admit_bot_message_for_source(event.source):
return True
event._bot_loop_admitted = True
effective_mode = self._effective_busy_input_mode(event.source)
if self._draining: # gateway restarting/stopping
await self._send_busy_drain_notice(event, session_key, effective_mode)
return True
if await self._route_plaintext_approval_while_busy(event, session_key):
return True
adapter = self._delivery_adapter_for(event.source)
if not adapter:
return False # let default path handle it
# Internal synthetic events (delegation / background completions) must never interrupt or
# steer; they surface as a NEW turn when idle. Plugin events carry untrusted payload text, so
# queue them through the FIFO (security metadata kept apart).
if getattr(event, "internal", False):
self._queue_or_replace_pending_event(session_key, event)
return True
if (
event.message_type == MessageType.TEXT
and self._effective_busy_text_mode(event.source) == "queue"
and effective_mode != "steer"
):
return False
_busy_state = self._peek_session_state(session_key)
running_agent = _busy_state.turn.agent if _busy_state else None
_steer = await self._resolve_busy_steer_or_redirect(event, session_key, effective_mode, running_agent)
effective_mode, redirected = _steer.effective_mode, _steer.redirected
# Queue as the next turn — skipped after a successful steer/redirect (the text is already in
# the run and must NOT replay). FIFO gives each text its own turn (raw merge would join them).
if not _steer.steered and not redirected:
self._queue_or_replace_pending_event(session_key, event)
# Store the message so it's processed as the next turn after the current run finishes (or is
# interrupted). Skip this for a successful steer — the text already landed inside the run and must
# NOT also be replayed as a next-turn user message. Route through _queue_or_replace_pending_event
# (the same FIFO infrastructure used by busy queue-mode and /queue) rather than a raw
# merge_pending_message_event(merge_text=True). The raw merge newline-joins consecutive TEXT
# follow-ups into a SINGLE pending turn, destroying message boundaries — so two separate user
# messages sent while the agent was busy (interrupt mode, or a steer that fell back to queue)
# arrived as one mashed-together turn (#43066 sub-bug 2). The FIFO path gives each text its own turn
# in arrival order while still preserving photo-burst / album merge semantics for media.
is_queue_mode = effective_mode == "queue"
is_steer_mode = effective_mode == "steer"
is_redirect_mode = effective_mode == "interrupt" and redirected
if (
effective_mode == "interrupt" and not redirected
and running_agent and running_agent is not _AGENT_PENDING_SENTINEL
):
await self._interrupt_running_agent_for_busy_event(event, adapter, running_agent)
# Disabled ack: still process input. Checked before debounce so an undelivered ack never
# stamps the "last ack" timestamp.
if os.environ.get("HERMES_GATEWAY_BUSY_ACK_ENABLED", "true").lower() != "true":
logger.debug("Busy ack suppressed for session %s", session_key)
return True # input still processed, just no ack sent
# Debounce (30s) before the config-heavy display lookup.
now = time.time()
if now - (_busy_state.turn.busy_ack_ts if _busy_state else 0) < 30:
return True # interrupt sent (if not queue), ack already delivered recently
if is_steer_mode and not self._busy_steer_ack_enabled(event, session_key):
return True
self._session_state(session_key).turn.busy_ack_ts = now
message = self._compose_busy_ack_message(
event, now, _busy_state, running_agent, is_steer_mode=is_steer_mode,
is_queue_mode=is_queue_mode, is_redirect_mode=is_redirect_mode,
demoted_for_subagents=_steer.demoted_for_subagents,
demoted_for_compression=_steer.demoted_for_compression,
)
await self._send_busy_ack_reply(event, adapter, message)
return True
# Slash name → handler method is ``_handle_<name>_command`` (``-`` → ``_``) except these.
_COMMAND_HANDLER_ALIASES = {"bg": "_handle_background_command", "sethome": "_handle_set_home_command"}
# Ordinary slash handlers shared by idle and busy dispatch.
_PLAIN_COMMANDS = (
"status", "context", "restart", "approve", "deny", "pause", "agents", "bg", "btw",
"kanban", "subgoal", "heartbeat", "busy", "yolo", "verbose", "footer", "help",
"commands", "profile", "login", "update", "version",
)
# Dispatched only on the idle path (busy dispatch has its own allowlist).
_IDLE_COMMANDS = (
"topic", "whoami", "platform", "stop", "reasoning", "memory", "skills", "fast",
"approvals", "model", "codex-runtime", "personality", "suggestions", "save", "retry",
"sethome", "compress", "usage", "topup", "insights", "reload-mcp", "reload-skills",
"bundles", "debug", "title", "resume", "sessions", "branch", "rollback", "diff", "goal",
"loop", "refine", "review", "voice",
)
def _command_handler_table(self, names) -> Dict[str, Any]:
return {
name: getattr(
self, self._COMMAND_HANDLER_ALIASES.get(name, f"_handle_{name.replace('-', '_')}_command"),
)
for name in names
}
def _gateway_plain_command_handlers(self):
"""Return ordinary slash handlers shared by idle and busy dispatch."""
return self._command_handler_table(self._PLAIN_COMMANDS)
async def _send_command_ack(self, source, text: str, label: str) -> None:
"""Best-effort acknowledgment for a slash command that falls through to agent processing."""
try:
adapter = self._delivery_adapter_for(source)
if adapter:
await adapter.send(
str(source.chat_id), text, metadata=self._thread_metadata_for_source(source)
)
except Exception:
logger.debug("%s ack send failed", label, exc_info=True)
def _gateway_idle_command_handlers(self):
"""Slash handlers dispatched only on the idle path (busy dispatch has its own allowlist)."""
return self._command_handler_table(self._IDLE_COMMANDS)
# busy_handler key (hermes_cli/commands.py CommandDef) → mid-run variant ``_busy_<key>_command``.
_BUSY_SPECIAL_HANDLERS: Dict[str, str] = {
k: f"_busy_{k}_command" for k in ("start", "stop", "new", "queue", "steer", "egress", "goal", "loop")
}
async def _dispatch_busy_slash_command(self, event: MessageEvent, cmd_def, quick_key: str, source):
"""Dispatch a recognized slash command while an agent is running.
Order: ``busy_handler`` (mid-run variant) → ``busy_policy == "dispatch"`` (normal handler)
→ catch-all reject text. Rejecting is required rather than falling through to
interrupt + discard: commands like /model, /reasoning, /voice, /insights, /title,
/resume, /retry, /undo, /compress, /usage, /reload-mcp, /sethome, /reset (all
registered as Discord slash commands) would interrupt the agent AND get silently
discarded by the slash-command safety net, producing a zero-char response.
See #5057, #6252, #10370.
1. ``busy_handler`` — special mid-run variant (e.g. /goal's control-verb whitelist, /queue's FIFO
enqueue, /model's custom reject text). 2. 3. See #5057, #6252, #10370.
"""
name = cmd_def.name
policy = getattr(cmd_def, "busy_policy", "reject")
handler_key = getattr(cmd_def, "busy_handler", None)
if handler_key:
special = self._BUSY_SPECIAL_HANDLERS.get(handler_key)
if special is not None:
return await getattr(self, special)(event, quick_key, source)
reject_key = self._BUSY_REJECT_TEXT.get(handler_key)
if reject_key is not None:
return t(reject_key)
if policy in ("dispatch", "interrupt_then_dispatch"):
plain = self._gateway_plain_command_handlers().get(name)
if plain is not None:
async with self._async_profile_scope_for_source(source):
return await plain(event)
logger.warning(
"busy_policy=%s for /%s has no mid-run handler — "
"falling back to busy-reject", policy, name,
)
return t("gateway.busy.slash_rejected", command=name)
async def _handle_pause_command(self, event: MessageEvent):
"""`/pause [reason]` engages the global emergency stop; `/pause off` lifts it (the estop gate
lets slash commands through while paused so messaging-only operators are never locked out)."""
from agent import estop
args = (event.get_command_args() or "").strip()
if args.lower() in {"off", "resume", "stop", "disengage"}:
if estop.disengage():
return t("gateway.pause.resumed")
return t("gateway.pause.not_paused")
state = estop.get_state()
if state is not None and not args:
suffix = t("gateway.pause.reason_suffix", reason=state.get("reason")) if state.get("reason") else ""
return t("gateway.pause.already_paused", suffix=suffix)
estop.engage(reason=args or None)
suffix = t("gateway.pause.reason_suffix", reason=args) if args else ""
return t("gateway.pause.paused", suffix=suffix)
async def _busy_start_command(self, event: MessageEvent, quick_key: str, source):
# Telegram's /start is a platform ping (bot launch/deep-link), not a user command.
logger.info("Ignoring /start platform ping for active session %s", quick_key)
return ""
async def _busy_egress_command(self, event: MessageEvent, quick_key: str, source):
from hermes_cli.proxy_cli import format_status_text
return format_status_text()
async def _busy_stop_command(self, event: MessageEvent, quick_key: str, source):
# Hard-kill: a soft interrupt can't reach a truly hung executor thread.
from gateway.run import _INTERRUPT_REASON_STOP
await self._interrupt_and_clear_session(
quick_key, source, interrupt_reason=_INTERRUPT_REASON_STOP, invalidation_reason="stop_command",
)
logger.info("STOP for session %s — agent interrupted, session lock released", quick_key)
return EphemeralReply(t("gateway.stop.stopped"))
async def _busy_new_command(self, event: MessageEvent, quick_key: str, source):
# /reset and /new bypass the running-agent guard (else they'd queue as user text and replay
# into the same broken history); clear pending messages so the old text doesn't replay.
from gateway.run import _INTERRUPT_REASON_RESET
# Interrupt the agent first, then clear the adapter's pending queue so the stale "/reset" text
# doesn't get re-processed as a user message after the interrupt completes. See #2170.
await self._interrupt_and_clear_session(
quick_key, source, interrupt_reason=_INTERRUPT_REASON_RESET, invalidation_reason="new_command",
)
return await self._handle_reset_command(event)
async def _busy_queue_command(self, event: MessageEvent, quick_key: str, source):
# Each /queue is its own full agent turn, run FIFO after the current run; never merged.
queued_text = event.get_command_args().strip()
# A /queue carrying media or reply context is valid with no prompt text (image caption).
has_media = bool(getattr(event, "media_urls", None))
if not queued_text and not has_media:
return t("gateway.queue.usage")
adapter = self._delivery_adapter_for(source)
if adapter:
self._enqueue_fifo(quick_key, MessageEvent(
text=queued_text, message_type=event.message_type if has_media else MessageType.TEXT,
source=event.source, raw_message=event.raw_message, message_id=event.message_id,
media_urls=list(getattr(event, "media_urls", []) or []),
media_types=list(getattr(event, "media_types", []) or []),
media_text_inlined=list(getattr(event, "media_text_inlined", []) or []),
reply_to_message_id=event.reply_to_message_id, reply_to_text=event.reply_to_text,
reply_to_author_id=event.reply_to_author_id,
reply_to_author_name=event.reply_to_author_name,
reply_to_is_own_message=event.reply_to_is_own_message, auto_skill=event.auto_skill,
channel_prompt=event.channel_prompt, channel_context=event.channel_context,
internal=event.internal, timestamp=event.timestamp,
), adapter)
depth = self._queue_depth(quick_key, adapter=adapter)
return t("gateway.queue.queued") + (t("gateway.queue.queued_depth", depth=depth) if depth > 1 else "")
async def _busy_steer_command(self, event: MessageEvent, quick_key: str, source):
# /steer lands BETWEEN tool-call iterations of the same run (appended to the last tool
# result) — no interrupt, no new user turn, no role-alternation violation.
from gateway.run import _AGENT_PENDING_SENTINEL
steer_text = event.get_command_args().strip()
if not steer_text:
return t("gateway.steer.usage")
_steer_state = self._peek_session_state(quick_key)
running_agent = _steer_state.turn.agent if _steer_state else None
def _queue_fallback(reply: str) -> str:
# Turn-boundary fallback: queue the steer text as its own follow-up turn.
adapter = self._delivery_adapter_for(source)
if adapter:
self._enqueue_fifo(quick_key, MessageEvent(
text=steer_text, message_type=MessageType.TEXT, source=event.source,
message_id=event.message_id, channel_prompt=event.channel_prompt,
channel_context=event.channel_context,
), adapter)
return reply
if running_agent is _AGENT_PENDING_SENTINEL:
return _queue_fallback(t("gateway.steer.queued_starting"))
if not running_agent or not hasattr(running_agent, "steer"):
return _queue_fallback(t("gateway.steer.queued_no_agent"))
try:
accepted = self._steer_running_agent(running_agent, self._steer_text_with_origin(steer_text, event))
except Exception as exc:
logger.warning("Steer failed for session %s: %s", quick_key, exc)
return t("gateway.steer.failed", error=exc)
if not accepted:
return t("gateway.steer.rejected_empty")
self._fold_into_running_turn(running_agent, quick_key, event)
preview = steer_text[:60] + ("..." if len(steer_text) > 60 else "")
target = (t("gateway.steer.target_subagents") if self._agent_has_active_subagents(running_agent)
else t("gateway.steer.target_run"))
return t("gateway.steer.queued", target=target, preview=preview)
async def _busy_goal_command(self, event: MessageEvent, quick_key: str, source):
# Control verbs are safe mid-run (state only); setting new goal text is rejected so we don't
# race a second continuation against the current turn. wait/gate take an argument.
from hermes_cli.goal_command import is_goal_control
if is_goal_control(event.get_command_args() or ""):
return await self._handle_goal_command(event)
return t("gateway.busy.reject_goal")
async def _busy_loop_command(self, event: MessageEvent, quick_key: str, source):
# Mirrors /goal: control verbs are safe mid-run; a new loop is rejected.
_loop_arg = (event.get_command_args() or "").strip().lower()
if not _loop_arg or _loop_arg in {"status", "pause", "resume", "stop", "clear", "cancel", "help", "--help", "-h"}:
return await self._handle_loop_command(event)
return t("gateway.busy.reject_loop")
def _check_slash_access(self, source: SessionSource, canonical_cmd: str) -> Optional[str]:
"""Denial message if ``source`` cannot run ``canonical_cmd``, else None (both dispatch paths
use it so an in-flight agent can't bypass admin gating; no ``allow_admin_from`` → None)."""
from gateway.slash_access import policy_for_runner_source
if not canonical_cmd:
return None
policy = policy_for_runner_source(self, source)
if not policy.enabled or policy.can_run(source.user_id, canonical_cmd):
return None
logger.info(
"Slash command /%s denied for %s:%s (not admin, not in user_allowed_commands)",
canonical_cmd, source.platform.value if source.platform else "?", source.user_id,
)
allowed_preview = sorted(policy.user_allowed_commands)
if allowed_preview:
suffix = (
t("gateway.unauthorized.admin_only_can_run") + ", ".join(f"/{c}" for c in allowed_preview[:12])
+ ("…" if len(allowed_preview) > 12 else "") + t("gateway.unauthorized.admin_only_whoami_hint")
)
else:
suffix = t("gateway.unauthorized.admin_only_none")
return t("gateway.unauthorized.admin_only", command=canonical_cmd, suffix=suffix)
def _same_chat_runs(self, source: SessionSource, own_key: str) -> List[Tuple[str, str, str]]:
"""``(key, chat_type, tail)`` for every OTHER running turn in the caller's chat (``tail`` is
the key text after the chat id, ``""`` when the key ends there).
The namespace comes from ``own_key`` — the session store's own answer, so a named-profile
stop matches that profile's runs and never a literal. ``_snapshot_running_agents`` already
drops the pending sentinel (a session still being set up has no agent). Callers gate on
authorization; ``own_key`` is excluded. Both tiers share one call.
"""
chat_id = str(getattr(source, "chat_id", None) or "")
if not chat_id:
return []
if source.chat_type == "dm" and source.platform == Platform.WHATSAPP:
# Match the same text build_session_key keyed: WhatsApp DM chat ids are canonicalised
# there, so a raw JID/LID alias would never line up with the stored key.
chat_id = canonical_whatsapp_identifier(chat_id) or chat_id
namespace = ":".join(own_key.split(":", 2)[:2])
prefix = f"{namespace}:{source.platform.value}:"
scope_id = str(getattr(source, "scope_id", None) or "") or None
runs = []
for key in self._snapshot_running_agents():
if key == own_key:
continue
parsed = _same_chat_key_slots(key, prefix=prefix, chat_id=chat_id, scope_id=scope_id)
if parsed is not None:
runs.append((key, parsed[0], parsed[1]))
return runs
def _sibling_thread_run_keys(
self, source: SessionSource, runs: List[Tuple[str, str, str]],
) -> List[str]:
"""Keys from ``runs`` belonging to OTHER participants in the caller's own thread (per-user
thread mode keys are ``...:{thread_id}:{user_id}``, so another user's run is invisible to the
caller's own ``/stop``). Callers still gate on authz."""
thread_id = str(getattr(source, "thread_id", None) or "")
chat_type = getattr(source, "chat_type", None) or ""
if not thread_id or not chat_type:
return []
return [
key
for key, key_chat_type, tail in runs
if key_chat_type == chat_type and _tail_has_slot(tail, thread_id)
]
def _chat_scoped_run_keys(
self, source: SessionSource, runs: List[Tuple[str, str, str]],
) -> List[str]:
"""Keys from ``runs`` for ANY session of the same chat, whatever the chat_type/thread/
participant slots. Two supported shapes make a /stop key miss a run in the same chat (found
via Slack's native stop button, gateway-gateway#286): a top-level channel turn keys
``channel`` while an in-thread /stop normalizes to ``thread``, and rolling-DM configs key
without the thread slot the stop carries. "/stop" means "stop what's running in THIS chat",
which is also what lets a human stop a peer's per-sender group run (see ``_same_chat_runs``).
A stop sent from INSIDE a thread only reaches runs whose own thread slot is that thread (or
that carry no thread slot at all — the rolling-DM shape). Anything else in the channel is a
different conversation: another reply thread, or a peer's top-level run. Callers gate on
authz.
"""
thread_id = str(getattr(source, "thread_id", None) or "")
return [
key
for key, _key_chat_type, tail in runs
if not thread_id or not tail or _tail_has_slot(tail, thread_id)
]
def _is_stale_restart_redelivery(self, event: MessageEvent) -> bool:
"""True if this /restart is a Telegram re-delivery we already handled.
The previous gateway wrote ``.restart_last_processed.json`` (platform + update_id). A
/restart with update_id <= that value is a redelivery when this process booted from that
restart; otherwise the marker must be < 5 minutes old. Telegram only (numeric ordering).
"""
from gateway.run import _hermes_home
if event is None or event.source is None or event.platform_update_id is None:
return False
try:
if event.source.platform.value != "telegram":
return False
except Exception:
return False
try:
marker_path = _hermes_home / ".restart_last_processed.json"
if not marker_path.exists():
# Missing marker: a redelivered /restart would otherwise re-restart forever. Suppress
# ONLY when this process booted from a chat /restart AND is within a short post-boot
# window; consume the flag one-shot so a later legitimate /restart is honored.
if (
# Belt-and-suspenders for when the dedup marker goes missing (manually cleaned up, or
# the previous cycle's write failed). Without a marker the update_id comparison below
# can't run, so a redelivered /restart would sail through and re-restart the gateway —
# an infinite loop (issue #18528).
getattr(self, "_booted_from_restart", False)
and time.time() - getattr(self, "_startup_time", 0.0) < 60
):
self._booted_from_restart = False
return True
return False
data = json.loads(marker_path.read_text(encoding="utf-8-sig"))
except Exception:
return False
recorded_uid = data.get("update_id")
if (
data.get("platform") != "telegram"
or not isinstance(recorded_uid, int)
or event.platform_update_id > recorded_uid
):
return False
# A service-managed restart can outlast the 5-minute trust window; consume the boot
# signal one-shot.
if getattr(self, "_booted_from_restart", False):
self._booted_from_restart = False
return True
# Staleness guard: an old marker (crash recovery) must not swallow a fresh /restart.
requested_at = data.get("requested_at")
return not (isinstance(requested_at, (int, float)) and time.time() - requested_at > 300)
async def _handle_suggestions_command(self, event: MessageEvent) -> str:
"""/suggestions via the shared handler (origin = event source so jobs deliver back here)."""
from gateway.run import _command_origin_for_source
try:
from hermes_cli.suggestions_cmd import handle_suggestions_command
return handle_suggestions_command(
(event.get_command_args() or "").strip(),
origin=_command_origin_for_source(event.source), surface="gateway",
)
except Exception as e:
logger.debug("suggestions command failed: %s", e)
return t("gateway.suggestions.failed", error=e)
async def _handle_blueprint_command(self, event: MessageEvent):
"""/blueprint via the shared handler (origin = event source so jobs deliver back here)."""
from gateway.run import _command_origin_for_source
try:
from hermes_cli.blueprint_cmd import handle_blueprint_command
return handle_blueprint_command(
(event.get_command_args() or "").strip(),
origin=_command_origin_for_source(event.source), surface="gateway",
)
except Exception as e:
logger.debug("blueprint command failed: %s", e)
from hermes_cli.blueprint_cmd import BlueprintCommandResult
return BlueprintCommandResult(t("gateway.blueprint.failed", error=e))
async def _maybe_confirm_destructive_slash(
self, *, event: MessageEvent, command: str, title: str, detail: str, execute
) -> Union[str, "EphemeralReply", None]:
"""Gate a destructive session slash command (/new, /reset, /undo).
``execute()`` (async → str | EphemeralReply) runs immediately when
``approvals.destructive_slash_confirm`` is off; otherwise via ``_request_slash_confirm``:
``once`` runs it, ``always`` persists the opt-out then runs it, ``cancel`` skips it.
"""
confirm_required = True
try:
approvals = self._read_user_config().get("approvals")
if isinstance(approvals, dict):
confirm_required = bool(approvals.get("destructive_slash_confirm", True))
except Exception:
pass
if not confirm_required:
return await execute()
session_key = self._session_key_for_source(event.source)
async def _on_confirm(choice: str):
# Via the class, not ``self``: tests drive this gate on a bare SimpleNamespace runner.
return await GatewayBusySessionMixin._run_confirmed_destructive_slash(
choice, command, execute, session_key
)
_p = self._typed_command_prefix_for(event.source.platform)
prompt_message = t("gateway.confirm.destructive_prompt", command=command, detail=detail, prefix=_p)
return await self._request_slash_confirm(
event=event, command=command, title=title, message=prompt_message, handler=_on_confirm
)
@staticmethod
def _destructive_optout_note(persisted: bool) -> str:
"""Note appended after an "always" confirm. ``persisted=False``: the user did approve this
run, so the action still goes ahead, but the preference did not stick and the prompt will be
back next time — say so rather than promising an opt-out that was never written."""
return t("gateway.confirm.destructive_optout" if persisted else "gateway.confirm.destructive_optout_save_failed")
@staticmethod
async def _run_confirmed_destructive_slash(choice: str, command: str, execute, session_key: str):
"""Confirm-callback body: ``cancel`` → message; ``always`` persists the opt-out, then runs."""
if choice == "cancel":
return t("gateway.confirm.cancelled", command=command)
persisted = False
if choice == "always":
try:
from cli import save_config_value
# save_config_value swallows its own errors and reports the outcome in the return
# value, so the try block alone says nothing about whether the write landed.
persisted = bool(save_config_value("approvals.destructive_slash_confirm", False))
if persisted:
logger.info("User opted out of destructive slash confirm (session=%s)", session_key)
else:
logger.warning(
"Could not persist destructive_slash_confirm=false "
"(session=%s); config.yaml is not writable", session_key,
)
except Exception as exc:
logger.warning("Failed to persist destructive_slash_confirm=false: %s", exc)
result = await execute()
# Only plain-string results get the note: it would mangle an EphemeralReply.
if choice == "always" and isinstance(result, str):
return result + GatewayBusySessionMixin._destructive_optout_note(persisted)
return result
async def _request_slash_confirm(
self, *, event: MessageEvent, command: str, title: str, message: str, handler
) -> Optional[str]:
"""Ask the user to confirm a slash command; ``handler(choice)`` runs on "once"/"always"/
"cancel" and its return is sent as a message. Returns None if buttons rendered, else the
text-fallback message (which IS the ack)."""
from tools import slash_confirm as _slash_confirm_mod
source = event.source
session_key = self._session_key_for_source(source)
# object.__new__ test runners lack the counter; fall back to a local one.
counter = getattr(self, "_slash_confirm_counter", None)
if counter is None:
import itertools as _itertools
counter = self._slash_confirm_counter = _itertools.count(1)
confirm_id = f"{next(counter)}"
# Register FIRST so a fast button click cannot race the send_slash_confirm return.
_slash_confirm_mod.register(session_key, confirm_id, command, handler)
adapter = self._delivery_adapter_for(source)
metadata = self._thread_metadata_for_source(source, self._reply_anchor_for_event(event))
if adapter is not None:
try:
button_result = await adapter.send_slash_confirm(
chat_id=source.chat_id, title=title, message=message, session_key=session_key,
confirm_id=confirm_id, metadata=metadata,
)
if button_result and getattr(button_result, "success", False):
return None # buttons rendered — no redundant text ack
# P5(b): distinguish a connector egress DECLINE from a lane
# failure. On a decline the connector refused this destination,
# so returning `message` as the direct reply would deliver the
# very content it refused, as text, to the same chat. Suppress
# the fallback and tear down the registration — no card
# rendered, so a later reply must not be captured as an answer
# to an invisible prompt.
#
# Classify the STRUCTURED response (see _approval_send_outcome):
# a code-only decline has no marker colon in its rendered text,
# and an ambiguous result must not be treated as a definite
# refusal.
from gateway.relay.egress import declined_send
_confirm_err = getattr(button_result, "error", None)
if declined_send(button_result):
logger.warning(
"slash-confirm DECLINED by the connector's egress "
"guard for %s on %s — suppressing the text fallback: %s",
command, source.platform, _confirm_err,
)
_slash_confirm_mod.clear(session_key)
return None
except Exception as exc:
logger.debug("send_slash_confirm failed for %s on %s: %s", command, source.platform, exc)
# Text fallback — the prompt message itself is the direct reply.
return message
def _read_user_config(self) -> Dict[str, Any]:
"""Raw config.yaml for gate lookups that must see on-disk changes without a restart."""
try:
from hermes_cli.config import load_config
cfg = load_config()
except Exception:
return {}
return cfg if isinstance(cfg, dict) else {}