fix: stream tool progress updates while the tool is still running - #519
fix: stream tool progress updates while the tool is still running#519ShreyanshVaibhaw wants to merge 1 commit into
Conversation
_run_tool collected on_update partials into a list and _execute_tool_call yielded them only after the tool returned, so every ToolExecutionUpdateEvent arrived in one batch immediately before tool_execution_end. The events existed but carried no information at a time a consumer could act on them. The cause is structural: on_update is a synchronous callback while only the async generator awaiting the tool can emit events. Hand the partials over through an asyncio.Queue and race it against the tool task, so each partial is yielded as soon as it is reported, draining anything left once the tool finishes. Preserved: ordering, partials reported just before returning, the isolation boundary around tool exceptions, ignoring updates reported after completion, and cancellation propagation - the tool task is cancelled if the consumer stops early rather than left running. Fixes huggingface#382
Interaction with #526This change and #526 touch the same code path: the cancellation of a tool call. They do not conflict in the files. But they meet in the behavior. The old guarantee. Before this change, The change in this PR. The tool now runs in its own task. In the The effect with #526 merged. In the Esc path, the harness records and persists the "interrupted" result while the tool task continues in the background. The transcript content is the same. But the order guarantee is gone. For example, a bash tool kills its process after the interruption is recorded. If the TUI stops the event loop quickly, the tool cleanup can fail to run. Requested change. In the finally:
accepting = False
if not task.done():
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await taskAn Sequence note. When the second of the two PRs merges, run |
Summary
Fixes #382.
Tool progress partials reported through
on_updatewere buffered and emitted only after the tool returned, soToolExecutionUpdateEvents all arrived in one batch immediately beforetool_execution_end. The events existed but carried no information at a time a consumer could act on them.Reproduction
A tool that reports a partial, blocks, then reports a second partial before returning:
Even with a 200 ms window nothing surfaced mid-run. After the change:
Fix
As @rian-dolphin noted in the issue, the cause is structural:
on_updateis a synchronous callback, while the only thing that can emit events is the async generator currently awaiting the tool. Nothing bridged the two._run_toolis now an async generator that hands partials over through anasyncio.Queueand races the queue against the tool task, yielding each partial as soon as it is reported and draining anything left once the tool finishes. The final(result, is_error)pair is appended to a caller-provided list, since an async generator cannot return a value to its consumer.Behaviour preserved
Each of these was verified explicitly, since the change touches the tool isolation boundary and cancellation:
is_error=True, message preserved, earlier partials still deliveredThe last one is a small improvement on the previous behaviour: a tool left running after its consumer went away is now cancelled rather than orphaned.
Tests
Two regression tests in
tests/test_agent_loop.py:test_agent_loop_streams_tool_updates_while_the_tool_runs- asserts a partial is observable while the tool is still blocked, not merely present at the end. Verified failing on unfixed code and passing on fixed code.test_agent_loop_keeps_tool_updates_reported_before_a_failure- guards the error path, which this change could plausibly have broken. It passes before and after; it is a guard rather than a bug reproduction.tests/test_agent_loop.py,test_agent_harness.py,test_coding_session.py: all pass (137 passed).Note: 3 failures remain in
tests/test_coding_tools.pyin my environment. They are the Windows bash-only tests (shell_command_prefixandsleep 1 & waitjob control); I confirmed they fail identically with this change stashed, and they calltool.executedirectly without going through the loop, so they cannot be affected by it.🤖 Generated with Claude Code