fix/issue 7956 assistant cancellation hang - #7991
Conversation
|
By the way, I've really been enjoying contributing to this project! My goal is to get more involved in open source to help maintain projects and learn from great engineering teams. Is there a process or pathway for becoming a member of the microsoft GitHub organization? I'd love to continue helping out where I can! |
ErenAta16
left a comment
There was a problem hiding this comment.
I filed #7956, so thanks for taking it on. The _assistant_agent.py change is right, but I think this needs splitting before it can be reviewed properly, and there's a gap in the fix itself.
The scope. 37 files changed, 36 of which are unrelated encoding="utf-8" additions across agbench, samples, studio and docs. The actual fix for #7956 is one hunk in _assistant_agent.py. That matters practically: 13 of those files also appear in #8003 ("add encoding='utf-8' to all text-mode open() calls"), so the two PRs will conflict, and whichever merges second has to be rebased. Splitting the UTF-8 work out and letting #8003 own it would leave this PR as a ~18-line diff that a maintainer can actually review against the issue.
The fix is correct as far as it goes. Moving the sentinel into a finally:
try:
results = await asyncio.gather(...)
return results
finally:
stream_queue.put_nowait(None)does close the hang at that layer. Previously put_nowait(None) sat after the await, so when gather raised, the consumer draining stream_queue never saw its terminator and blocked forever. That's the observable symptom I reported.
But it treats the symptom rather than the cause, and the cause is one layer down. From the issue:
StaticWorkbench.call_tool and StaticStreamWorkbench.call_tool_stream (_static_workbench.py, lines 115-124 and 197-224) wrap the cancellation-linked future in except Exception. Since Python 3.8 CancelledError derives from BaseException, so it isn't caught there, while every other tool failure is converted into an error ToolResult. Cancellation is the single failure mode that escapes call_tool instead of being reported through the normal result path.
With only this PR's change, cancelling a tool call now terminates the stream instead of hanging, which is a genuine improvement. But the CancelledError still propagates out of on_messages_stream rather than the run ending cleanly, and callers get no FunctionExecutionResult recording that the tool was cancelled, so the message history has a FunctionCall with no corresponding result. If the intent is the contract in BaseGroupChat.run_stream ("cancel() ... kill the task immediately"), the workbench should catch CancelledError explicitly and surface it as a cancelled ToolResult, with this finally as the belt-and-braces guarantee that the queue always terminates.
Also missing: a regression test. The hang is the kind of bug that returns quietly. A test that cancels the token mid-tool-call and asserts on_messages_stream terminates (rather than hanging) would pin it. Worth using asyncio.wait_for with a short timeout so a regression fails fast instead of hanging CI.
Suggested split:
- this PR,
_assistant_agent.pyfinallyonly, plus a cancellation test - the workbench
CancelledErrorhandling, either here or as a follow-up - the UTF-8 changes dropped in favour of #8003
Happy to review each piece as it goes up.
5c415b6 to
82c3377
Compare
Move stream_queue.put_nowait(None) into a finally block so the stream always terminates, even when asyncio.gather is cancelled. Previously, cancellation during tool execution left the stream_queue without its sentinel, causing the consumer to block forever. Fixes microsoft#7956
Adds a test that cancels a tool mid-execution and asserts the stream terminates within a 5-second timeout — regression coverage for microsoft#7956. Prior to the finally-block fix in _execute_tool_calls, the stream terminator was never enqueued on cancellation, causing on_messages_stream to hang forever. Co-Authored-By: Claude <noreply@anthropic.com>
|
Thanks for the detailed review @ErenAta16! I've addressed the feedback:
Let me know if there's anything else needed! 🙏 |
ErenAta16
left a comment
There was a problem hiding this comment.
The source change is right — the terminator in finally is what guarantees the consumer stops waiting, and it covers the exception path as well as cancellation.
The regression test does not fail on main though. The last block is:
try:
await asyncio.wait_for(consume_task, timeout=5.0)
except (asyncio.CancelledError, Exception):
pass # Both outcomes are acceptable — what matters is we didn't hang.asyncio.wait_for signals the timeout by raising asyncio.TimeoutError, and that is an Exception subclass, so the blanket except swallows the one outcome the test exists to catch:
issubclass(asyncio.TimeoutError, Exception): True
hanging task -> swallowed TimeoutError -> test would PASS
without the blanket except -> TimeoutError raised -> test would FAIL
So a stream that hangs for the full 5 seconds ends the test green. Letting TimeoutError through fixes it:
try:
await asyncio.wait_for(consume_task, timeout=5.0)
except asyncio.CancelledError:
pass # expected: cancellation propagates out of the consumerasyncio.TimeoutError then escapes and fails the test, while the cancellation you do expect is still tolerated. Worth confirming it fails with the try/finally reverted before landing, since that is the property the test is claiming.
Splitting out the workbench CancelledError change sounds right to me.
Fixes #7956.