Skip to content

fix/issue 7956 assistant cancellation hang - #7991

Open
wasim-builds wants to merge 3 commits into
microsoft:mainfrom
wasim-builds:fix/issue-7956-assistant-cancellation-hang
Open

fix/issue 7956 assistant cancellation hang#7991
wasim-builds wants to merge 3 commits into
microsoft:mainfrom
wasim-builds:fix/issue-7956-assistant-cancellation-hang

Conversation

@wasim-builds

Copy link
Copy Markdown

Fixes #7956.

@wasim-builds

Copy link
Copy Markdown
Author

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 ErenAta16 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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:

  1. this PR, _assistant_agent.py finally only, plus a cancellation test
  2. the workbench CancelledError handling, either here or as a follow-up
  3. the UTF-8 changes dropped in favour of #8003

Happy to review each piece as it goes up.

@wasim-builds
wasim-builds force-pushed the fix/issue-7956-assistant-cancellation-hang branch from 5c415b6 to 82c3377 Compare July 29, 2026 05:16
wasim-builds and others added 2 commits July 29, 2026 10:47
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>
@wasim-builds

Copy link
Copy Markdown
Author

Thanks for the detailed review @ErenAta16! I've addressed the feedback:

  1. Clean branch — The PR was rebuilt with only the _assistant_agent.py change (no UTF-8 encoding files).

  2. Regression test addedtest_tool_cancellation_terminates_stream cancels a token mid-tool-execution and asserts that on_messages_stream terminates within a 5-second timeout. This directly covers the hang scenario from Cancelling an in-flight tool call deadlocks AssistantAgent.on_messages_stream #7956.

  3. The workbench CancelledError handling — I agree that's a follow-worthy improvement but kept this PR focused per your split suggestion. Happy to do it as a separate PR.

Let me know if there's anything else needed! 🙏

@ErenAta16 ErenAta16 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 consumer

asyncio.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.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cancelling an in-flight tool call deadlocks AssistantAgent.on_messages_stream

2 participants