Skip to content

fix(discord): release the agent when clarify buttons expire - #72742

Closed
hyuseinleshov wants to merge 2 commits into
NousResearch:mainfrom
hyuseinleshov:fix/discord-clarify-expiry-release
Closed

hyuseinleshov wants to merge 2 commits into
NousResearch:mainfrom
hyuseinleshov:fix/discord-clarify-expiry-release

Conversation

@hyuseinleshov

Copy link
Copy Markdown
Contributor

What does this PR do?

A native multi-choice clarify on Discord is rendered by ClarifyChoiceView, whose lifetime comes from approvals.discord_prompt_timeout (300 s by default). The agent thread that asked the question is parked in clarify_gateway.wait_for_response, governed by a completely separate deadline — agent.clarify_timeout (3600 s by default, unlimited when set to 0).

When the view timed out, on_timeout disabled the buttons and repainted the embed with "⏱ Prompt expired — no action taken" — and that was all. The clarify entry stayed armed, so the agent stayed blocked for the remaining ~55 minutes.

The prompt is genuinely unanswerable during that window:

  • the buttons are disabled, so there is no click path left;
  • _coerce_text_response rejects prose for an awaiting_text=False entry, so typing an answer does nothing (correct in itself — strict multi-choice coercion is deliberate);
  • the rejected text falls through to the busy path, where busy_input_mode: interrupt calls AIAgent.interrupt(). That flag is only observed at loop checkpoints, and the thread is asleep inside Event.wait. So the message is queued behind the very turn it was meant to unblock.

Net effect: the session is pinned, the UI claims the prompt is dead, and every follow-up message vanishes silently — no inbound message log line is ever emitted for it.

Evidence from a live gateway

Discord showed ⏳ Working — 54 min — iteration 7/150, clarify under an embed reading "Prompt expired — no action taken". Three user messages (including "Cancel the current task") were accepted by the adapter but never reached the agent:

13:32:42  agent.conversation_loop: API call #7 ... latency=11.9s   ← clarify raised here
13:48:14  [Discord] Flushing text batch ... (53 chars)             ← no `inbound message` line
14:14:52  [Discord] Flushing text batch ... (21 chars)             ← no `inbound message` line
14:20:06  [Discord] Flushing text batch ... (63 chars)             ← no `inbound message` line

kill -USR2 on the gateway confirmed where the turn was stuck:

tools/clarify_gateway.py:139 in wait_for_response      ← Event.wait
gateway/run.py:21911 in _clarify_callback_sync
tools/clarify_tool.py:102 in clarify_tool
agent/tool_executor.py:1360 in _execute

Related Issue

No existing issue covers the Discord view-expiry path — I searched open and closed issues and PRs for clarify timeout, clarify prompt expired, discord clarify button and prompt expired no action taken before writing this.

Related but distinct: #71946 (Telegram native-choice clarify deadlock). Same awaiting_text=False coercion boundary, different trigger — that one is the redirect/steer path during tool execution, this one is UI expiry racing an independent agent-side deadline. This PR does not fix #71946.

Related history: #45903 made the view timeout configurable, which is what allowed the two deadlines to drift apart in the first place.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)

Changes Made

plugins/platforms/discord/adapter.py

  • ClarifyChoiceView.on_timeout now flips a still-pending entry into text-capture mode via mark_awaiting_text(), so a typed reply answers the prompt once the buttons are gone. The footer says so — "⏱ Buttons expired — reply with a message to answer" — instead of claiming no action was taken.
  • Schedules _resolve_after_grace(), which resolves the entry with an empty response after approvals.discord_clarify_text_grace seconds (default 300, 0 = release immediately). An abandoned prompt can no longer pin a session, including under clarify_timeout: 0, where the old code hung forever.
  • A reply landing during the grace window wins; the release task re-checks that the entry is still pending and does nothing if it isn't.
  • When the entry is already gone (answered elsewhere, run interrupted, session cleared), the original "no action taken" footer is kept.
  • New _read_clarify_text_grace() reader (same shape as _read_discord_prompt_timeout, clamped to [0, 3600]) and _clarify_entry_pending() helper. Expiry tasks are held in _CLARIFY_EXPIRY_TASKS — asyncio keeps only a weak reference to a running task, so without a strong ref the GC can collect one mid-sleep and the agent never gets released.

tests/gateway/test_discord_clarify_buttons.py — new TestClarifyChoiceViewTimeout (6 tests): entry flipped to text-capture; prose answers the prompt after expiry (the regression); agent released when grace is disabled; an answer during the grace window is not overwritten; expiry with no entry stays a plain no-op; expiry with no stored message reference doesn't raise.

tests/gateway/test_discord_prompt_timeout_config.py — 11 tests for the new reader, mirroring the existing ones, plus a cross-default invariant: view timeout + grace must fit inside the default agent.clarify_timeout. That invariant is precisely what had drifted.

How to Test

Reproduce (before this PR)

  1. Set approvals.discord_prompt_timeout: 30 in config.yaml to make the window short.
  2. From Discord, ask the agent something that makes it call clarify with native choices.
  3. Wait 30 s for the buttons to grey out with "Prompt expired — no action taken".
  4. Type any prose (e.g. cancel that). Nothing happens — no reply, no inbound message line in ~/.hermes/logs/gateway.log, and the session stays busy until agent.clarify_timeout elapses.

After this PR

  • At step 3 the footer instead reads "Buttons expired — reply with a message to answer".
  • At step 4 the prose resolves the clarify and the turn continues.
  • If nobody replies, the agent is released discord_clarify_text_grace seconds later, with Discord clarify expired unanswered (id=…, grace=…s, ok=True) in the log.

Automated

scripts/run_tests.sh tests/gateway/test_discord_clarify_buttons.py tests/gateway/test_discord_prompt_timeout_config.py -q
# 38 passed

The 6 behavioural tests fail on main (assert entry.awaiting_text is TrueFalse; resolve_text_response_for_session(...) is TrueFalse) and pass with the fix.

Wider sweep — scripts/run_tests.sh tests/gateway/ tests/tools/ -q901 files, 20056 passed, 3 failed. All three failures reproduce with the three touched files reverted to origin/main content, so none of them come from this PR:

  • tests/gateway/test_agent_cache.py::TestExtractCacheBustingConfig::test_honcho_cache_busting_config_memoized_by_mtime
  • tests/tools/test_managed_browserbase_and_modal.py::test_browser_use_explicit_local_mode_stays_local_even_when_managed_gateway_is_ready
  • tests/tools/test_managed_browserbase_and_modal.py::test_browser_use_availability_skips_refresh_for_expired_cached_gateway_token

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run the suite — tests/gateway/ and tests/tools/ are green apart from the pre-existing test_agent_cache failure noted above
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: Ubuntu 24.04, Python 3.11.15, Discord adapter, live gateway

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — the new reader and both timeout interactions are documented in docstrings; there is no prose doc covering approvals.* today
  • N/A — cli-config.yaml.example has no approvals: section (its sibling discord_prompt_timeout isn't listed there either), so adding one key alone would be inconsistent; happy to add the whole block in a follow-up if you'd like it documented
  • N/A — no architecture or workflow change
  • I've considered cross-platform impact — asyncio only, no platform-specific calls; the touched path is adapter-level and identical on Windows, macOS and Linux

@alt-glitch alt-glitch added type/bug Something isn't working P3 Low — cosmetic, nice to have comp/plugins Plugin system and bundled plugins platform/discord Discord bot adapter sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Jul 27, 2026

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for tracing the separate Discord-view and agent-side deadlines; current main still has the reported expiry path (plugins/platforms/discord/adapter.py:9131-9145), while the gateway blocks in gateway/run.py:4717-4722.

Problems

  • plugins/platforms/discord/adapter.py:8987 treats a still-registered entry as unanswered. A typed reply sets entry.event, but wait_for_response removes the entry only after that wait returns (tools/clarify_gateway.py:143-157). Because resolve_gateway_clarify overwrites entry.response without checking event.is_set() (tools/clarify_gateway.py:164-176), the expiry task can replace a real reply with "" during that window.
  • The proposed regression test manually removes the entry before draining the task (tests/gateway/test_discord_clarify_buttons.py:729-735), so it does not exercise that production race.

Suggested changes

  • Make resolve_gateway_clarify single-winner under _lock (reject already-set entries), then rely on its return value in the expiry task.
  • Test an event-set entry that remains registered and verify the expiry task preserves its typed response. Document the new user-facing approvals.discord_clarify_text_grace setting alongside the Discord clarify timeout behavior.

Automated hermes-sweeper review.

try:
if grace > 0:
await asyncio.sleep(grace)
if not _clarify_entry_pending(self.clarify_id):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A present entry is not necessarily unanswered: resolve_gateway_clarify() sets its event before wait_for_response() removes it. A typed reply can therefore remain in _entries here and then be overwritten by the empty expiry resolution. Make resolution single-winner atomically in tools/clarify_gateway.py (reject an already-set event) and use that result rather than entry presence.

@teknium1 teknium1 added sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 30, 2026
A native multi-choice clarify renders a Discord button view whose timeout
(`approvals.discord_prompt_timeout`, 300s by default) is independent of the
agent-side wait (`agent.clarify_timeout`, 3600s by default, unlimited at 0).
On expiry the view only greyed out the embed and disabled the buttons — the
clarify entry stayed armed, so the agent thread stayed parked in
`clarify_gateway.wait_for_response` for the remaining ~55 minutes.

The prompt is unanswerable in that window. The buttons are dead, and
`_coerce_text_response` rejects prose for an `awaiting_text=False` entry —
correct per the strict-prose behavior, but it means every follow-up message
queues behind the very turn it was meant to unblock. Observed on a Discord
gateway sitting at "Working — 54 min — iteration 7/150, clarify" under an
embed reading "Prompt expired — no action taken"; three user messages, one of
them "Cancel the current task", produced no `inbound message` log line at all.

`on_timeout` now:

  * flips a still-pending entry into text-capture mode, so a typed reply
    answers the prompt once the buttons are gone, and says so in the footer
    instead of claiming that no action was taken;
  * schedules a release task that resolves the entry with an empty response
    after `approvals.discord_clarify_text_grace` seconds (300 by default, 0
    to release as soon as the view expires), so an abandoned prompt can never
    pin the session — including under `clarify_timeout: 0`;
  * keeps the old "no action taken" footer when the entry is already gone.

Related: #71946 (Telegram native-choice clarify deadlock — same
`awaiting_text=False` coercion boundary, different trigger).
Mirrors the discord_prompt_timeout reader tests: defaults, numeric strings,
malformed values, clamping, and a crashing read_raw_config. Adds one
cross-default invariant — view timeout plus grace must fit inside the
default agent.clarify_timeout, which is exactly what drifted apart and left
sessions pinned behind an expired prompt.
@hyuseinleshov
hyuseinleshov force-pushed the fix/discord-clarify-expiry-release branch from 60f9e5b to e16e0b2 Compare July 31, 2026 10:44
@hyuseinleshov

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (98105f31f4) — this was ~1383 commits behind and CONFLICTING, now MERGEABLE.

The only conflict was in tests/gateway/test_discord_prompt_timeout_config.py, and it was positional rather than semantic: main ends the file after test_default_matches_previous_hardcoded_value, and this branch appends the discord_clarify_text_grace block after it. Resolved by keeping both. No production code needed changes; plugins/platforms/discord/adapter.py replayed cleanly.

Verified all three imported symbols (_CLARIFY_TEXT_GRACE_DEFAULT, _CLARIFY_TEXT_GRACE_MAX, _read_clarify_text_grace) still resolve against the rebased adapter.

Tests, using per-file isolation as scripts/run_tests.sh mandates:

tests/gateway/test_discord_clarify_buttons.py         ✓
tests/gateway/test_discord_prompt_timeout_config.py   ✓
30 passed

