Skip to content

Clarify tool breaks in gateway/Telegram mode — missing callback wiring #21032

@virtt

Description

@virtt

Bug Description

After upgrading Hermes to current HEAD, the clarify tool always returns an error in Telegram gateway mode:

{"error": "Clarify tool is not available in this execution context."}

Inline keyboard buttons never appear. The tool works correctly in CLI and TUI modes — the bug is specific to gateway/Telegram.

Steps to Reproduce

  1. Start Hermes gateway with Telegram platform: hermes gateway start
  2. Send any message that causes the agent to call the clarify tool
  3. Observe: no inline keyboard appears in Telegram
  4. The agent receives {"error": "Clarify tool is not available in this execution context."} and cannot ask the user questions

Reproduces every time — the tool is completely non-functional in gateway mode.

Expected Behavior

The agent sends a Telegram message with inline keyboard buttons showing the choices. User taps a button → choice is returned to the agent.

Actual Behavior

Error returned immediately. No message sent to Telegram. No buttons.

Environment

  • Hermes Agent: v0.12.0 (2026.4.30), updated to HEAD (691 commits ahead of tag)
  • Platform: Telegram gateway (polling mode)
  • Python: 3.11.15
  • OS: Linux (systemd user service)
  • Gateway started fresh after upgrade

Evidence

Before upgrade (gateway started April 27) — clarify worked:

2026-05-07 07:01:37 gateway.run: clarify pending stored: 16ab104a... choices=[...]
2026-05-07 07:01:46 gateway.platforms.telegram: clarify callback received: cq:16ab104a...:0
2026-05-07 07:01:46 gateway.platforms.telegram: clarify resolved: 16ab104a... idx=0

After upgrade + restart — clarify never fires. No log entries for clarify at all. The tool returns the error above.

Root Cause

AIAgent requires clarify_callback to wire the tool to the platform. In CLI (cli.py) and TUI (tui_gateway/server.py) this callback is passed during agent construction. In the messaging gateway (gateway/run.py) it is never passed — self.clarify_callback remains None, which clarify_tool.py:57 treats as unavailable.

Additionally, the Telegram adapter (gateway/platforms/telegram.py) has no:

  • _clarify_state dict for storing prompt state
  • send_clarify_prompt() method
  • clarify: handler in _handle_callback_query

Impact

Every gateway user on Telegram (and likely other messaging platforms that do not implement their own clarify integration) cannot use the clarify tool. The agent silently fails to ask clarifying questions.

Fix

2 files changed, ~145 insertions. Full diff below.

gateway/run.py — callback + wiring

# After _status_callback_sync block, before run_sync():
def _clarify_callback(question: str, choices=None) -> str:
    if not _status_adapter or not _run_still_current():
        return ""
    import uuid, threading
    clarify_id = uuid.uuid4().hex
    event = threading.Event()
    _status_adapter._clarify_state[clarify_id] = {
        "event": event, "choice": None,
        "choices": choices or [], "question": question,
    }
    try:
        asyncio.run_coroutine_threadsafe(
            _status_adapter.send_clarify_prompt(
                chat_id=_status_chat_id,
                question=question, choices=choices or [],
                clarify_id=clarify_id,
                metadata=_status_thread_metadata,
            ), _loop_for_step,
        ).result(timeout=10)
    except Exception as _e:
        logger.error("clarify_callback send error: %s", _e)
        _status_adapter._clarify_state.pop(clarify_id, None)
        return ""
    logger.info("clarify pending stored: %s choices=%s", clarify_id, choices)
    event.wait(timeout=120)
    state = _status_adapter._clarify_state.pop(clarify_id, {"choice": ""})
    return state.get("choice", "")

# After agent.status_callback = _status_callback_sync:
agent.clarify_callback = _clarify_callback

gateway/platforms/telegram.py — adapter support

1. In __init__:

self._clarify_state: Dict[str, dict] = {}

2. New method send_clarify_prompt:

async def send_clarify_prompt(
    self, chat_id: str, question: str, choices: list,
    clarify_id: str, metadata=None,
) -> SendResult:
    if not self._bot:
        return SendResult(success=False, error="Not connected")
    try:
        buttons = []
        for i, choice in enumerate(choices):
            buttons.append([InlineKeyboardButton(
                choice, callback_data=f"clarify:{clarify_id}:{i}")])
        buttons.append([InlineKeyboardButton(
            "✗ Skip", callback_data=f"clarify:{clarify_id}:skip")])
        keyboard = InlineKeyboardMarkup(buttons)
        thread_id = metadata.get("thread_id") if metadata else None
        msg = await self._bot.send_message(
            chat_id=int(chat_id), text=question,
            reply_markup=keyboard,
            message_thread_id=int(thread_id) if thread_id else None,
            **self._link_preview_kwargs(),
        )
        return SendResult(success=True, message_id=str(msg.message_id))
    except Exception as e:
        logger.warning("[%s] send_clarify_prompt failed: %s", self.name, e)
        return SendResult(success=False, error=str(e))

3. In _handle_callback_query, before update-prompt handler:

# --- Clarify callbacks (clarify:id:idx) ---
if data.startswith("clarify:"):
    parts = data.split(":", 2)
    if len(parts) == 3:
        clarify_id = parts[1]
        choice_key = parts[2]
        state = self._clarify_state.get(clarify_id)
        if not state:
            await query.answer(text="This question is no longer active.")
            return
        if choice_key == "skip":
            user_choice = ""
        else:
            try:
                idx = int(choice_key)
            except ValueError:
                await query.answer(text="Invalid choice.")
                return
            choices = state.get("choices", [])
            if 0 <= idx < len(choices):
                user_choice = choices[idx]
            else:
                await query.answer(text="Invalid choice.")
                return
        state["choice"] = user_choice
        state["event"].set()
        user_display = getattr(query.from_user, "first_name", "User")
        label = f"Selected: {user_choice}" if user_choice else "Skipped"
        await query.answer(text=label)
        try:
            await query.edit_message_text(
                text=f"{state['question']}\n\n*{label}* by {user_display}",
                parse_mode=ParseMode.MARKDOWN, reply_markup=None,
            )
        except Exception:
            pass
        logger.info(
            "[%s] clarify resolved: %s idx=%s choice=%r",
            self.name, clarify_id, choice_key, user_choice,
        )
    return

Notes

  • Uses uuid.uuid4().hex for clarify IDs (no colons) — avoids the split(":", 2) pitfall documented in references/clarify-telegram-buttons.md
  • Follows the existing callback pattern from _status_callback_sync
  • Fix has been tested in production — clarify tool works correctly after applying this patch

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Medium — degraded but workaround existscomp/gatewayGateway runner, session dispatch, deliveryplatform/telegramTelegram bot adaptertype/bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions