Skip to content

fix: stream tool progress updates while the tool is still running - #519

Open
ShreyanshVaibhaw wants to merge 1 commit into
huggingface:mainfrom
ShreyanshVaibhaw:fix/stream-tool-updates-382
Open

fix: stream tool progress updates while the tool is still running#519
ShreyanshVaibhaw wants to merge 1 commit into
huggingface:mainfrom
ShreyanshVaibhaw:fix/stream-tool-updates-382

Conversation

@ShreyanshVaibhaw

Copy link
Copy Markdown
Contributor

Summary

Fixes #382.

Tool progress partials reported through on_update were buffered and emitted only after the tool returned, so ToolExecutionUpdateEvents all 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.

Reproduction

A tool that reports a partial, blocks, then reports a second partial before returning:

events seen WHILE tool still running : ['ToolExecutionStartEvent']
events after completion              : ['ToolExecutionStartEvent', 'ToolExecutionUpdateEvent',
                                        'ToolExecutionUpdateEvent', 'ToolExecutionEndEvent', ...]

Even with a 200 ms window nothing surfaced mid-run. After the change:

events seen WHILE tool still running : ['ToolExecutionStartEvent', 'ToolExecutionUpdateEvent']

Fix

As @rian-dolphin noted in the issue, the cause is structural: on_update is a synchronous callback, while the only thing that can emit events is the async generator currently awaiting the tool. Nothing bridged the two.

_run_tool is now an async generator that hands partials over through an asyncio.Queue and 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:

Property Result
Ordering across many partials 20/20 in order
Partial reported immediately before returning delivered, not dropped
Tool raises is_error=True, message preserved, earlier partials still delivered
Updates reported after completion ignored, as before
Consumer stops early tool task cancelled, no leaked tasks, tool does not run to completion

The 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.py in my environment. They are the Windows bash-only tests (shell_command_prefix and sleep 1 & wait job control); I confirmed they fail identically with this change stashed, and they call tool.execute directly without going through the loop, so they cannot be affected by it.

🤖 Generated with Claude Code

_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
@rian-dolphin

Copy link
Copy Markdown
Contributor

Interaction with #526

This 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, _run_tool awaited the tool inline. When the consumer task was cancelled, the CancelledError went through tool.execute immediately. The tool stopped, and its cleanup completed, before the generator closed. The teardown in #526 uses this guarantee: when the harness writes the synthetic "Tool call interrupted by user" result, the tool has stopped.

The change in this PR. The tool now runs in its own task. In the finally block, the code calls task.cancel() but does not await the task. I made a test for this: aclose() on the generator returns before the tool receives the cancellation. The tool continues until the event loop gets control again.

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 block, await the task after task.cancel(). Suppress the CancelledError:

finally:
    accepting = False
    if not task.done():
        task.cancel()
        with contextlib.suppress(asyncio.CancelledError):
            await task

An await in the finally block of an async generator is safe during aclose(). This change restores the guarantee: when the generator closes, the tool has stopped.

Sequence note. When the second of the two PRs merges, run test_cancelled_prompt_teardown_persists_interrupted_tool_result from #526 against the combination.

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.

Tool on_update partials are buffered until the tool returns, so tool_execution_update cannot carry live progress

2 participants