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).
Current behavior
A tool reports progress through the
on_updatecallback in its executor signature (ToolExecutor,src/tau_agent/tools.py). Each reported partial is meant to surface as aToolExecutionUpdateEventwith apartial_resultfield.As of 0.2.0, the loop delivers these events only after the tool has finished.
_run_toolinsrc/tau_agent/loop.pyappends each partial to a list while the tool runs:_execute_tool_callthen yields the bufferedToolExecutionUpdateEvents afterawait _run_tool(...)returns. Every update therefore arrives in one batch after execution completes, immediately before thetool_execution_endevent. 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_loopis a pull-based async generator, and there is no yield point insideawait 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 anemitsink and calls it inline at every event point. InexecutePreparedToolCall, theonUpdatecallback handed to the tool callsemit({type: "tool_execution_update", ...})directly while the tool is executing, guarded by anacceptingUpdatesflag 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
agenttool whose foreground mode blocks insideexecutefor the duration of a child coding session. The extension reports cumulative run statistics (turns, tool uses, billed tokens) throughon_updateso 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_resultcard 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
AgentToolResultpartials 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
emitsink, and keep the publicrun_agent_loopiterator 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).