Clarify prompt typed-reply interception across platforms
NousResearch/hermes-agent · cluster 25567 · gateway
Data as of
Nothing open on GitHub. Fix merged in #25778. Check it covers your case.
The short answer
- Known?
- Yes. 12 related items grouped here, 4 issues already closed.
- Fix event
- A fix was merged to main in PR #25778. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #25778. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These issues and fixes concern the clarify tool failing to intercept typed text replies when presenting multi-choice prompts, affecting Mattermost, Discord, Telegram, and the base gateway. Work spans bug reports, platform-specific rendering changes, and session state fixes.
Open issues0
Open PRs0
Fixes on record
-
#25778 fix(gateway): enable text-intercept for multi-choice clarify fallback (#25587)
files:
gateway/platforms/base.py,tests/tools/test_clarify_gateway.pyShow the diff
-
gateway/platforms/base.py@@ -1774,8 +1774,12 @@ async def send_clarify( The default implementation falls back to a numbered text list, which works on every platform — the user replies with a number ("2") or with the literal choice text, and the gateway intercepts - and resolves. Adapters with native button UIs (Telegram, Discord) - SHOULD override this for a richer UX. + and resolves. For the text fallback path, the default calls + ``mark_awaiting_text()`` so that the gateway text-intercept + (:meth:`GatewayRunner._maybe_intercept_clarify_text`) catches the + user's reply instead of timing out. + Adapters with native button UIs (Telegram, Discord) SHOULD + override this for a richer UX. """ if choices: lines = [f"❓ {question}", ""] @@ -1784,6 +1788,10 @@ async def send_clarify( lines.append("") lines.append("Reply with the number, the option text, or your own answer.") text = "\n".join(lines) + # Text fallback: enable text-capture so the gateway intercept + # picks up the user's typed reply (e.g. "2" or choice text). + from tools.clarify_gateway import mark_awaiting_text + mark_awaiting_text(clarify_id) else: text = f"❓ {question}" return await self.send( -
tests/tools/test_clarify_gateway.py@@ -205,3 +205,23 @@ def test_get_pending_for_session_returns_oldest_text_awaiting(self): pending2 = cm.get_pending_for_session("sk") assert pending2 is not None assert pending2.clarify_id == "first" + def test_text_fallback_enables_awaiting_text_for_multi_choice(self): + """When base send_clarify renders choices as text, mark_awaiting_text + is called so the gateway text-intercept can capture the reply.""" + from tools import clarify_gateway as cm + + entry = cm.register("id-tf", "sk-tf", "Pick one", ["A", "B", "C"]) + # Initially, multi-choice does NOT await text (button path) + assert entry.awaiting_text is False + + # After the base send_clarify text fallback calls mark_awaiting_text: + flipped = cm.mark_awaiting_text("id-tf") + assert flipped is True + + # Now get_pending_for_session should find it + pending = cm.get_pending_for_session("sk-tf") + assert pending is not None + assert pending.clarify_id == "id-tf" + + # Clean up + cm.clear_session("sk-tf")
-
-
#54160 fix(gateway): accept typed replies to multi-choice clarify prompts (#38475)
files:
gateway/platforms/base.py,gateway/run.py,infographic/clarify-typed-replies/infographic.png,tests/gateway/test_clarify_active_session_bypass.py,tests/tools/test_clarify_gateway.py,tools/clarify_gateway.pyShow the diff
-
gateway/platforms/base.py@@ -4410,11 +4410,11 @@ async def handle_message(self, event: MessageEvent) -> None: logger.error("[%s] Command '/%s' dispatch failed: %s", self.name, cmd, e, exc_info=True) return - # Clarify text-capture bypass: if the agent is blocked on a - # clarify_tool call awaiting a free-form text response (open- - # ended clarify, or user picked "Other"), the next non-command - # message in this session MUST reach the runner so the - # clarify-intercept can resolve it and unblock the agent. + # Clarify reply bypass: if the agent is blocked on a + # clarify_tool call, the next non-command message in this + # session MUST reach the runner so typed numeric choices, + # exact choices, and free-form "Other" answers can resolve the + # clarify-intercept and unblock the agent. # # Without this bypass: the message gets queued in # _pending_messages as a follow-up turn instead of reaching the @@ -4427,7 +4427,10 @@ async def handle_message(self, event: MessageEvent) -> None: try: from tools import clarify_gateway as _clarify_mod _has_text_clarify = ( - _clarify_mod.get_pending_for_session(session_key) is not None + _clarify_mod.get_pending_for_session( + session_key, + include_choice_prompts=True, + ) is not None ) except Exception: _has_text_clarify = False -
gateway/run.py@@ -8037,26 +8037,28 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: ) _update_prompts.pop(_quick_key, None) - # Intercept messages that are responses to a pending clarify - # request that is awaiting free-form text (either an open-ended - # clarify with no choices, or one where the user picked the - # "Other" button). The first non-empty user message in the - # session resolves the clarify and unblocks the agent thread — - # we do NOT route it to the agent as a new turn. + # Intercept messages that are responses to a pending clarify. + # Open-ended prompts and "Other" responses are captured as free text; + # direct replies to multi-choice prompts are accepted too ("2" maps + # to the second option, arbitrary text becomes a custom answer). Slash + # commands still bypass this path so /stop and friends keep working. + _clarify_mod = None try: from tools import clarify_gateway as _clarify_mod - _pending_clarify = _clarify_mod.get_pending_for_session(_quick_key) + _pending_clarify = _clarify_mod.get_pending_for_session( + _quick_key, include_choice_prompts=True, + ) except Exception: _pending_clarify = None - if _pending_clarify is not None: + if _pending_clarify is not None and _clarify_mod is not None: _raw_clarify_reply = (event.text or "").strip() # Skip slash commands — the user clearly wanted to issue a # command, not answer the clarify. Leave the clarify pending # so the user can retry; if it times out, the agent unblocks # with an empty response. if _raw_clarify_reply and not _raw_clarify_reply.startswith("/"): - _resolved = _clarify_mod.resolve_gateway_clarify( - _pending_clarify.clarify_id, _raw_clarify_reply, + _resolved = _clarify_mod.resolve_text_response_for_session( + _quick_key, _raw_clarify_reply, ) if _resolved: logger.info( -
tests/gateway/test_clarify_active_session_bypass.py@@ -0,0 +1,87 @@ +"""Regression tests for clarify replies while a gateway session is busy.""" + +import asyncio +from unittest.mock import AsyncMock + +import pytest + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + SendResult, +) +from gateway.session import SessionSource, build_session_key + + +class _ClarifyBypassAdapter(BasePlatformAdapter): + def __init__(self): + super().__init__(PlatformConfig(enabled=True, token="test"), Platform.TELEGRAM) + + async def connect(self): + return True + + async def disconnect(self): + pass + + async def send(self, chat_id, content, reply_to=None, metadata=None): + return SendResult(success=True, message_id="text") + + async def get_chat_info(self, chat_id): + return {"id": chat_id, "type": "private"} + + +def _event(text="custom answer"): + return MessageEvent( + text=text, + message_type=MessageType.TEXT, + source=SessionSource( + platform=Platform.TELEGRAM, + chat_id="12345", + chat_type="private", + user_id="user1", + ), + message_id="msg1", + ) + + +def _clear_clarify_state(): + from tools import clarify_gateway as cm + + with cm._lock: + cm._entries.clear() + cm._session_index.clear() + cm._notify_cbs.clear() + + [redacted:email] +async def test_active_session_routes_typed_choice_clarify_reply_to_runner_not_busy_queue(): + """Typed text must resolve a pending choice clarify even while the agent is busy. + + Telegram button clarifies keep the adapter session active while the agent + thread blocks on ``wait_for_response``. If the adapter only bypasses for + entries already marked ``awaiting_text``, typed replies to the visible + multi-choice prompt are handled as busy follow-ups and the clarify wait is + never resolved. + """ + _clear_clarify_state() + from tools import clarify_gateway as cm + + adapter = _ClarifyBypassAdapter() + adapter._message_handler = AsyncMock(return_value="") + adapter._busy_session_handler = AsyncMock(return_value=True) + event = _event("None of those are valid options") + session_key = build_session_key( + event.source, + group_sessions_per_user=adapter.config.extra.get("group_sessions_per_user", True), + thread_sessions_per_user=adapter.config.extra.get("thread_sessions_per_user", False), + ) + adapter._active_sessions[session_key] = asyncio.Event() + cm.register("clarify-1", session_key, "Pick one", ["A", "B"]) + + await adapter.handle_message(event) + + adapter._message_handler.assert_awaited_once_with(event) + adapter._busy_session_handler.assert_not_awaited() + assert adapter._pending_messages == {} -
tests/tools/test_clarify_gateway.py@@ -64,6 +64,32 @@ def test_button_choice_does_not_auto_await(self): assert entry.awaiting_text is False assert cm.get_pending_for_session("sk3") is None + def test_include_choice_prompts_returns_multi_choice_entry(self): + """Gateway typed replies must see active choice prompts too.""" + from tools import clarify_gateway as cm + + cm.register("id3b", "sk3b", "Pick", ["X", "Y"]) + pending = cm.get_pending_for_session("sk3b", include_choice_prompts=True) + assert pending is not None + assert pending.clarify_id == "id3b" + + def test_resolve_text_response_maps_numeric_choice(self): + """Typed numbers should resolve to the canonical choice string.""" + from tools import clarify_gateway as cm + + cm.register("id3c", "sk3c", "Pick", ["X", "Y"]) + assert cm.resolve_text_response_for_session("sk3c", "2") is True + assert cm.wait_for_response("id3c", timeout=0.1) == "Y" + + def test_resolve_text_response_accepts_custom_other_text(self): + """Arbitrary typed text should resolve as a custom Other answer.""" + from tools import clarify_gateway as cm + + cm.register("id3d", "sk3d", "Pick", ["X", "Y"]) + custom = "None of those are valid options" + assert cm.resolve_text_response_for_session("sk3d", custom) is True + assert cm.wait_for_response("id3d", timeout=0.1) == custom + def test_other_button_flips_to_text_mode(self): """mark_awaiting_text makes get_pending_for_session find the entry.""" from tools import clarify_gateway as cm -
tools/clarify_gateway.py@@ -162,12 +162,19 @@ def resolve_gateway_clarify(clarify_id: str, response: str) -> bool: return True -def get_pending_for_session(session_key: str) -> Optional[_ClarifyEntry]: - """Return the OLDEST pending clarify entry for a session, or None. - - Used by the text-fallback intercept in ``_handle_message`` — when a - clarify is awaiting a free-form text response, the next user message - in that session is captured as the answer. +def get_pending_for_session( + session_key: str, + *, + include_choice_prompts: bool = False, +) -> Optional[_ClarifyEntry]: + """Return the oldest pending clarify entry for a session, or None. + + By default this only returns entries awaiting free-form text (open-ended + clarifies, or a multi-choice clarify after the user picked ``Other``). + Gateways may pass ``include_choice_prompts=True`` when the user has typed + directly in response to an active multi-choice prompt; in that case the + oldest unresolved clarify is returned so the text can resolve it instead + of being queued as an unrelated follow-up turn. """ with _lock: ids = _session_index.get(session_key) or [] @@ -175,11 +182,38 @@ def get_pending_for_session(session_key: str) -> Optional[_ClarifyEntry]: entry = _entries.get(cid) if entry is None: continue - if entry.awaiting_text: + if include_choice_prompts or entry.awaiting_text: return entry return None +def _coerce_text_response(entry: _ClarifyEntry, response: str) -> str: + """Map typed choice replies to canonical choice text, otherwise keep custom text.""" + text = str(response).strip() + if entry.choices: + try: + idx = int(text) - 1 + except ValueError: + idx = -1 + if 0 <= idx < len(entry.choices): + return entry.choices[idx] + for choice in entry.choices: + if text.casefold() == str(choice).strip().casefold(): + return str(choice).strip() + return text + + +def resolve_text_response_for_session(session_key: str, response: str) -> bool: + """Resolve the oldest pending clarify in ``session_key`` from typed text.""" + entry = get_pending_for_session(session_key, include_choice_prompts=True) + if entry is None: + return False + return resolve_gateway_clarify( + entry.clarify_id, + _coerce_text_response(entry, response), + ) + + def mark_awaiting_text(clarify_id: str) -> bool: """Flip an entry into text-capture mode (user picked the 'Other' button).
-
6 PRs closed without merging
From the close comments: superseded by another PR 3 · merged via another change 1 · other 2
-
#25587 fix(gateway): enable text-intercept for multi-choice clarify fallback · merged via another change →
#25778 -
#38484 fix: accept typed clarify choice replies · other →
d7f655f,#54160,#38484 -
#38544 fix(gateway): intercept freeform text replies to multi-choice clarify prompts #38475 · superseded by another PR →
#54160,#38475 - #41353 fix(discord): render clarify choices as Select menu, not truncated buttons · superseded by another PR
-
#47603 fix(gateway): resolve button-prompt clarifies via typed replies · superseded by another PR →
#47566
Show 1 more
- #47613 fix(telegram): intercept typed replies to button clarify prompts (#47566) · other
Closed issues · 4
- Closed#25567Bug: clarify multiple-choice text fallback never intercepts user reply on Mattermost
- Closed#26009[Bug] clarify tool hangs indefinitely with 'Still working...' on Discord (choice-based clarifies not intercepted)
- Closed#38475Gateway clarify prompt ignores typed freeform replies while awaiting response
- Closed#47566clarify: typed replies to button prompts fall through instead of resolving the clarify — intended?
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.14: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab