Skip to content

fix(agent): broadcast tool.result per call as it settles, not after the round - #117

Open
w0nche0l wants to merge 3 commits into
mainfrom
dennisjeong/dev-1067-broadcast-tool-result-per-call
Open

w0nche0l wants to merge 3 commits into
mainfrom
dennisjeong/dev-1067-broadcast-tool-result-per-call

Conversation

@w0nche0l

@w0nche0l w0nche0l commented Aug 25, 2026 •

Copy link
Copy Markdown
Contributor
Summary, root cause, and test plan (AGENT-WRITTEN)

Fixes DEV-1067

Problem

When a model emits multiple tool calls in one turn, executeToolRound awaited Promise.allSettled over the entire round before broadcasting any tool.result / tool.call_output events. With serial execution (maxConcurrency: 1), an early call could be finished for minutes while its chip in the chatroom still showed "Running...", and then every chip flipped to completed at once when the last call finished.

Change

  • executeToolRound now attaches a settle-time finalizer to each tool-call promise (finalizeSettledRoundCall / finalizeRejectedRoundCall): the tool.result, legacy tool_result, and tool.call_output broadcasts — and computeToolOutputForModel — run the moment that call settles.
  • Model-facing output assembly is unchanged: after Promise.allSettled, an ordered loop collects the pre-finalized function_call_outputs in call order, so the follow-up request input and prompt-cache behavior are byte-identical.
  • Background invocations now start their grace-window race at their own settle time — at least as early as the previous post-allSettled parallel kickoff, so no (N-1)×graceMs stagger is reintroduced.
  • Extracted the executeSingleToolCall outcome union into a named SingleToolCallOutcome type and added a pushToolCallOutputEvent helper (the push was previously duplicated at four sites).

Behavior notes

  • tool.call_output / tool.result / legacy tool_result events on the wire are now emitted in completion order (previously call order); the changeset calls this out. tool.result events were already only meaningful per call. The model-facing function_call_output array order is unchanged.
  • If computeToolOutputForModel (toModelOutput) throws for one call, executeToolRound waits for every sibling call to settle (Promise.allSettled over the finalizers) and then rejects with the first error in call order. Siblings' broadcasts and async registrations all land before the run fails. (Edit: an earlier revision used Promise.all here, which rejected immediately and orphaned still-running siblings.)
  • ctx.defer() taskId collisions within a round are still resolved in call order (the earlier call wins). A deferred call's registration waits for earlier calls in the round to settle; other calls are unaffected.

Tests

New tests/unit/tool-round-settle-broadcast.test.ts:

  • A fast call's tool.result + tool.call_output appear on the turn broadcaster while a slow sibling in the same round is still gated, and the follow-up request's function_call_outputs stay in call order (slow first) despite settling second.
  • A throwing call broadcasts its error result as it settles, without waiting for the slow sibling.

Full suite: 103 files / 1219 tests pass; lint and typecheck clean.

Release

Changeset included (patch). Follow-up after release: bump @openrouter/agent in openrouter-web (the existing patches/@openrouter%2Fagent@0.10.0.patch there will need re-basing onto the new version).

Made with Cursor

…he round (DEV-1067)

Co-authored-by: Cursor <cursoragent@cursor.com>
LukasParke and others added 2 commits October 21, 2026 17:58
…-call

Resolve the conflict with #110 (drop preliminaryResults): remove
preliminaryResultsForCall from SingleToolCallOutcome and the
broadcastToolResult call, and make SingleToolCallOutcome non-generic.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ms by call

- executeToolRound awaits Promise.allSettled over the per-call finalizers
  and rethrows the first rejection in call order. Promise.all rejected the
  round as soon as one call's toModelOutput threw, orphaning still-running
  siblings (late broadcasts dropped, async calls registering after the run
  failed).
- ctx.defer() registration waits on earlier calls' claim gates so
  rejectDuplicateTaskId picks the earlier call deterministically, as it did
  before per-call finalization. Only deferred calls wait.
- Regression tests for both; changeset calls out completion-order events.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@LukasParke

Copy link
Copy Markdown
Contributor

Pushed 2 commits:

  • Merge main: resolved the conflict with perf(agent): stop accumulating generator-tool preliminaryResults #110. Dropped preliminaryResultsForCall from SingleToolCallOutcome and from the broadcastToolResult call, and made the type non-generic (it was failing tsc with TS6133).
  • Fix: executeToolRound uses Promise.allSettled over the finalizers again and rethrows the first rejection in call order. With Promise.all, one call's toModelOutput throwing rejected the round while siblings were still running, so their broadcasts were dropped and async calls could register after the run had failed. ctx.defer() registration now waits for earlier calls' claim gates, so rejectDuplicateTaskId picks the winner by call order again. Only deferred calls wait.
  • Added regression tests for both. Updated the changeset to say tool.call_output/tool.result now arrive in completion order, and corrected the PR body ("still rejects, as before").

This branch has not been deployed

No deployments
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.

2 participants