Skip to content

feat(gateway): per-message busy-session controls — inline buttons + multilingual halt heuristic#21080

Open
tymrtn wants to merge 10 commits into
NousResearch:mainfrom
tymrtn:feat/busy-session-buttons-pr
Open

feat(gateway): per-message busy-session controls — inline buttons + multilingual halt heuristic#21080
tymrtn wants to merge 10 commits into
NousResearch:mainfrom
tymrtn:feat/busy-session-buttons-pr

Conversation

@tymrtn

@tymrtn tymrtn commented May 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds per-message busy-session controls on top of the existing display.busy_input_mode infrastructure. Users can choose steer / interrupt / stop on a specific follow-up message via inline keyboard buttons instead of only changing a global mode.

Resolves #11639. Resolves #11118.

What changed

  • Adds reusable busy-session button payload/handle helpers with short hashes for Telegram's 64-byte callback_data limit.
  • Adds Telegram, Discord, and Slack button attachment/update paths, with no-op defaults for platforms that do not implement controls.
  • Adds a conservative multilingual halt-phrase matcher so plain stop / alto / 止まれ style messages can unlock a busy chat without slash-command exactness.
  • Changes the default busy behavior to queue and keeps explicit per-message override controls visible while the turn runs.
  • Defers queue acknowledgements briefly so controls attach to the live tool/progress bubble when possible, avoiding duplicate “Queued...” bubbles above tool output.
  • Keeps one active control surface by clearing older ack keyboards when controls move to a tool bubble or newer ack.
  • Preserves Telegram controls on streaming edits by reattaching the existing busy keyboard on non-final edit_message_text calls.

Dogfood evidence

This has been running in Tyler's live Telegram gateway. The interrupt buttons are working well in practice, including long tool-running sessions where follow-up messages need to be queued, steered, interrupted, or stopped without losing the current transcript.

Test plan

  • python -m py_compile gateway/run.py gateway/platforms/telegram.py gateway/platforms/discord.py gateway/platforms/slack.py gateway/busy_session_buttons.py gateway/stop_phrases.py
  • python -m pytest -o addopts='' tests/gateway/test_busy_session_buttons.py tests/gateway/test_busy_session_runner.py tests/gateway/test_stop_phrase_matcher.py tests/gateway/test_telegram_busy_controls.py tests/gateway/test_telegram_clarify_buttons.py tests/gateway/test_restart_drain.py -q
    • 105 passed in 1.26s

Compatibility

  • display.busy_buttons: false disables the new inline controls.
  • Existing explicit display.busy_input_mode settings still work.
  • Platforms without button implementations inherit no-op base methods.

@alt-glitch alt-glitch added type/feature New feature or request P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery platform/telegram Telegram bot adapter platform/discord Discord bot adapter platform/slack Slack app adapter labels May 7, 2026
tymrtn and others added 4 commits May 19, 2026 00:18
…ultilingual halt heuristic

Resolves NousResearch#11639. Resolves NousResearch#11118.

Adds per-message busy-session controls on top of the existing
``display.busy_input_mode`` infrastructure. Lets users pick **steer /
interrupt / stop** on a specific follow-up message via inline keyboard
buttons rather than only changing the global mode.

Three additive pieces:

1. **Inline keyboard ``[/steer] [/interrupt] [/stop]``** anchored to the
   running tool-progress bubble (Telegram + Discord + Slack). Reuses the
   existing busy-ack message as the keyboard anchor when no tool bubble
   is live yet — no extra messages, no duplication. After a tap, the ack
   body rewrites to reflect what actually happened (``⏩ Steered…`` /
   ``⚡ Interrupted…`` / ``🛑 Stopped.``) so chat history stays accurate.
   A single tap acts on EVERY follow-up that arrived since the agent
   went busy — texts joined, primitive applied once, reaction emitted on
   each individual user message.

2. **Multilingual halt-phrase heuristic** — 16-language exact-word
   matcher (en/es/fr/de/pt/it/nl/ja/ko/zh-Hans/zh-Hant/ru/ar/hi/tr/pl)
   with conservative function-word exclusions ("para" / "basta" /
   "잠깐" deliberately not included). Runs as a pre-flight inside
   ``_handle_active_session_busy_message``; matches like ``stop``,
   ``alto``, ``止まれ``, ``/``, or empty msg trigger the full
   ``_interrupt_and_clear_session`` path so the chat unlocks even if
   the agent is wedged inside a tool.

3. **Reaction lifecycle on user follow-ups** — 👍 (steer), ⚡
   (interrupt), 🙊 (halt). Wraps emoji in ``ReactionTypeEmoji`` to dodge
   python-telegram-bot's variation-selector ``custom_emoji``
   serialization bug.

**Default change:** ``display.busy_input_mode`` defaults to ``queue``
instead of ``interrupt``. Interrupt-by-default destroys partial work;
queue buffers the follow-up for the next turn. The new buttons give
users an explicit per-message override when they want something
different. New ``display.busy_buttons: true`` toggle (env:
``HERMES_GATEWAY_BUSY_BUTTONS=false``) for per-bot disable.

**Why deterministic instead of the LLM-router proposed in NousResearch#11639?**
No model latency, no false positives on ambiguous text, no per-message
token cost. The buttons are language-neutral and always available; the
halt heuristic gives a fast path for natural-language stops without
needing ``/stop``.

**Authorization:**
- Telegram: ``_is_callback_user_authorized`` (PR NousResearch#17775).
- Discord: ``_component_check_auth`` (mirrors ``ExecApprovalView`` etc.)
  PLUS runner-level ``_is_user_authorized`` for deployments using
  ``GATEWAY_ALLOWED_USERS`` or pairing.
- Slack: routes through ``GatewayRunner._is_user_authorized`` for full
  org-level checks; falls back to ``SLACK_ALLOWED_USERS`` only when no
  runner is bound, and FAILS CLOSED when neither is configured.

**Cross-user ownership gate:** in shared chats with per-user session
keys, user A's busy-session buttons are visible to user B. The runner
compares the target ``session_key`` against the key the tapping user's
source produces (via ``_session_key_for_source``, the resolver that
honors ``group_sessions_per_user`` / ``thread_sessions_per_user`` from
the session store) and rejects mismatches. Mirrors the implicit
isolation ``/stop`` already enjoys.

**Telegram callback_data 64-byte cap:** Long group/forum session keys
(e.g.
``agent:main:telegram:supergroup:-1001234567890:thread:42:user:9876543210``)
overflow Telegram's 64-byte cap. ``build_buttons_with_handles``
switches to a stable short hash (``bs:steer:#<12-char-sha256>``) and
surfaces the handle map; the platform registers the mapping at attach
time and resolves it on tap.

**Tests:**
- 21 button-module tests (wire format, primitive set, hash-handle
  invariants, callback parsing).
- 33 stop-phrase matcher tests (per-language matching, false-positive
  defenses, length cap, function-word exclusions).
- 19 runner-integration tests (halt pre-flight, button-tap dispatch,
  multi-followup concatenation, ack-text rewrite, control-bubble
  fallback, cross-user rejection, cleanup).
- All existing busy-session/auth/steer/queue/reaction upstream tests
  still pass (271 focused tests green).
- ``codex review --base origin/main`` — 7 passes, 25 findings resolved
  (Slack auth bypass, Discord adapter binding, halt-phrase full-clear,
  multi-anchor cleanup, callback-data cap, cross-user ownership,
  Slack 3000-char Block Kit cap, threaded-reply text fetch,
  forum-topic session-key normalization, …).

**Backwards-compatibility:**
- ``display.busy_buttons: false`` keeps current behavior (no buttons).
- ``display.busy_input_mode: interrupt`` (legacy) still works; the
  change is to the default only.
- All new platform methods on ``BasePlatformAdapter`` have no-op
  defaults; platforms that don't override (Matrix, Feishu, DingTalk,
  WhatsApp) are unaffected and inherit silently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@tymrtn tymrtn force-pushed the feat/busy-session-buttons-pr branch from 5bf1eff to 5eb97f4 Compare May 18, 2026 22:19
@goneflyin

Copy link
Copy Markdown

👍 from me -- as I created one just like this for Telegram, but simpler and not as broadly suitable. Would love to see this merged in!

@tymrtn

tymrtn commented May 26, 2026

Copy link
Copy Markdown
Contributor Author

Yeah, it's been a bother to have to maintain this outside of upstream bc it's functionality I heavily depend on

@tymrtn

tymrtn commented Jun 4, 2026

Copy link
Copy Markdown
Contributor Author

Production-use update: this has become one of the most useful gateway UX features in my Telegram/mobile workflow.

The ability to choose steer / interrupt / stop while a session is busy is materially better than the old all-or-nothing behavior. It preserves long-running work by default, while still providing a clear emergency brake when a tool call or model turn is stuck.

I’ve been using this regularly with long-running Telegram sessions, and it is solving a real reliability problem for chat-based operation. Another contributor also noted above that this implementation is broader than their Telegram-only version.

The PR has drifted and is currently conflicting with main; I’ll refresh the branch and rerun the focused busy-session gateway tests so it is easier to review.

@teknium1 flagging for visibility because this is a small-looking UX feature with a large practical impact for mobile/chat users.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists platform/discord Discord bot adapter platform/slack Slack app adapter platform/telegram Telegram bot adapter type/feature New feature or request

Projects

None yet

3 participants