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

0open issues
0open PRs
12items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #25778 fix(gateway): enable text-intercept for multi-choice clarify fallback (#25587)

    merged to main

    files: gateway/platforms/base.py, tests/tools/test_clarify_gateway.py

    Show 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)

    merged to main

    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.py

    Show 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.00 means 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

At a glance

Subsystem
gateway
Merged PRs
#25778, #54160
Closed issues
4
Attempts
6 PRs closed without merging
Fix link checked
28 Jun 2026

For your agent

The same facts, typed.