clarify/secret prompt timeout and expiry handling

NousResearch/hermes-agent · cluster 32762 · dashboard

Data as of

Nothing open on GitHub. Fix merged in #54056. Check it covers your case.

The short answer

0open issues
0open PRs
9items grouped
Known?
Yes. 9 related items grouped here, 3 issues already closed.
Fix event
A fix was merged to main in PR #54056. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PRs #54056, #61885, #69773. That is separate from the fix event.
Still open
Nothing open on GitHub.

These issues and fixes address how clarify and secret prompts behave when they expire or receive late responses, including hard errors returned to clients, uncancellable overlays in the TUI, and lost resolver state across Telegram gateway restarts.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

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

  • #54056 fix(clarify): notify on expired Telegram tap + raise default clarify_timeout (#32762)

    merged to main

    files: hermes_cli/config.py, infographic/clarify-expiry-32762/infographic.png, plugins/platforms/telegram/adapter.py, tests/gateway/test_telegram_clarify_buttons.py, tests/tools/test_clarify_gateway.py, tools/clarify_gateway.py

    Show the diff
    • hermes_cli/config.py
      @@ -1012,7 +1012,13 @@ def _ensure_hermes_home_managed(home: Path):
               # 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,
      +        # Default 3600 (1h): real users step away (meetings, AFK) and the
      +        # old 600s default evicted the entry mid-think, so a later button
      +        # tap landed on a dead entry (#32762).  Tradeoff: a higher value
      +        # holds the gateway's running-agent guard longer for a genuinely
      +        # abandoned prompt — lower it if a single session must free up the
      +        # guard sooner.
      +        "clarify_timeout": 3600,
               # 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.
    • plugins/platforms/telegram/adapter.py
      @@ -4292,6 +4292,31 @@ def get_label(slug):
                   # Catch-all (e.g. page counter button "mx:noop")
                   await query.answer()
       
      +    async def _notify_clarify_expired(self, query, user_display: str) -> None:
      +        """Tell the user a clarify tap arrived too late to be delivered.
      +
      +        Fires when the clarify entry was evicted by ``clarify_timeout`` or the
      +        gateway restarted between asking and the tap. In both cases the agent
      +        thread is no longer waiting, so the tap would otherwise leave a
      +        misleading ✓ (or an "awaiting typed response" prompt) on a button the
      +        agent never receives.
      +        """
      +        try:
      +            await query.answer(text="⚠️ This prompt expired — please /retry.")
      +        except Exception:
      +            pass
      +        try:
      +            await query.edit_message_text(
      +                text=(
      +                    f"❓ {_html.escape(query.message.text or '')}\n\n"
      +                    "<i>⚠️ This question expired or the session reset — please /retry.</i>"
      +                ),
      +                parse_mode=ParseMode.HTML,
      +                reply_markup=None,
      +            )
      +        except Exception:
      +            pass
      +
           async def _handle_callback_query(
               self, update: "Update", context: "ContextTypes.DEFAULT_TYPE"
           ) -> None:
      @@ -4529,12 +4554,20 @@ async def _handle_callback_query(
                           # 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.
      +                    flipped = False
                           try:
                               from tools.clarify_gateway import mark_awaiting_text
      -                        mark_awaiting_text(clarify_id)
      +                        flipped = mark_awaiting_text(clarify_id)
                           except Exception as exc:
                               logger.warning("[%s] mark_awaiting_text failed: %s", self.name, exc)
       
      +                    if not flipped:
      +                        # Entry evicted (clarify_timeout) or gateway restarted
      +                        # between ask and tap — a typed answer would go nowhere.
      +                        self._clarify_state.pop(clarify_id, None)
      +                        await self._notify_clarify_expired(query, user_display)
      +                        return
      +
                           await query.answer(text="✏️ Type your answer in the chat.")
                           try:
                               await query.edit_message_text(
      @@ -4580,22 +4613,25 @@ async def _handle_callback_query(
                           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:
      +                    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
                           logger.info(
                               "Telegram clarify button resolved (id=%s, choice=%r, user=%s)",
                               clarify_id, resolved_text, user_display,
                           )
                       else:
      +                    # Entry evicted (clarify_timeout) or gateway restarted
      +                    # between ask and tap — surface this instead of leaving a
      +                    # misleading ✓ on a button the agent will never receive.
      +                    await self._notify_clarify_expired(query, user_display)
                           logger.warning(
                               "Telegram clarify button: resolve_gateway_clarify returned False (id=%s)",
                               clarify_id,
    • tests/gateway/test_telegram_clarify_buttons.py
      @@ -351,6 +351,71 @@ def _is_user_authorized(self, source):
               # State preserved
               assert adapter._clarify_state["cidC"] == "sk-auth"
       
      +    @pytest.mark.asyncio
      +    async def test_numeric_choice_expired_notifies_user(self):
      +        """Late tap after the entry was evicted (timeout) or the gateway
      +        restarted must surface an expiry notice, not a misleading ✓."""
      +        adapter = _make_adapter()
      +        # _clarify_state still maps the id (timeout eviction does not pop it),
      +        # but the clarify primitive entry is gone → resolve returns False.
      +        adapter._clarify_state["cidExpired"] = "sk-expired"
      +
      +        query = AsyncMock()
      +        query.data = "cl:cidExpired:0"
      +        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)
      +
      +        # User is told the prompt expired — not a misleading checkmark.
      +        answer_text = query.answer.call_args[1]["text"].lower()
      +        assert "expired" in answer_text
      +        edit_text = query.edit_message_text.call_args[1]["text"].lower()
      +        assert "expired" in edit_text or "session reset" in edit_text
      +        assert "/retry" in edit_text
      +
      +    @pytest.mark.asyncio
      +    async def test_other_button_expired_notifies_user(self):
      +        """Tapping 'Other' after the entry was evicted must tell the user the
      +        prompt expired instead of silently entering text-capture mode."""
      +        adapter = _make_adapter()
      +        # No clarify primitive entry → mark_awaiting_text returns False.
      +        adapter._clarify_state["cidOtherExpired"] = "sk-other-expired"
      +
      +        query = AsyncMock()
      +        query.data = "cl:cidOtherExpired: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)
      +
      +        answer_text = query.answer.call_args[1]["text"].lower()
      +        assert "expired" in answer_text
      +        # State popped so a subsequent typed message is not mis-captured.
      +        assert "cidOtherExpired" not in adapter._clarify_state
      +
           @pytest.mark.asyncio
           async def test_invalid_choice_token(self):
               from tools import clarify_gateway as cm
    • tests/tools/test_clarify_gateway.py
      @@ -167,11 +167,11 @@ def test_session_index_isolation(self):
               assert b is not None and b.clarify_id == "idB"
       
           def test_clarify_timeout_config_default(self):
      -        """get_clarify_timeout returns 600 by default."""
      +        """get_clarify_timeout returns a positive int (default 3600)."""
               from tools import clarify_gateway as cm
       
               timeout = cm.get_clarify_timeout()
      -        # Default 600s OR whatever is in the user's loaded config.
      +        # Default 3600s 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
    • tools/clarify_gateway.py
      @@ -231,10 +231,13 @@ def clear_session(session_key: str) -> int:
       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
      +    Defaults to 3600 (1 hour) — long enough that a user who steps away
      +    (meeting, AFK, slow to read) still finds a live entry when they tap
      +    the button, short enough that a genuinely abandoned prompt eventually
           unblocks the agent thread instead of pinning the running-agent guard
      -    forever.
      +    forever.  The old 600s default evicted the entry mid-think, so a late
      +    tap landed on a dead entry and the agent hung on ``running: clarify``
      +    (#32762).
       
           Reads ``agent.clarify_timeout`` from config.yaml.
           """
      @@ -242,9 +245,9 @@ def get_clarify_timeout() -> int:
               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))
      +        return int(agent_cfg.get("clarify_timeout", 3600))
           except Exception:
      -        return 600
      +        return 3600
       
       
       # =========================================================================
  • #61885 fix(tui): dismiss expired sensitive prompts

    merged to main

    files: tests/tui_gateway/test_protocol.py, tui_gateway/server.py, ui-tui/README.md, ui-tui/src/__tests__/createGatewayEventHandler.test.ts, ui-tui/src/__tests__/useInputHandlers.test.ts, ui-tui/src/app/createGatewayEventHandler.ts, ui-tui/src/app/useInputHandlers.ts, ui-tui/src/app/useMainApp.ts, ui-tui/src/gatewayTypes.ts, website/docs/developer-guide/programmatic-integration.md

  • #69773 fix(tui_gateway): tolerate late clarify + terminal.read replies after timeout

    merged to main

    files: tests/tui_gateway/test_protocol.py, tui_gateway/server.py

3 PRs closed without merging

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

  • #33850 fix(telegram): persist clarify-button resolver state across restarts · other → #32878, #33073
  • #56571 fix(tui): gracefully handle late clarify responses after timeout · superseded by another PR → #69773, #64886
  • #56596 fix(tui): consume expired clarify responses · other
Closed issues · 3
  • Closed#32762gateway: Telegram clarify-button resolver loses state on SIGTERM restart and 600s timeout
  • Closed#56558gateway: clarify.respond to an expired prompt returns hard 4009 'no pending answer request', leaking to client and wedging the chat
  • Closed#61748TUI secret.respond zombie overlay: 4009 'no pending value request' leaves secret prompt uncancellable
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.19: 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
dashboard
Merged PRs
#54056, #61885, #69773
Closed issues
3
Attempts
3 PRs closed without merging
Fix link checked
23 Jul 2026

For your agent

The same facts, typed.