Skip to content

feat(agent): emit a live ToolExecutionStarted stream item when a tool call dispatches - #2247

Open
nazq wants to merge 1 commit into
0xPlaygrounds:mainfrom
nazq:feat/tool-start-stream-event
Open

feat(agent): emit a live ToolExecutionStarted stream item when a tool call dispatches#2247
nazq wants to merge 1 commit into
0xPlaygrounds:mainfrom
nazq:feat/tool-start-stream-event

Conversation

@nazq

@nazq nazq commented Aug 3, 2026

Copy link
Copy Markdown

Fixes #2246.

Semver

MultiTurnStreamItem is #[non_exhaustive], so adding the variant is a minor, non-breaking
change: downstream matches already carry a wildcard arm, and existing consumers see the new
item fall into it. Every pre-existing stream item keeps its exact position and payload — the new
item is purely additive to the sequence. Serialized form follows the enum's existing tagged
convention ("type": "toolExecutionStarted").

Motivation

The multi-turn stream currently has no live tool-execution signal.
ToolExecutionCommitted + ToolResult are (deliberately) surfaced only after the whole tool
batch settles, so a streaming consumer that wants to render "running tool X…" — a chat UI, an
AG-UI/SSE bridge mapping onto a TOOL_CALL_* lifecycle, or any progress surface — has to
register an on_tool_call hook and re-merge that side channel into its own stream, re-deriving
correlation that the stream already carries. Hooks remain the right place for steering; this
PR gives pure observers a first-class stream item instead.

What this adds

A new MultiTurnStreamItem::ToolExecutionStarted { tool_name, internal_call_id }, emitted at
each tool call's actual start moment — immediately before its ToolCall hook chain runs —
one per dispatched call, not per batch. It carries the same rig-generated internal_call_id
consumers already receive on the model ToolCall item, ToolExecutionCommitted, and the
ToolResult, so the full start ⟷ commit ⟷ result lifecycle correlates with the ids they
already track.

Ordering contract (documented on the variant)

Per tool call, keyed by internal_call_id:

  1. the model's complete StreamedAssistantContent::ToolCall items — up front for the whole
    batch, in call order (unchanged);
  2. ToolExecutionStarted — live, as each call actually starts: call order on the sequential
    path, start order under tool_concurrency > 1;
  3. after the whole batch settles successfully — ToolExecutionCommitted + ToolResult, in
    call order (unchanged, still atomic all-or-nothing).

The item reports that dispatch began, not that the body ran:

  • a call whose ToolCall hook returns Skip still surfaces a start (its hook chain ran, which
    matters for slow approval-style hooks) but no ToolExecutionCommitted;
  • a call preresolved by invalid-tool-call recovery never dispatches and emits nothing;
  • a batch that terminates may leave starts without commit/result items (batch atomicity is
    unchanged).

Implementation

Both surfaces share drive_tool_calls, so the change lives there and behaves identically for
local and MCP-backed tools (both dispatch through the same registry snapshot / hook pipeline);
the blocking surface is untouched (forward_items: false still builds no items).

  • Sequential path: the item is yielded directly before run_single_tool is awaited, so a
    consumer observes it before the tool body is even constructed.
  • Concurrent path (buffer_unordered): each task reports its start through an unbounded
    side channel the moment it dispatches (after the fail-fast terminating check, so dropped
    siblings emit nothing), and the drain loop is a stream::select merge of that channel with
    the settled-outcome stream — starts surface live while tools are still running, and the
    channel closes exactly when the last task settles (each task owns one sender clone), so the
    merged drain terminates as before. Outcome collection, fail-fast semantics, and the
    atomic-after-settle commit are untouched.

Tests

All in rig-agent's existing streaming/loop test style (mock model turns, no network):

  • stream_emits_tool_execution_started_live_while_tool_runsliveness: the tool body
    completes only when the consumer reacts to the start item on the stream; a driver that
    surfaced the start after execution would deadlock (timeout-guarded). Also pins correlation:
    the start's internal_call_id equals the model ToolCall item's, the commit's, and the
    result's.
  • stream_emits_tool_execution_started_live_under_concurrency — same liveness proof on the
    buffer_unordered path with every call gated on its own start being consumed; asserts every
    start precedes the batch's commit/result items and start ids pair 1:1 with commit/result ids.
  • stream_emits_model_tool_calls_then_atomic_execution_items — extended taxonomy/ordering
    fixture now asserts the full marker sequence (model-calls → starts → commit/result pairs) at
    concurrency 1 and 4.
  • stream_hook_skip_surfaces_result_without_execution_commit — extended: a hook-skipped call
    surfaces a start and a result but no commit.
  • execution_commit_items_are_not_emitted_when_run_commit_fails — extended: a failed batch
    commit surfaces no commit/result items, while the already-emitted live start documents the
    starts-without-results terminal case.

Cassettes: not applicable (no provider behavior change).

cargo fmt --check, cargo clippy -p rig-agent --all-features --all-targets, and
cargo test -p rig-agent --all-features (489 passed) are green on the workspace toolchain.

…all dispatches

The multi-turn stream had no live tool-execution signal:
ToolExecutionCommitted and ToolResult surface only after the whole tool
batch settles, so a streaming consumer showing in-flight tool activity
had to ride the on_tool_call hook and re-merge it into its own stream.

Emit MultiTurnStreamItem::ToolExecutionStarted { tool_name,
internal_call_id } at each call's actual start moment on both the
sequential and concurrent tool paths, correlated with the ids consumers
already receive on the model ToolCall, execution-commit, and result
items. MultiTurnStreamItem is non_exhaustive, so the variant is additive
and non-breaking; every existing item keeps its position and the
atomic-after-settle batch commit is unchanged.
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.

feat(agent): emit a live tool-execution start item on the multi-turn stream

1 participant