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
- 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
Open PRs1
- PR#29719feat(feishu): wire clarify tool with interactive card buttons
Fixes on record
-
#24199 feat(gateway): wire clarify tool with inline keyboard buttons on Telegram
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.mdShow 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 "<script>" 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.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 LabAt a glance
- Subsystem
- gateway
- Merged PRs
- #24199
- Closed issues
- 5
- Attempts
- 3 PRs closed without merging
- Fix link checked
- 12 May 2026