Full tests/gateway/ on the rebased branch: 4443 passed, 23 skipped, 5 failed. Those 5 are pre-existing and unrelated — clean upstream/main with none of this branch's changes produces the identical 5 (4425 passed, 23 skipped, 5 failed), and all three files pass when run individually. They are cross-file leakage artifacts of invoking pytest tests/gateway/ in a single process, which is what the per-file runner exists to avoid, not a regression from this PR.

Happy to split the test commit out or adjust anything if that helps review.

@GottZ GottZ left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was generated by AI during triage.

Summary

Three PRs address or reference #71946: #71997 breaks the unmatched-prose redirect-to-steer deadlock, #75732 carries that fix forward with atomic first-writer resolution and retry-aware rejection classification, and #72742 addresses the distinct Discord button-expiry path that can leave the clarify waiter blocked.

Related pull requests

  • #71997 best fix — (+14/-3) — superseded deadlock fix: releases a rejected native-choice clarify before ordinary busy-message routing, preserving strict prose rejection, but its unguarded empty resolution can overwrite a button result. Despite the keep_open review and recorded best-fix verdict on #71997, #75732 preserves this core change while adding first-writer-wins resolution and distinguishing retryable invalid selections from free prose.
  • #72742 related — (+401/-11) — distinct Discord expiry salvage path: disables expired buttons, enables a configurable typed-answer grace period, and eventually releases the waiter, covering a separate cause from the unmatched-prose deadlock. The keep_open review remains blocking because the diff tests entry presence rather than atomic unresolved state, allowing expiry to overwrite an answer before the waiter removes the entry.
  • #75732 best fix — (+378/-37) — strongest current unmatched-prose fix: releases only free-prose rejects before redirect-to-steer routing, retains retryable out-of-range and malformed selection attempts, and makes clarify resolution first-writer-wins under the lock. This directly addresses the keep_open review's selection-classification concern in commit 09129e4f5 and adds regressions for both retry and button-then-prose races.

Duplicates

#71997 and #75732 substantially implement the same unmatched-prose deadlock fix; #75732 is the successor and subsumes #71997. #72742 overlaps in clarify release and race handling but targets the distinct Discord button-expiry path.

Suggested consolidation

Keep #75732 open with its salvage path as the consolidated implementation for the unmatched-prose deadlock, and close #71997 as a duplicate of #75732; this closure is justified despite #71997's recorded best-fix verdict and keep_open review because #75732 carries its core fix forward while correcting the documented button-result overwrite and invalid-selection retry regressions. Keep #72742 open with a salvage path for the distinct Discord expiry behavior, but require it to use the atomic first-writer-wins result from #75732 instead of entry presence and to add the event-set-but-still-registered race test requested by its keep_open review.

Cross-PR triage: Reviewed 3 pull requests and 1 issue in this complex. Each diff was read against this issue; Assessment working set: 44 kB of PR diffs, 29 kB of issue/PR text, 9 kB of discussion (12 comments), 5 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch.

@doryani-ai

Copy link
Copy Markdown
Contributor

We reproduced the split-timeout failure on Discord with a production gateway: agent.clarify_timeout was 259200s, while the native Discord view used its 300s default. The controls disabled after ~5 minutes, but the backend waiter remained live; an exact numeric reply (1) about 27 minutes later still resumed it. Natural prose sent earlier did not resolve the strict choice and the busy session provided no useful recovery path.

This PR is therefore directionally right, but the post-expiry 5-minute free-text grace still ends by abandoning the original waiter rather than giving a later-message recovery contract. A conflict-minimizing split that looks safe:

For the clarify path, the two regression cases that mattered in our reproduction were: invalid selection visibly re-presents the numbered choices; non-selection prose releases the parked waiter exactly once and continues as ordinary user text. A first-writer-wins guard is important because a late component tap can race the prose handoff.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have platform/discord Discord bot adapter sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Telegram native-choice clarify can deadlock when custom prose is redirected; /stop leaves clarify armed

5 participants