Skip to content

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

Description

@rian-dolphin

Current behavior

A tool reports progress through the on_update callback in its executor signature (ToolExecutor, src/tau_agent/tools.py). Each reported partial is meant to surface as a ToolExecutionUpdateEvent with a partial_result field.

As of 0.2.0, the loop delivers these events only after the tool has finished. _run_tool in src/tau_agent/loop.py appends each partial to a list while the tool runs:

updates: list[AgentToolResult] = []
accepting = True

def on_update(partial: AgentToolResult) -> None:
    if accepting:
        updates.append(partial.model_copy(deep=True))

try:
    result = await tool.execute(call.id, call.arguments, signal, on_update)
    return result, False, updates

_execute_tool_call then yields the buffered ToolExecutionUpdateEvents after await _run_tool(...) returns. Every update therefore arrives in one batch after execution completes, immediately before the tool_execution_end event. For a long-running tool, the events exist but carry no information at the time a consumer could act on it.

The cause is structural: run_agent_loop is a pull-based async generator, and there is no yield point inside await tool.execute(...) through which an update could reach the consumer while the tool runs.

What pi does

pi's loop core is push-based. runAgentLoop (packages/agent/src/agent-loop.ts) takes an emit sink and calls it inline at every event point. In executePreparedToolCall, the onUpdate callback handed to the tool calls emit({type: "tool_execution_update", ...}) directly while the tool is executing, guarded by an acceptingUpdates flag that is cleared when the tool returns, with the collected emit promises settled before the result is returned. Subscribers therefore receive update events during execution, and no update can arrive after its own tool's result.

pi's production consumer (Agent.runPromptMessages, packages/agent/src/agent.ts) passes an emit sink that awaits every listener, so this liveness does not come at the cost of ordering: the loop pauses at each event until listeners have processed it, and only the tool-execution updates are decoupled from the running tool.

What this prevents

The tau-subagents extension (a port of pi-subagents) registers an agent tool whose foreground mode blocks inside execute for the duration of a child coding session. The extension reports cumulative run statistics (turns, tool uses, billed tokens) through on_update so the executing tool row can display live progress, matching pi-subagents.

Under the current buffering, these updates are delivered only once the child run has finished, at which point the tool's render_result card replaces the row and the updates are discarded unseen. Any tool whose execution is long enough for progress to be useful is affected in the same way; the shorter built-in tools simply do not expose the problem.

The extension-side contract is already correct (it emits AgentToolResult partials as specified), so a fix here requires no changes in extensions.

Possible directions

the loop core could adopt pi's shape: convert the body to a push function taking an awaited emit sink, and keep the public run_agent_loop iterator as a thin adapter. This matches the architecture tau's protocol is ported from and makes live updates structural rather than special-cased, at the cost of a larger and more behavior-sensitive change (consumer ordering, cancellation, and backpressure semantics all need to be preserved deliberately).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions