NousResearch/hermes-agent

Clarify tool wiring for gateway/Telegram/Feishu

Cluster 12573 · gateway. Data as of

1 open item. Fix merged in #24199 on 12 May 2026. Check it covers your case.

The short answer

0open issues
1open PR
10items grouped
Known?
Yes. 10 related items grouped here, 5 issues already closed.
Fix event
A fix was merged to main in PR #24199. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #24199. That is separate from the fix event.
Still open
0 issues and 1 PR.

These issues and pull requests concern adding and fixing the wiring of a clarify tool—including callback handling and interactive buttons—across gateway sessions, Telegram, and Feishu messaging platforms. Several members address a bug where the clarify tool was exposed but its callback was never connected in gateway/Telegram mode.

Open issues0

No open issues.

Open PRs1

  • PR#29719feat(feishu): wire clarify tool with interactive card buttons

Fixes on record

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

  • #24199 feat(gateway): wire clarify tool with inline keyboard buttons on Telegram

    merged to main

    files: gateway/platforms/base.py, gateway/platforms/telegram.py, gateway/run.py, hermes_cli/config.py, tests/gateway/test_telegram_clarify_buttons.py, tests/tools/test_clarify_gateway.py, tools/clarify_gateway.py, website/docs/user-guide/messaging/telegram.md

    Show the diff
    • gateway/platforms/base.py
      @@ -1743,6 +1743,55 @@ async def send_slash_confirm(
               """
               return SendResult(success=False, error="Not supported")
       
      +    async def send_clarify(
      +        self,
      +        chat_id: str,
      +        question: str,
      +        choices: Optional[list],
      +        clarify_id: str,
      +        session_key: str,
      +        metadata: Optional[Dict[str, Any]] = None,
      +    ) -> SendResult:
      +        """Send a clarify prompt to the user.
      +
      +        Two render modes:
      +
      +          * **Multiple choice** (``choices`` is a non-empty list) — adapters
      +            that override this should render inline buttons (one per choice
      +            plus a final "Other" / free-text option).  Button callbacks
      +            MUST resolve via
      +            ``tools.clarify_gateway.resolve_gateway_clarify(clarify_id, response)``
      +            with the chosen string.  Picking the "Other" button calls
      +            ``mark_awaiting_text(clarify_id)`` so the next message in the
      +            session is captured as the response.
      +
      +          * **Open-ended** (``choices`` is None or empty) — render the
      +            question as a plain text message; the next user message in the
      +            session is captured by the gateway's text-intercept and
      +            resolves the clarify automatically (see
      +            ``GatewayRunner._maybe_intercept_clarify_text``).
      +
      +        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.
      +        """
      +        if choices:
      +            lines = [f"❓ {question}", ""]
      +            for i, choice in enumerate(choices, start=1):
      +                lines.append(f"  {i}. {choice}")
      +            lines.append("")
      +            lines.append("Reply with the number, the option text, or your own answer.")
      +            text = "\n".join(lines)
      +        else:
      +            text = f"❓ {question}"
      +        return await self.send(
      +            chat_id=chat_id,
      +            content=text,
      +            metadata=metadata,
      +        )
      +
           async def send_private_notice(
               self,
               chat_id: str,
      @@ -2831,6 +2880,58 @@ 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.
      +            #
      +            # Without this bypass: the message gets queued in
      +            # _pending_messages AND triggers an interrupt, killing the
      +            # agent run mid-clarify and discarding the user's answer.
      +            # Same shape as the /approve deadlock fix (PR #4926) — both
      +            # cases are "agent thread blocked on Event.wait, message must
      +            # reach the resolver before being treated as a new turn."
      +            if not cmd:
      +                try:
      +                    from tools import clarify_gateway as _clarify_mod
      +                    _has_text_clarify = (
      +                        _clarify_mod.get_pending_for_session(session_key) is not None
      +                    )
      +                except Exception:
      +                    _has_text_clarify = False
      +
      +                if _has_text_clarify:
      +                    logger.debug(
      +                        "[%s] Routing message to clarify text-intercept for %s",
      +                        self.name, session_key,
      +                    )
      +                    try:
      +                        _thread_meta = _thread_metadata_for_source(
      +                            event.source, _reply_anchor_for_event(event)
      +                        )
      +                        response = await self._message_handler(event)
      +                        _text, _eph_ttl = self._unwrap_ephemeral(response)
      +                        if _text:
      +                            _r = await self._send_with_retry(
      +                                chat_id=event.source.chat_id,
      +                                content=_text,
      +                                reply_to=_reply_anchor_for_event(event),
      +                                metadata=_thread_meta,
      +                            )
      +                            if _eph_ttl > 0 and _r.success and _r.message_id:
      +                                self._schedule_ephemeral_delete(
      +                                    chat_id=event.source.chat_id,
      +                                    message_id=_r.message_id,
      +                                    ttl_seconds=_eph_ttl,
      +                                )
      +                    except Exception as e:
      +                        logger.error(
      +                            "[%s] Clarify text-intercept dispatch failed: %s",
      +                            self.name, e, exc_info=True,
      +                        )
      +                    return
      +
                   if self._busy_session_handler is not None:
                       try:
                           if await self._busy_session_handler(event, session_key):
    • gateway/platforms/telegram.py
      @@ -427,6 +427,9 @@ def __init__(self, config: PlatformConfig):
               # Slash-confirm button state: confirm_id → session_key (for /reload-mcp
               # and any other slash-confirm prompts; see GatewayRunner._request_slash_confirm).
               self._slash_confirm_state: Dict[str, str] = {}
      +        # Clarify button state: clarify_id → session_key (for the clarify tool's
      +        # multiple-choice prompts; see GatewayRunner clarify_callback wiring).
      +        self._clarify_state: Dict[str, str] = {}
               # Notification mode for message sends.
               # "important" — only final responses, approvals, and slash confirmations
               #               trigger notifications; tool progress, streaming, status
      @@ -2215,6 +2218,80 @@ async def send_slash_confirm(
                   logger.warning("[%s] send_slash_confirm failed: %s", self.name, e)
                   return SendResult(success=False, error=str(e))
       
      +    async def send_clarify(
      +        self,
      +        chat_id: str,
      +        question: str,
      +        choices: Optional[list],
      +        clarify_id: str,
      +        session_key: str,
      +        metadata: Optional[Dict[str, Any]] = None,
      +    ) -> SendResult:
      +        """Render a clarify prompt with one inline button per choice.
      +
      +        Multi-choice mode (``choices`` non-empty): renders one button per
      +        option plus a final "✏️ Other (type answer)" button.  Picking the
      +        "Other" button flips the entry into text-capture mode so the next
      +        message becomes the response.
      +
      +        Open-ended mode (``choices`` empty): renders the question as plain
      +        text — no buttons.  The next message in the session is captured by
      +        the gateway's text-intercept and resolves the clarify.
      +        """
      +        if not self._bot:
      +            return SendResult(success=False, error="Not connected")
      +
      +        try:
      +            text = f"❓ {_html.escape(question)}"
      +            thread_id = self._metadata_thread_id(metadata)
      +
      +            kwargs: Dict[str, Any] = {
      +                "chat_id": int(chat_id),
      +                "text": text,
      +                "parse_mode": ParseMode.HTML,
      +                **self._link_preview_kwargs(),
      +            }
      +
      +            if choices:
      +                # Telegram caps callback_data at 64 bytes; keep "cl:<id>:<idx>"
      +                # short.  Button label is also capped (~64 chars in practice).
      +                rows = []
      +                for idx, choice in enumerate(choices):
      +                    label = str(choice)
      +                    if len(label) > 60:
      +                        label = label[:57] + "..."
      +                    rows.append([
      +                        InlineKeyboardButton(
      +                            f"{idx + 1}. {label}",
      +                            callback_data=f"cl:{clarify_id}:{idx}",
      +                        )
      +                    ])
      +                rows.append([
      +                    InlineKeyboardButton(
      +                        "✏️ Other (type answer)",
      +                        callback_data=f"cl:{clarify_id}:other",
      +                    )
      +                ])
      +                kwargs["reply_markup"] = InlineKeyboardMarkup(rows)
      +
      +            reply_to_id = self._reply_to_message_id_for_send(None, metadata)
      +            kwargs["reply_to_message_id"] = reply_to_id
      +            kwargs.update(
      +                self._thread_kwargs_for_send(
      +                    chat_id,
      +                    thread_id,
      +                    metadata,
      +                    reply_to_message_id=reply_to_id,
      +                )
      +            )
      +
      +            msg = await self._send_message_with_thread_fallback(**kwargs)
      +            self._clarify_state[clarify_id] = session_key
      +            return SendResult(success=True, message_id=str(msg.message_id))
      +        except Exception as e:
      +            logger.warning("[%s] send_clarify failed: %s", self.name, e)
      +            return SendResult(success=False, error=str(e))
      +
           async def send_model_picker(
               self,
               chat_id: str,
      @@ -2700,6 +2777,111 @@ async def _handle_callback_query(
                           logger.error("[%s] slash-confirm callback failed: %s", self.name, exc, exc_info=True)
                   return
       
      +        # --- Clarify callbacks (cl:clarify_id:idx | cl:clarify_id:other) ---
      +        if data.startswith("cl:"):
      +            parts = data.split(":", 2)
      +            if len(parts) == 3:
      +                clarify_id = parts[1]
      +                choice_token = [redacted:secret-assignment]
      +
      +                caller_id = str(getattr(query.from_user, "id", ""))
      +                if not self._is_callback_user_authorized(
      +                    caller_id,
      +                    chat_id=query_chat_id,
      +                    chat_type=str(query_chat_type) if query_chat_type is not None else None,
      +                    thread_id=str(query_thread_id) if query_thread_id is not None else None,
      +                    user_name=query_user_name,
      +                ):
      +                    await query.answer(text="⛔ You are not authorized to answer this prompt.")
      +                    return
      +
      +                session_key = self._clarify_state.get(clarify_id)
      +                if not session_key:
      +                    await query.answer(text="This prompt has already been resolved.")
      +                    return
      +
      +                user_display = getattr(query.from_user, "first_name", "User")
      +
      +                if choice_token == "other":
      +                    # Flip into text-capture mode and tell the user to type
      +                    # their answer.  The gateway's text-intercept will pick
      +                    # up the next message in this session and resolve the
      +                    # clarify.  Do NOT pop _clarify_state yet — we still
      +                    # need it if the user is slow to respond and the entry
      +                    # is cleared by something else.
      +                    try:
      +                        from tools.clarify_gateway import mark_awaiting_text
      +                        mark_awaiting_text(clarify_id)
      +                    except Exception as exc:
      +                        logger.warning("[%s] mark_awaiting_text failed: %s", self.name, exc)
      +
      +                    await query.answer(text="✏️ Type your answer in the chat.")
      +                    try:
      +                        await query.edit_message_text(
      +                            text=f"❓ {query.message.text or ''}\n\n<i>Awaiting typed response from {_html.escape(user_display)}…</i>",
      +                            parse_mode=ParseMode.HTML,
      +                            reply_markup=None,
      +                        )
      +                    except Exception:
      +                        pass
      +                    return
      +
      +                # Numeric choice → resolve immediately with the chosen text
      +                try:
      +                    idx = int(choice_token)
      +                except (ValueError, TypeError):
      +                    await query.answer(text="Invalid choice.")
      +                    return
      +
      +                # Look up the choice text from the entry registered in the
      +                # clarify primitive.  Fall back to the index if the entry
      +                # has been cleaned up (race with timeout / session reset).
      +                resolved_text: Optional[str] = None
      +                try:
      +                    from tools.clarify_gateway import _entries as _clarify_entries  # type: ignore
      +                    entry = _clarify_entries.get(clarify_id)
      +                    if entry and entry.choices and 0 <= idx < len(entry.choices):
      +                        resolved_text = entry.choices[idx]
      +                except Exception:
      +                    resolved_text = None
      +
      +                if resolved_text is None:
      +                    # Race: entry vanished. Echo the index as a number so
      +                    # the agent at least sees an intentional response
      +                    # rather than nothing.
      +                    resolved_text = f"choice {idx + 1}"
      +
      +                # Pop state and resolve
      +                self._clarify_state.pop(clarify_id, None)
      +                try:
      +                    from tools.clarify_gateway import resolve_gateway_clarify
      +                    resolved = resolve_gateway_clarify(clarify_id, resolved_text)
      +                except Exception as exc:
      +                    logger.error("[%s] resolve_gateway_clarify failed: %s", self.name, exc)
      +                    resolved = False
      +
      +                await query.answer(text=f"✓ {resolved_text[:60]}")
      +                try:
      +                    await query.edit_message_text(
      +                        text=f"❓ {_html.escape(query.message.text or '')}\n\n<b>{_html.escape(user_display)}:</b> {_html.escape(resolved_text)}",
      +                        parse_mode=ParseMode.HTML,
      +                        reply_markup=None,
      +                    )
      +                except Exception:
      +                    pass
      +
      +                if resolved:
      +                    logger.info(
      +                        "Telegram clarify button resolved (id=%s, choice=%r, user=%s)",
      +                        clarify_id, resolved_text, user_display,
      +                    )
      +                else:
      +                    logger.warning(
      +                        "Telegram clarify button: resolve_gateway_clarify returned False (id=%s)",
      +                        clarify_id,
      +                    )
      +            return
      +
               # --- Update prompt callbacks ---
               if not data.startswith("update_prompt:"):
                   return
    • gateway/run.py
      @@ -5828,6 +5828,37 @@ 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.
      +        try:
      +            from tools import clarify_gateway as _clarify_mod
      +            _pending_clarify = _clarify_mod.get_pending_for_session(_quick_key)
      +        except Exception:
      +            _pending_clarify = None
      +        if _pending_clarify 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,
      +                )
      +                if _resolved:
      +                    logger.info(
      +                        "Gateway intercepted clarify text response (session=%s, id=%s)",
      +                        _quick_key, _pending_clarify.clarify_id,
      +                    )
      +                    # Acknowledge with empty string so adapters that emit
      +                    # the agent's response don't double-post.  The agent
      +                    # itself will produce the next user-facing message.
      +                    return ""
      +
               # Intercept messages that are responses to a pending /reload-mcp
               # (or future) slash-confirm prompt.  Recognized confirm replies are
               # /approve, /always, /cancel (plus short aliases).  Anything else
      @@ -14957,6 +14988,76 @@ def _bg_review_send(message: str) -> None:
                           if _pdc is not None:
                               _pdc[session_key] = _release_bg_review_messages
       
      +            # ------------------------------------------------------------------
      +            # Clarify callback: present a clarify prompt and block on a response.
      +            #
      +            # Runs on the agent's worker thread (see clarify_tool's synchronous
      +            # callback contract).  Bridges sync→async by scheduling the
      +            # adapter's send_clarify on the gateway event loop, then blocks on
      +            # the clarify primitive's threading.Event with a configurable
      +            # timeout.  Returns the user's response string, or a sentinel
      +            # explaining that no response arrived (so the agent can adapt
      +            # rather than hang forever).
      +            # ------------------------------------------------------------------
      +            def _clarify_callback_sync(question: str, choices) -> str:
      +                from tools import clarify_gateway as _clarify_mod
      +                import uuid as _uuid
      +
      +                if not _status_adapter:
      +                    return ""
      +
      +                clarify_id = _uuid.uuid4().hex[:10]
      +                _clarify_mod.register(
      +                    clarify_id=clarify_id,
      +                    session_key=session_key or "",
      +                    question=question,
      +                    choices=list(choices) if choices else None,
      +                )
      +
      +                # Pause typing — like approval, we don't want a "thinking..."
      +                # status to obscure the prompt or block the user from typing
      +                # an "Other" response on platforms that disable input while
      +                # typing is active (Slack Assistant API).
      +                try:
      +                    _status_adapter.pause_typing_for_chat(_status_chat_id)
      +                except Exception:
      +                    pass
      +
      +                send_ok = False
      +                try:
      +                    fut = asyncio.run_coroutine_threadsafe(
      +                        _status_adapter.send_clarify(
      +                            chat_id=_status_chat_id,
      +                            question=question,
      +                            choices=list(choices) if choices else None,
      +                            clarify_id=clarify_id,
      +                            session_key=session_key or "",
      +                            metadata=_status_thread_metadata,
      +                        ),
      +                        _loop_for_step,
      +                    )
      +                    result = fut.result(timeout=15)
      +                    send_ok = bool(getattr(result, "success", False))
      +                except Exception as exc:
      +                    logger.warning("Clarify send failed: %s", exc)
      +                    send_ok = False
      +
      +                if not send_ok:
      +                    # Couldn't deliver the prompt — clean up and return
      +                    # sentinel so the agent can fall back to a sensible
      +                    # default rather than hanging.
      +                    _clarify_mod.clear_session(session_key or "")
      +                    return "[clarify prompt could not be delivered]"
      +
      +                timeout = _clarify_mod.get_clarify_timeout()
      +                response = _clarify_mod.wait_for_response(clarify_id, timeout=float(timeout))
      +                if response is None or response == "":
      +                    # Timeout or session-boundary cancellation
      +                    return f"[user did not respond within {int(timeout / 60)}m]"
      +                return response
      +
      +            agent.clarify_callback = _clarify_callback_sync
      +
                   # Store agent reference for interrupt support
                   agent_holder[0] = agent
                   # Capture the full tool definitions for transcript logging
      @@ -15228,6 +15329,14 @@ def _approval_notify_sync(approval_data: dict) -> None:
                       result = agent.run_conversation(_run_message, conversation_history=agent_history, task_id=session_id)
                   finally:
                       unregister_gateway_notify(_approval_session_key)
      +                # Cancel any pending clarify entries so blocked agent
      +                # threads don't hang past the end of the run (interrupt,
      +                # completion, gateway shutdown).  Idempotent.
      +                try:
      +                    from tools.clarify_gateway import clear_session as _clear_clarify_session
      +                    _clear_clarify_session(_approval_session_key)
      +                except Exception:
      +                    pass
                       reset_current_session_key(_approval_session_token)
                   result_holder[0] = result
       
    • hermes_cli/config.py
      @@ -477,6 +477,12 @@ def _ensure_hermes_home_managed(home: Path):
               # threshold before escalating to a full timeout.  The warning fires
               # once per run and does not interrupt the agent.  0 = disable warning.
               "gateway_timeout_warning": 900,
      +        # Maximum time (seconds) the gateway will block an agent waiting for
      +        # a clarify-tool response from the user.  Hit this and the agent
      +        # unblocks with "[user did not respond within Xm]" so it can adapt
      +        # rather than pinning the running-agent guard forever.  CLI clarify
      +        # blocks indefinitely (input() is synchronous) and ignores this.
      +        "clarify_timeout": 600,
               # Periodic "still working" notification interval (seconds).
               # Sends a status message every N seconds so the user knows the
               # agent hasn't died during long tasks.  0 = disable notifications.
    • tests/gateway/test_telegram_clarify_buttons.py
      @@ -0,0 +1,451 @@
      +"""Tests for Telegram inline keyboard clarify buttons.
      +
      +Mirrors test_telegram_approval_buttons.py for the new ``send_clarify`` and
      +``cl:`` callback dispatch added in feat/clarify-gateway-buttons.
      +"""
      +
      +import asyncio
      +import os
      +import sys
      +from pathlib import Path
      +from types import SimpleNamespace
      +from unittest.mock import AsyncMock, MagicMock, patch
      +
      +import pytest
      +
      +# ---------------------------------------------------------------------------
      +# Ensure the repo root is importable
      +# ---------------------------------------------------------------------------
      +_repo = str(Path(__file__).resolve().parents[2])
      +if _repo not in sys.path:
      +    sys.path.insert(0, _repo)
      +
      +
      +# ---------------------------------------------------------------------------
      +# Minimal Telegram mock so TelegramAdapter can be imported (mirrors
      +# test_telegram_approval_buttons.py)
      +# ---------------------------------------------------------------------------
      +def _ensure_telegram_mock():
      +    if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"):
      +        return
      +
      +    mod = MagicMock()
      +    mod.ext.ContextTypes.DEFAULT_TYPE = type(None)
      +    mod.constants.ParseMode.MARKDOWN = "Markdown"
      +    mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2"
      +    mod.constants.ParseMode.HTML = "HTML"
      +    mod.constants.ChatType.PRIVATE = "private"
      +    mod.constants.ChatType.GROUP = "group"
      +    mod.constants.ChatType.SUPERGROUP = "supergroup"
      +    mod.constants.ChatType.CHANNEL = "channel"
      +    mod.error.NetworkError = type("NetworkError", (OSError,), {})
      +    mod.error.TimedOut = type("TimedOut", (OSError,), {})
      +    mod.error.BadRequest = type("BadRequest", (Exception,), {})
      +
      +    for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"):
      +        sys.modules.setdefault(name, mod)
      +    sys.modules.setdefault("telegram.error", mod.error)
      +
      +
      +_ensure_telegram_mock()
      +
      +from gateway.platforms.telegram import TelegramAdapter
      +from gateway.config import Platform, PlatformConfig
      +
      +
      +def _make_adapter(extra=None):
      +    config = PlatformConfig(enabled=True, token="[redacted:secret-assignment]", extra=extra or {})
      +    adapter = TelegramAdapter(config)
      +    adapter._bot = AsyncMock()
      +    adapter._app = MagicMock()
      +    return adapter
      +
      +
      +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()
      +
      +
      +# ===========================================================================
      +# send_clarify — render
      +# ===========================================================================
      +
      +class TestTelegramSendClarify:
      +    """Verify the rendered prompt has buttons or none, and stores state."""
      +
      +    def setup_method(self):
      +        _clear_clarify_state()
      +
      +    @pytest.mark.asyncio
      +    async def test_multi_choice_renders_buttons_and_other(self):
      +        adapter = _make_adapter()
      +        mock_msg = MagicMock()
      +        mock_msg.message_id = 100
      +        adapter._bot.send_message = AsyncMock(return_value=mock_msg)
      +
      +        result = await adapter.send_clarify(
      +            chat_id="12345",
      +            question="Which option?",
      +            choices=["alpha", "beta", "gamma"],
      +            clarify_id="cid1",
      +            session_key="sk1",
      +        )
      +
      +        assert result.success is True
      +        assert result.message_id == "100"
      +
      +        kwargs = adapter._bot.send_message.call_args[1]
      +        assert kwargs["chat_id"] == 12345
      +        assert "Which option?" in kwargs["text"]
      +        # InlineKeyboardMarkup with N+1 buttons (3 choices + Other)
      +        markup = kwargs["reply_markup"]
      +        assert markup is not None
      +        # Mocked InlineKeyboardMarkup — just verify it was constructed
      +        # with rows.  We check state instead of poking the mock structure.
      +        assert "cid1" in adapter._clarify_state
      +        assert adapter._clarify_state["cid1"] == "sk1"
      +
      +    @pytest.mark.asyncio
      +    async def test_open_ended_no_keyboard(self):
      +        adapter = _make_adapter()
      +        mock_msg = MagicMock()
      +        mock_msg.message_id = 101
      +        adapter._bot.send_message = AsyncMock(return_value=mock_msg)
      +
      +        result = await adapter.send_clarify(
      +            chat_id="12345",
      +            question="What is your name?",
      +            choices=None,
      +            clarify_id="cid2",
      +            session_key="sk2",
      +        )
      +
      +        assert result.success is True
      +        kwargs = adapter._bot.send_message.call_args[1]
      +        # No reply_markup means no buttons — open-ended path
      +        assert "reply_markup" not in kwargs
      +        assert "What is your name?" in kwargs["text"]
      +        assert adapter._clarify_state["cid2"] == "sk2"
      +
      +    @pytest.mark.asyncio
      +    async def test_not_connected(self):
      +        adapter = _make_adapter()
      +        adapter._bot = None
      +        result = await adapter.send_clarify(
      +            chat_id="12345",
      +            question="?",
      +            choices=["a"],
      +            clarify_id="cid3",
      +            session_key="sk3",
      +        )
      +        assert result.success is False
      +
      +    @pytest.mark.asyncio
      +    async def test_truncates_long_choice_label(self):
      +        adapter = _make_adapter()
      +        mock_msg = MagicMock()
      +        mock_msg.message_id = 102
      +        adapter._bot.send_message = AsyncMock(return_value=mock_msg)
      +
      +        long_choice = "x" * 200  # > 60 char cap
      +        result = await adapter.send_clarify(
      +            chat_id="12345",
      +            question="?",
      +            choices=[long_choice],
      +            clarify_id="cid4",
      +            session_key="sk4",
      +        )
      +        assert result.success is True
      +        # The truncation logic replaces with "..." past 57 chars; we don't
      +        # inspect the mock's button labels directly (auto-MagicMock), but
      +        # we can verify the call didn't raise on absurdly long input.
      +
      +    @pytest.mark.asyncio
      +    async def test_html_escapes_question(self):
      +        adapter = _make_adapter()
      +        mock_msg = MagicMock()
      +        mock_msg.message_id = 103
      +        adapter._bot.send_message = AsyncMock(return_value=mock_msg)
      +
      +        await adapter.send_clarify(
      +            chat_id="12345",
      +            question="<script>alert(1)</script>",
      +            choices=["x"],
      +            clarify_id="cid5",
      +            session_key="sk5",
      +        )
      +        kwargs = adapter._bot.send_message.call_args[1]
      +        # Must NOT contain raw <script> — html.escape should have neutralized
      +        assert "<script>" not in kwargs["text"]
      +        assert "&lt;script&gt;" in kwargs["text"]
      +
      +
      +# ===========================================================================
      +# Callback dispatch — _handle_callback_query routing for cl:* prefixes
      +# ===========================================================================
      +
      +class TestTelegramClarifyCallback:
      +    """Verify clicking a button resolves the clarify primitive."""
      +
      +    def setup_method(self):
      +        _clear_clarify_state()
      +
      +    @pytest.mark.asyncio
      +    async def test_numeric_choice_resolves_with_choice_text(self):
      +        from tools import clarify_gateway as cm
      +
      +        adapter = _make_adapter()
      +        # Pre-register a clarify entry so the callback can look up the choice text
      +        cm.register("cidA", "sk-cb", "Pick", ["red", "green", "blue"])
      +        adapter._clarify_state["cidA"] = "sk-cb"
      +
      +        query = AsyncMock()
      +        query.data = "cl:cidA:1"  # green
      +        query.message = MagicMock()
      +        query.message.chat_id = 12345
      +        query.message.text = "Pick"
      +        query.from_user = MagicMock()
      +        query.from_user.id = "777"
      +        query.from_user.first_name = "Tester"
      +        query.answer = AsyncMock()
      +        query.edit_message_text = AsyncMock()
      +
      +        update = MagicMock()
      +        update.callback_query = query
      +        context = MagicMock()
      +
      +        with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "*"}, clear=False):
      +            await adapter._handle_callback_query(update, context)
      +
      +        # State popped
      +        assert "cidA" not in adapter._clarify_state
      +        # Wait shouldn't be needed — resolve_gateway_clarify is sync.
      +        # The entry's response should be set.
      +        # We test by reading the entry's response directly.
      +        with cm._lock:
      +            entry = cm._entries.get("cidA")
      +        # Entry might be popped by wait_for_response, but here we never
      +        # called wait — so it's still in _entries with response set.
      +        assert entry is not None
      +        assert entry.response == "green"
      +        assert entry.event.is_set()
      +        query.answer.assert_called_once()
      +        query.edit_message_text.assert_called_once()
      +
      +    @pytest.mark.asyncio
      +    async def test_other_button_flips_to_text_mode(self):
      +        from tools import clarify_gateway as cm
      +
      +        adapter = _make_adapter()
      +        cm.register("cidB", "sk-cb-other", "Pick", ["x", "y"])
      +        adapter._clarify_state["cidB"] = "sk-cb-other"
      +
      +        query = AsyncMock()
      +        query.data = "cl:cidB:other"
      +        query.message = MagicMock()
      +        query.message.chat_id = 12345
      +        query.message.text = "Pick"
      +        query.from_user = MagicMock()
      +        query.from_user.id = "777"
      +        query.from_user.first_name = "Tester"
      +        query.answer = AsyncMock()
      +        query.edit_message_text = AsyncMock()
      +
      +        update = MagicMock()
      +        update.callback_query = query
      +        context = MagicMock()
      +
      +        with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "*"}, clear=False):
      +            await adapter._handle_callback_query(update, context)
      +
      +        # Entry should now be in text-capture mode
      +        pending = cm.get_pending_for_session("sk-cb-other")
      +        assert pending is not None
      +        assert pending.clarify_id == "cidB"
      +        assert pending.awaiting_text is True
      +        # State NOT popped — the user still needs to type their answer
      +        assert "cidB" in adapter._clarify_state
      +        # Entry NOT yet resolved
      +        with cm._lock:
      +            entry = cm._entries.get("cidB")
      +        assert entry is not None
      +        assert not entry.event.is_set()
      +
      +    @pytest.mark.asyncio
      +    async def test_already_resolved(self):
      +        adapter = _make_adapter()
      +        # No state for cidGone
      +
      +        query = AsyncMock()
      +        query.data = "cl:cidGone:0"
      +        query.message = MagicMock()
      +        query.message.chat_id = 12345
      +        query.from_user = MagicMock()
      +        query.from_user.id = "777"
      +        query.from_user.first_name = "Tester"
      +        query.answer = AsyncMock()
      +
      +        update = MagicMock()
      +        update.callback_query = query
      +        context = MagicMock()
      +
      +        with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "*"}, clear=False):
      +            await adapter._handle_callback_query(update, context)
      +
      +        query.answer.assert_called_once()
      +        # Should NOT resolve anything
      +        assert "already" in query.answer.call_args[1]["text"].lower()
      +
      +    @pytest.mark.asyncio
      +    async def test_unauthorized_user_rejected(self):
      +        from tools import clarify_gateway as cm
      +
      +        adapter = _make_adapter()
      +        cm.register("cidC", "sk-auth", "Pick", ["a", "b"])
      +        adapter._clarify_state["cidC"] = "sk-auth"
      +
      +        # Hook up a runner that says NOT authorized
      +        class _DenyRunner:
      +            async def _handle_message(self, event):
      +                return None
      +            def _is_user_authorized(self, source):
      +                return False
      +
      +        adapter._message_handler = _DenyRunner()._handle_message
      +
      +        query = AsyncMock()
      +        query.data = "cl:cidC:0"
      +        query.message = MagicMock()
      +        query.message.chat_id = 12345
      +        query.message.chat.type = "private"
      +        query.message.text = "Pick"
      +        query.from_user = MagicMock()
      +        query.from_user.id = "999"
      +        query.from_user.first_name = "Mallory"
      +        query.answer = AsyncMock()
      +        query.edit_message_text = AsyncMock()
      +
      +        update = MagicMock()
      +        update.callback_query = query
      +        context = MagicMock()
      +
      +        await adapter._handle_callback_query(update, context)
      +
      +        # Must not resolve, must answer with not-authorized message
      +        with cm._lock:
      +            entry = cm._entries.get("cidC")
      +        assert entry is not None
      +        assert not entry.event.is_set()
      +        query.answer.assert_called_once()
      +        assert "not authorized" in query.answer.call_args[1]["text"].lower()
      +        # State preserved
      +        assert adapter._clarify_state["cidC"] == "sk-auth"
      +
      +    @pytest.mark.asyncio
      +    async def test_invalid_choice_token(self):
      +        from tools import clarify_gateway as cm
      +
      +        adapter = _make_adapter()
      +        cm.register("cidD", "sk-inv", "Q?", ["a"])
      +        adapter._clarify_state["cidD"] = "sk-inv"
      +
      +        query = AsyncMock()
      +        query.data = "cl:cidD:not-a-number"
      +        query.message = MagicMock()
      +        query.message.chat_id = 12345
      +        query.message.text = "Q?"
      +        query.from_user = MagicMock()
      +        query.from_user.id = "777"
      +        query.from_user.first_name = "Tester"
      +        query.answer = AsyncMock()
      +
      +        update = MagicMock()
      +        update.callback_query = query
      +        context = MagicMock()
      +
      +        with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "*"}, clear=False):
      +            await adapter._handle_callback_query(update, context)
      +
      +        with cm._lock:
      +            entry = cm._entries.get("cidD")
      +        assert entry is not None
      +        assert not entry.event.is_set()
      +        query.answer.assert_called_once()
      +        assert "invalid" in query.answer.call_args[1]["text"].lower()
      +
      +
      +# ===========================================================================
      +# Base adapter fallback render — text numbered list
      +# ===========================================================================
      +
      +class TestBaseAdapterClarifyFallback:
      +    """Adapters without button overrides should render numbered text."""
      +
      +    @pytest.mark.asyncio
      +    async def test_numbered_text_fallback(self):
      +        from gateway.platforms.base import BasePlatformAdapter, SendResult
      +
      +        # Subclass just enough to instantiate
      +        class _Stub(BasePlatformAdapter):
      +            name = "stub"
      +
      +            def __init__(self):
      +                # Skip base __init__ — we're not exercising it
      +                self.sent: list = []
      +
      +            async def connect(self): pass
      +            async def disconnect(self): pass
      +            async def send(self, chat_id, content, **kw):
      +                self.sent.append({"chat_id": chat_id, "content": content})
      +                return SendResult(success=True, message_id="1")
      +            async def edit(self, *a, **k): return SendResult(success=False)
      +            async def get_history(self, *a, **k): return []
      +            async def get_chat_info(self, *a, **k): return {}
      +
      +        adapter = _Stub()
      +
      +        result = await adapter.send_clarify(
      +            chat_id="c",
      +            question="Pick a fruit",
      +            choices=["apple", "banana"],
      +            clarify_id="x",
      +            session_key="s",
      +        )
      +        assert result.success is True
      +        assert len(adapter.sent) == 1
      +        text = adapter.sent[0]["content"]
      +        assert "Pick a fruit" in text
      +        assert "1." in text and "apple" in text
      +        assert "2." in text and "banana" in text
      +
      +    @pytest.mark.asyncio
      +    async def test_open_ended_fallback_renders_question_only(self):
      +        from gateway.platforms.base import BasePlatformAdapter, SendResult
      +
      +        class _Stub(BasePlatformAdapter):
      +            name = "stub"
      +            def __init__(self):
      +                self.sent: list = []
      +            async def connect(self): pass
      +            async def disconnect(self): pass
      +            async def send(self, chat_id, content, **kw):
      +                self.sent.append(content)
      +                return SendResult(success=True, message_id="1")
      +            async def edit(self, *a, **k): return SendResult(success=False)
      +            async def get_history(self, *a, **k): return []
      +            async def get_chat_info(self, *a, **k): return {}
      +
      +        adapter = _Stub()
      +        await adapter.send_clarify(
      +            chat_id="c",
      +            question="Free form?",
      +            choices=None,
      +            clarify_id="x",
      +            session_key="s",
      +        )
      +        assert "Free form?" in adapter.sent[0]
      +        # No numbered list — choices were empty
      +        assert "1." not in adapter.sent[0]
    • tests/tools/test_clarify_gateway.py
      @@ -0,0 +1,207 @@
      +"""Tests for the gateway-side clarify primitive (tools/clarify_gateway.py).
      +
      +The clarify tool needs to ask the user a question and block the agent
      +thread until they respond.  These tests cover the module-level state
      +machine: register, wait, resolve via button, resolve via text-fallback,
      +"Other"-button text-capture flip, timeout, session boundary cleanup.
      +"""
      +
      +from __future__ import annotations
      +
      +import threading
      +import time
      +from concurrent.futures import ThreadPoolExecutor
      +
      +import pytest
      +
      +
      +def _clear_clarify_state():
      +    """Reset module-level state between tests."""
      +    from tools import clarify_gateway as cm
      +    with cm._lock:
      +        cm._entries.clear()
      +        cm._session_index.clear()
      +        cm._notify_cbs.clear()
      +
      +
      +class TestClarifyPrimitive:
      +    """Core register/wait/resolve mechanics."""
      +
      +    def setup_method(self):
      +        _clear_clarify_state()
      +
      +    def test_button_choice_resolves_wait(self):
      +        """resolve_gateway_clarify unblocks wait_for_response with the chosen string."""
      +        from tools import clarify_gateway as cm
      +
      +        cm.register("id1", "sk1", "Pick one", ["A", "B", "C"])
      +
      +        def resolver():
      +            time.sleep(0.05)
      +            cm.resolve_gateway_clarify("id1", "B")
      +
      +        threading.Thread(target=resolver).start()
      +        result = cm.wait_for_response("id1", timeout=2.0)
      +        assert result == "B"
      +
      +    def test_open_ended_auto_awaits_text(self):
      +        """Clarify with no choices is in text-capture mode immediately."""
      +        from tools import clarify_gateway as cm
      +
      +        entry = cm.register("id2", "sk2", "Free form?", None)
      +        assert entry.awaiting_text is True
      +
      +        # get_pending_for_session returns the entry so the gateway
      +        # text-intercept can find it.
      +        pending = cm.get_pending_for_session("sk2")
      +        assert pending is not None
      +        assert pending.clarify_id == "id2"
      +
      +    def test_button_choice_does_not_auto_await(self):
      +        """Multi-choice clarify should NOT be in text-capture mode initially."""
      +        from tools import clarify_gateway as cm
      +
      +        entry = cm.register("id3", "sk3", "Pick", ["X", "Y"])
      +        assert entry.awaiting_text is False
      +        assert cm.get_pending_for_session("sk3") is None
      +
      +    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
      +
      +        cm.register("id4", "sk4", "Pick", ["X", "Y"])
      +        assert cm.get_pending_for_session("sk4") is None
      +
      +        flipped = cm.mark_awaiting_text("id4")
      +        assert flipped is True
      +
      +        pending = cm.get_pending_for_session("sk4")
      +        assert pending is not None
      +        assert pending.clarify_id == "id4"
      +
      +    def test_mark_awaiting_text_unknown_id(self):
      +        """mark_awaiting_text on a non-existent id returns False."""
      +        from tools import clarify_gateway as cm
      +
      +        assert cm.mark_awaiting_text("nope") is False
      +
      +    def test_timeout_returns_none(self):
      +        """wait_for_response returns None when no resolve fires within the timeout."""
      +        from tools import clarify_gateway as cm
      +
      +        cm.register("id5", "sk5", "Q?", ["A"])
      +        result = cm.wait_for_response("id5", timeout=0.2)
      +        assert result is None
      +
      +    def test_resolve_unknown_id_returns_false(self):
      +        """resolve_gateway_clarify is idempotent on unknown ids."""
      +        from tools import clarify_gateway as cm
      +
      +        assert cm.resolve_gateway_clarify("nope", "anything") is False
      +
      +    def test_resolve_after_wait_completes_is_noop(self):
      +        """A late resolve on a finished entry doesn't blow up."""
      +        from tools import clarify_gateway as cm
      +
      +        cm.register("id6", "sk6", "Q?", ["A"])
      +        # Time out, entry gets cleaned up
      +        cm.wait_for_response("id6", timeout=0.1)
      +        # Late button click — should not raise
      +        result = cm.resolve_gateway_clarify("id6", "A")
      +        assert result is False
      +
      +    def test_clear_session_cancels_pending_entries(self):
      +        """clear_session unblocks blocked threads with empty response."""
      +        from tools import clarify_gateway as cm
      +
      +        cm.register("id7", "sk7", "Q?", ["A"])
      +
      +        def waiter():
      +            return cm.wait_for_response("id7", timeout=10.0)
      +
      +        with ThreadPoolExecutor(1) as pool:
      +            fut = pool.submit(waiter)
      +            time.sleep(0.05)
      +            cancelled = cm.clear_session("sk7")
      +            assert cancelled == 1
      +            result = fut.result(timeout=2.0)
      +            # clear_session sets response="" then the wait returns it
      +            assert result == ""
      +
      +    def test_has_pending(self):
      +        from tools import clarify_gateway as cm
      +
      +        cm.register("id8", "sk8", "Q?", ["A"])
      +        assert cm.has_pending("sk8") is True
      +        assert cm.has_pending("nonexistent") is False
      +
      +    def test_notify_register_unregister_clears_pending(self):
      +        """unregister_notify cancels any pending clarify so threads unwind."""
      +        from tools import clarify_gateway as cm
      +
      +        cm.register("id9", "sk9", "Q?", ["A"])
      +
      +        def waiter():
      +            return cm.wait_for_response("id9", timeout=10.0)
      +
      +        with ThreadPoolExecutor(1) as pool:
      +            fut = pool.submit(waiter)
      +            time.sleep(0.05)
      +
      +            cm.register_notify("sk9", lambda entry: None)
      +            cm.unregister_notify("sk9")
      +
      +            # unregister_notify calls clear_session; thread unwinds
      +            result = fut.result(timeout=2.0)
      +            assert result == ""
      +
      +    def test_session_index_isolation(self):
      +        """Entries from different sessions don't leak across get_pending lookups."""
      +        from tools import clarify_gateway as cm
      +
      +        cm.register("idA", "alpha", "Q?", None)  # auto-await text
      +        cm.register("idB", "beta", "Q?", None)   # auto-await text
      +
      +        a = cm.get_pending_for_session("alpha")
      +        b = cm.get_pending_for_session("beta")
      +        assert a is not None and a.clarify_id == "idA"
      +        assert b is not None and b.clarify_id == "idB"
      +
      +    def test_clarify_timeout_config_default(self):
      +        """get_clarify_timeout returns 600 by default."""
      +        from tools import clarify_gateway as cm
      +
      +        timeout = cm.get_clarify_timeout()
      +        # Default 600s OR whatever is in the user's loaded config.
      +        # Floor check: must be a positive int, not crashed.
      +        assert isinstance(timeout, int)
      +        assert timeout > 0
      +
      +
      +class TestGatewayTextIntercept:
      +    """The gateway's _handle_message intercepts text replies to pending clarifies."""
      +
      +    def setup_method(self):
      +        _clear_clarify_state()
      +
      +    def test_get_pending_for_session_returns_oldest_text_awaiting(self):
      +        """When two clarifies are pending, get_pending_for_session returns the
      +        first that is awaiting_text (the older one if both)."""
      +        from tools import clarify_gateway as cm
      +
      +        # Older multi-choice (not awaiting text)
      +        cm.register("first", "sk", "Q1?", ["A"])
      +        # Newer open-ended (awaiting text)
      +        cm.register("second", "sk", "Q2?", None)
      +
      +        pending = cm.get_pending_for_session("sk")
      +        # The newer one is awaiting text; the older isn't.
      +        assert pending is not None
      +        assert pending.clarify_id == "second"
      +
      +        # Now flip the first to text mode too.  Both are awaiting text,
      +        # FIFO returns the older one.
      +        cm.mark_awaiting_text("first")
      +        pending2 = cm.get_pending_for_session("sk")
      +        assert pending2 is not None
      +        assert pending2.clarify_id == "first"
    • tools/clarify_gateway.py
      @@ -0,0 +1,278 @@
      +"""Gateway-side clarify primitive (blocking event-based queue).
      +
      +The ``clarify`` tool needs to ask the user a question and block the agent
      +thread until they respond.  In CLI mode this is trivial — ``input()`` is
      +synchronous.  In gateway mode the agent runs on a worker thread while the
      +event loop handles the user's reply, so we need a thread-safe primitive
      +that:
      +
      +  * stores a pending clarify request (with a generated ``clarify_id``),
      +  * blocks the agent thread on an ``Event``,
      +  * resolves the wait when the gateway's button-callback or text-intercept
      +    fires ``resolve_gateway_clarify(clarify_id, response)``,
      +  * supports timeouts so a user who never responds does NOT hang the agent
      +    thread forever (which would also pin the gateway's running-agent guard).
      +
      +State is module-level (same shape as ``tools.approval``) so platform
      +adapters can call ``resolve_gateway_clarify`` without holding a back-
      +reference to the ``GatewayRunner`` instance.
      +
      +Two delivery paths from the adapter:
      +
      +  1. **Button UI** — adapters override ``send_clarify`` to render inline
      +     buttons (e.g. Telegram ``InlineKeyboardMarkup``).  The button
      +     callback resolves with the chosen string.  A final "Other (type
      +     answer)" button enters text-capture mode for free-form responses.
      +
      +  2. **Text fallback** — adapters without rich UI render a numbered list.
      +     The user replies with a number ("2") or with free text; the gateway's
      +     ``_handle_message`` intercepts the reply and resolves directly.
      +"""
      +
      +from __future__ import annotations
      +
      +import logging
      +import threading
      +import time
      +from dataclasses import dataclass, field
      +from typing import Callable, Dict, List, Optional
      +
      +logger = logging.getLogger(__name__)
      +
      +
      +# =========================================================================
      +# Module-level state
      +# =========================================================================
      +
      +@dataclass
      +class _ClarifyEntry:
      +    """One pending clarify request inside a gateway session."""
      +    clarify_id: str
      +    session_key: str
      +    question: str
      +    choices: Optional[List[str]]
      +    event: threading.Event = field(default_factory=threading.Event)
      +    response: Optional[str] = None
      +    awaiting_text: bool = False  # set when user picked "Other" or clarify is open-ended
      +
      +    def signature(self) -> Dict[str, object]:
      +        return {
      +            "clarify_id": self.clarify_id,
      +            "session_key": self.session_key,
      +            "question": self.question,
      +            "choices": list(self.choices) if self.choices else None,
      +        }
      +
      +
      +_lock = threading.RLock()
      +# clarify_id → _ClarifyEntry  (primary lookup for button callbacks)
      +_entries: Dict[str, _ClarifyEntry] = {}
      +# session_key → list[clarify_id]  (FIFO; for text-fallback intercept and session cleanup)
      +_session_index: Dict[str, List[str]] = {}
      +
      +
      +# =========================================================================
      +# Public API — agent-thread side
      +# =========================================================================
      +
      +def register(
      +    clarify_id: str,
      +    session_key: str,
      +    question: str,
      +    choices: Optional[List[str]],
      +) -> _ClarifyEntry:
      +    """Register a pending clarify request and return the entry.
      +
      +    The caller (gateway clarify_callback) will then send the prompt to the
      +    user and block on ``wait_for_response(clarify_id, timeout)``.
      +    """
      +    entry = _ClarifyEntry(
      +        clarify_id=clarify_id,
      +        session_key=session_key,
      +        question=question,
      +        choices=list(choices) if choices else None,
      +        # Open-ended (no choices) → next message IS the response, no buttons needed.
      +        awaiting_text=not bool(choices),
      +    )
      +    with _lock:
      +        _entries[clarify_id] = entry
      +        _session_index.setdefault(session_key, []).append(clarify_id)
      +    return entry
      +
      +
      +def wait_for_response(clarify_id: str, timeout: float) -> Optional[str]:
      +    """Block on the entry's event until resolved or timeout fires.
      +
      +    Polls in 1-second slices so the agent's inactivity heartbeat keeps
      +    firing — without this, ``Event.wait(timeout=600)`` blocks the thread
      +    for 10 minutes with zero activity touches and the gateway's inactivity
      +    watchdog kills the agent while the user is still typing.
      +
      +    Returns the resolved response string, or ``None`` on timeout.
      +    """
      +    with _lock:
      +        entry = _entries.get(clarify_id)
      +    if entry is None:
      +        return None
      +
      +    try:
      +        from tools.environments.base import touch_activity_if_due
      +    except Exception:  # pragma: no cover - optional
      +        touch_activity_if_due = None
      +
      +    deadline = time.monotonic() + max(timeout, 0.0)
      +    activity_state = {"last_touch": time.monotonic(), "start": time.monotonic()}
      +    while True:
      +        remaining = deadline - time.monotonic()
      +        if remaining <= 0:
      +            break
      +        if entry.event.wait(timeout=min(1.0, remaining)):
      +            break
      +        if touch_activity_if_due is not None:
      +            touch_activity_if_due(activity_state, "waiting for user clarify response")
      +
      +    with _lock:
      +        # Remove from indices regardless of resolution outcome.
      +        _entries.pop(clarify_id, None)
      +        ids = _session_index.get(entry.session_key)
      +        if ids and clarify_id in ids:
      +            ids.remove(clarify_id)
      +            if not ids:
      +                _session_index.pop(entry.session_key, None)
      +
      +    return entry.response
      +
      +
      +# =========================================================================
      +# Public API — gateway / adapter side
      +# =========================================================================
      +
      +def resolve_gateway_clarify(clarify_id: str, response: str) -> bool:
      +    """Unblock the agent thread waiting on ``clarify_id``.
      +
      +    Returns True if an entry was found and resolved, False otherwise
      +    (already resolved, expired, or never existed).
      +    """
      +    with _lock:
      +        entry = _entries.get(clarify_id)
      +        if entry is None:
      +            return False
      +    entry.response = str(response) if response is not None else ""
      +    entry.event.set()
      +    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.
      +    """
      +    with _lock:
      +        ids = _session_index.get(session_key) or []
      +        for cid in ids:
      +            entry = _entries.get(cid)
      +            if entry is None:
      +                continue
      +            if entry.awaiting_text:
      +                return entry
      +        return None
      +
      +
      +def mark_awaiting_text(clarify_id: str) -> bool:
      +    """Flip an entry into text-capture mode (user picked the 'Other' button).
      +
      +    Returns True if the entry exists and was flipped, False otherwise.
      +    """
      +    with _lock:
      +        entry = _entries.get(clarify_id)
      +        if entry is None:
      +            return False
      +        entry.awaiting_text = True
      +        return True
      +
      +
      +def has_pending(session_key: str) -> bool:
      +    """Return True when this session has at least one pending clarify entry."""
      +    with _lock:
      +        ids = _session_index.get(session_key) or []
      +        return any(_entries.get(cid) is not None for cid in ids)
      +
      +
      +def clear_session(session_key: str) -> int:
      +    """Resolve and drop every pending clarify for a session.
      +
      +    Used by session-boundary cleanup (e.g. ``/new``, gateway shutdown,
      +    cached-agent eviction) so blocked agent threads don't hang past the
      +    end of their session.  Returns the number of entries cancelled.
      +    """
      +    with _lock:
      +        ids = list(_session_index.pop(session_key, []) or [])
      +        entries = [_entries.pop(cid, None) for cid in ids]
      +    cancelled = 0
      +    for entry in entries:
      +        if entry is None:
      +            continue
      +        # Empty string sentinel — agent code can distinguish from a real
      +        # response by inspecting the wait_for_response return value
      +        # alongside its own timeout deadline.  Most callers just treat any
      +        # falsy result as "user did not respond".
      +        entry.response = ""
      +        entry.event.set()
      +        cancelled += 1
      +    return cancelled
      +
      +
      +# =========================================================================
      +# Config
      +# =========================================================================
      +
      +def get_clarify_timeout() -> int:
      +    """Read the clarify response timeout (seconds) from config.
      +
      +    Defaults to 600 (10 minutes) — long enough for the user to type a
      +    thoughtful response, short enough that an abandoned prompt eventually
      +    unblocks the agent thread instead of pinning the running-agent guard
      +    forever.
      +
      +    Reads ``agent.clarify_timeout`` from config.yaml.
      +    """
      +    try:
      +        from hermes_cli.config import load_config
      +        cfg = load_config() or {}
      +        agent_cfg = cfg.get("agent", {}) or {}
      +        return int(agent_cfg.get("clarify_timeout", 600))
      +    except Exception:
      +        return 600
      +
      +
      +# =========================================================================
      +# Per-session notify hook (gateway → adapter bridge)
      +# =========================================================================
      +# Mirrors tools.approval's _gateway_notify_cbs: the gateway registers a
      +# per-session callback that sends the clarify prompt to the user.  The
      +# callback bridges sync→async (runs on the agent thread; schedules the
      +# adapter ``send_clarify`` call on the event loop).
      +
      +_notify_cbs: Dict[str, Callable[[_ClarifyEntry], None]] = {}
      +
      +
      +def register_notify(session_key: str, cb: Callable[[_ClarifyEntry], None]) -> None:
      +    """Register a per-session notify callback used by ``clarify_callback``."""
      +    with _lock:
      +        _notify_cbs[session_key] = cb
      +
      +
      +def unregister_notify(session_key: str) -> None:
      +    """Drop the per-session notify callback and cancel any pending clarify entries."""
      +    with _lock:
      +        _notify_cbs.pop(session_key, None)
      +    # Cancel any pending entries so blocked threads unwind when the run
      +    # ends (interrupt, completion, gateway shutdown).
      +    clear_session(session_key)
      +
      +
      +def get_notify(session_key: str) -> Optional[Callable[[_ClarifyEntry], None]]:
      +    with _lock:
      +        return _notify_cbs.get(session_key)
    • website/docs/user-guide/messaging/telegram.md
      @@ -907,6 +907,19 @@ When the agent tries to run a potentially dangerous command, it asks you for app
       
       Reply "yes"/"y" to approve or "no"/"n" to deny.
       
      +## Interactive Prompts (clarify)
      +
      +When the agent calls the `clarify` tool — to ask which approach you prefer, get post-task feedback, or check before a non-trivial decision — Telegram renders the question with **inline keyboard buttons**:
      +
      +> ❓ Which framework should I use for the dashboard?
      +>
      +> [1. Next.js] [2. Remix] [3. Astro]
      +> [✏️ Other (type answer)]
      +
      +Tap a button to answer, or tap **Other** to type a free-form response (the next message you send becomes the answer). Open-ended `clarify` calls (no preset choices) skip the buttons and just capture your next message.
      +
      +Configure the response timeout via `agent.clarify_timeout` in `~/.hermes/config.yaml` (default `600` seconds). If you don't respond within the timeout, the agent unblocks with a sentinel message and adapts rather than hanging.
      +
       ## Security
       
       :::warning

3 PRs closed without merging

From the close comments: done elsewhere 1 · superseded by another PR 1 · other 1

  • #12576 fix(gateway): wire clarify callback for messaging sessions · done elsewhere → 29d7c24, d7f655f, #24199, #12573
  • #21517 fix(gateway,telegram): wire clarify_callback through gateway agent and Telegram adapter · other
  • #23740 feat(gateway): bridge clarify tool to messaging platforms · superseded by another PR → #29719, #24199
Closed issues · 5
  • Closed#12573Bug: gateway sessions expose clarify tool but never wire clarify_callback
  • Closed#18301feat: wire up clarify tool for Telegram via inline keyboard buttons
  • Closed#21028[BUG] Clarify tool returns 'not available in this execution context' in gateway/Telegram mode
  • Closed#21032Clarify tool breaks in gateway/Telegram mode — missing callback wiring
  • Closed#24191feat(gateway): wire clarify tool end-to-end with inline keyboard buttons on Telegram
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.26: 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
#24199
Closed issues
5
Attempts
3 PRs closed without merging
Fix link checked
12 May 2026

For your agent

The same facts, typed.