Skip to content

fix(clarify): unblock the agent when a prose reply is rejected against a multi-choice clarify - #84642

Open
andyst-dev wants to merge 1 commit into
NousResearch:mainfrom
andyst-dev:fix/clarify-prose-deadlock
Open

fix(clarify): unblock the agent when a prose reply is rejected against a multi-choice clarify#84642
andyst-dev wants to merge 1 commit into
NousResearch:mainfrom
andyst-dev:fix/clarify-prose-deadlock

Conversation

@andyst-dev

Copy link
Copy Markdown
Contributor

Fixes #84608

Problem

When an agent calls clarify with a native multi-choice prompt and the user replies with prose (e.g. asking what the options mean), the reply is rejected as a clarify response and demoted to a queued steer — but the clarify wait is never resolved or cancelled. Since the agent thread is already blocked inside wait_for_response(), the "continue as a normal turn" path can't run, so the session parks for the full agent.clarify_timeout (default 3600 seconds) before the queued reply is delivered. Asking a clarifying question about a clarify prompt is precisely what deadlocks the session for up to an hour.

Root cause

resolve_text_response_for_session() (tools/clarify_gateway.py) returned False on a rejected prose reply (matching _coerce_text_response's docstring: "so the message continues as a normal turn") but never resolved or cancelled the clarify entry. The blocked agent thread had no way to unblock.

Fix

When a prose reply is rejected against a non-awaiting_text clarify (native multi-choice), resolve the clarify with the empty sentinel — the same sentinel clear_session() uses — so the blocked agent returns promptly. resolve_text_response_for_session still returns False, so the caller dispatches the message as a normal turn.

Open-ended clarifies and the "Other" text-capture path are unchanged (they already accept arbitrary text and never hit this branch).

Verification

  • New test: a prose reply to a native multi-choice clarify returns False from resolve_text_response_for_session, resolves the wait with the empty sentinel (blocked thread unblocks), and consumes the entry from the pending index.
  • tests/tools/test_clarify_gateway.py: 24/24 pass.
  • ruff check clean on both edited files.

@alt-glitch alt-glitch added type/bug Something isn't working comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Aug 12, 2026
@ayushnangia

Copy link
Copy Markdown
Contributor

Verified on your branch: scripts/run_tests.sh tests/tools/test_clarify_gateway.py tests/tools/test_clarify_tool.py44 passed, 0 failed. The fix point is the right one — the rejection path deciding what happens next, with the blocked wait in scope — rather than patching _coerce_text_response or wait_for_response alone, which (as @AlexxRussell's composition analysis on #84608 showed) are each correct in isolation. Also ran a merge check against #84560 (interrupt-path member of the same family, touches the same file): the two branches auto-merge cleanly, no conflict — they compose, in whichever order maintainers land them. With this, every non-desktop member of the clarify/approval family from the #84047 triage has a fix in flight: #25506/#84560 (interrupt), #83346 (session-key routing), #84642 (rejection-demotion). Nice turnaround — #84608 was 85 minutes old when you fixed it.

@Enough1122

Copy link
Copy Markdown
Contributor

AI code review — automated review for reference, author can ignore or act on any point.

fix(clarify): unblock the blocked agent when a prose reply is rejected against a multi-choice clarify

  1. tools/clarify_gateway.py (resolve_text_response_for_session): the return-value contract is now overloaded — False historically meant "rejected, nothing consumed, message continues as a normal turn", but it can now also mean "rejected and the clarify entry was resolved/cancelled with the empty sentinel". Confirm every caller treats the consumed-entry case correctly (the entry is gone from the pending index when it next checks), and document the new contract on the function.
  2. tools/clarify_gateway.py: the if not entry.awaiting_text guard means a prose reply to a text-prompt clarify (awaiting_text=True) still leaves the agent parked for the full clarify_timeout. If the parked-agent symptom from [Bug]: Prose reply to a multi-choice clarify is demoted to a steer, so the clarify blocks for its full timeout #84608 applies there too, consider unblocking that path as well; at minimum a test locking the branch behavior would prevent regressions.
  3. Minor: reusing the empty-string sentinel (same as clear_session) for cancellation makes a genuine empty user response indistinguishable from a cancel. A dedicated named sentinel constant would make the intent explicit.

…d against a multi-choice clarify

When a user answers a native multi-choice clarify with prose (e.g. asking what
the options mean), _coerce_text_response returns None and
resolve_text_response_for_session returned False — but it never resolved or
cancelled the clarify wait. The agent thread was already blocked inside
wait_for_response(), so the 'continue as a normal turn' path could not run and
the wait parked for the full clarify_timeout (default 3600s), deadlocking the
session for up to an hour (NousResearch#84608).

Now, when a prose reply is rejected against a non-awaiting-text clarify, the
clarify is resolved with the empty sentinel (same as clear_session) so the
blocked agent returns promptly, while resolve_text_response_for_session still
returns False so the caller dispatches the message as a normal turn. Open-ended
and 'Other'-text clarifies are unchanged.
@andyst-dev
andyst-dev force-pushed the fix/clarify-prose-deadlock branch from 15adf13 to ee0cd02 Compare August 17, 2026 07:45
@andyst-dev andyst-dev changed the title fix(clarify): unblock the blocked agent when a prose reply is rejected against a multi-choice clarify fix(clarify): break deadlock when a prose response is rejected (rebase: no-op touch to force mergeable recalc) Aug 17, 2026
@andyst-dev andyst-dev changed the title fix(clarify): break deadlock when a prose response is rejected (rebase: no-op touch to force mergeable recalc) fix(clarify): unblock the agent when a prose reply is rejected against a multi-choice clarify Aug 17, 2026
@keeltrace

Copy link
Copy Markdown

AI-assisted state-machine review; reviewed before posting.

One ordering invariant may be worth pinning around the chosen “resolve sentinel + return False” behavior.

A rejected prose message now has two effects: it resolves the currently blocked clarify, waking the existing agent turn, while resolve_text_response_for_session() returns False so that same prose continues through normal message dispatch.

Could there be a race where the prose begins normal processing before the clarify-owning turn has observed the cancellation and relinquished its execution slot?

The safety property I’d want is:

A rejected clarify reply may unblock the owning turn and subsequently become normal input, but it must not create a second concurrently executing turn for that session.

A deterministic regression could hold the original clarify-owning turn immediately after the sentinel wakes it, allow the prose-dispatch path to proceed, and verify that the prose remains queued/serialized until the first turn has exited or yielded session ownership. Then assert the prose is delivered exactly once afterward.

It may also be useful to pin that the awakened turn cannot interpret the cancellation sentinel as permission to continue consequential work before the user's actual prose is processed.

This seems distinct from the existing return-contract/sentinel observations: those establish what the two branches mean; this would establish their causal ordering when both are triggered by one message.

@andyst-dev

Copy link
Copy Markdown
Contributor Author

Good observation — this is a real invariant worth pinning, and let me be precise about where it holds in this PR versus where it lives upstream.

What this PR deterministically guarantees (inside clarify_gateway.py): the sentinel resolve happens before resolve_text_response_for_session() returns False, and it's a single-threaded, side-effect-ordered sequence inside the gateway (resolve entry → return). So by the time the caller sees False and re-dispatches the prose, the clarify-owning wait has already been released. There is no interleaving inside this module that lets the re-dispatch observe an unreleased wait. The existing test (test_clarify_gateway.py) pins the sentinel-with-empty-string + return-False contract.

What is NOT pinned here: the turn-serialization property you're pointing at — that the re-dispatched prose doesn't enter a second concurrently-executing turn for the same session before the awakened turn has yielded session ownership. That ordering lives in the agent execution loop / message dispatch above this module, which this utility doesn't import or control. clarify_gateway has no reference to the executor, so a unit test in this file cannot honestly assert turn concurrency without pulling in the whole agent runtime.

So I'd split it:

  • The part this PR can and should own: "sentinel resolve strictly precedes the rejected-prose return" — already implemented and tested here.
  • The genuine race window (awakened turn still holding the slot while prose reaches dispatch) is a property of the dispatch layer and is better covered by an integration-level test against the real agent loop, or pinned where that loop serializes per-session turns.

What would you prefer — (a) I open a follow-up issue capturing the turn-serialization invariant (with your suggested deterministic test sketch) so it's scoped to the executor and doesn't get lost, or (b) you'd like me to attempt an integration test in this PR against the run-agent loop (heavier setup, but covers it end-to-end)? I'm happy to add (a) now and do (b) if you think it belongs in this change.

@alt-glitch alt-glitch added the sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages label Aug 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists 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]: Prose reply to a multi-choice clarify is demoted to a steer, so the clarify blocks for its full timeout

5 participants