Skip to content

fix(server-utils): Keep Workers AI streamed tool calls that have no index - #25835

Open
pcbeingused333 wants to merge 3 commits into
getsentry:developfrom
pcbeingused333:fix/workers-ai-stream-tool-calls-without-index
Open

pcbeingused333 wants to merge 3 commits into
getsentry:developfrom
pcbeingused333:fix/workers-ai-stream-tool-calls-without-index

Conversation

@pcbeingused333

Copy link
Copy Markdown
Contributor

Why: The Workers AI streaming instrumentation (accumulateStreamingToolCalls) keyed every tool-call delta by toolCall.index ?? 0. OpenAI-compatible endpoints that omit index and send each parallel call whole in its own chunk therefore ended up as a single call: the first call's id and name, with the arguments of every call concatenated, and the other calls missing from gen_ai.response.tool_calls and gen_ai.output.messages. Same situation #25622 fixed for the OpenAI instrumentation.

Tool calls with a numeric index are grouped by it exactly as before. A delta without a usable index (missing or null) is kept in a separate list, in arrival order, so it can never collide with a real index: a delta whose id is already known continues that call, a new id (or a function name without an id) starts a new call, and anything else, such as an arguments fragment with neither, continues the latest one. Native and indexed streams (including the top-level name/arguments shape) behave as before.

How to review: getUnindexedToolCall and the ToolCallAccumulator split in packages/server-utils/src/ai/workers-ai/streaming.ts; the three new cases in workers-ai-streaming.test.ts (two parallel calls without an index stay separate, argument fragments of one unindexed call are merged, an unindexed call does not merge into an indexed call at index: 0). The first and third fail without the change; the whole test/ai/lib/tracing folder passes.

Unsure about: whether an unindexed delta with neither id nor name should continue the latest call (chosen here, mirrors #25622) or be dropped.

Closes: no linked issue; same class of bug as #25622.

This change was prepared with the assistance of an AI coding agent (Claude).

…ndex

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@pcbeingused333
pcbeingused333 requested a review from a team as a code owner October 28, 2026 15:41
@pcbeingused333
pcbeingused333 requested review from isaacs and mydea and removed request for a team October 28, 2026 15:41
@github-actions github-actions Bot added the external PR from an external contributor label Oct 28, 2026
@RulaKhaled
RulaKhaled self-requested a review October 29, 2026 13:33

@JosephDoUrden JosephDoUrden left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

read b403a7a against #25622 and ran instrumentWorkersAiStream from a copy with stubbed sentry imports, head vs develop, 8 delta shapes. no index and index: null parallel calls: 2 calls, develop gave 1. normal indexed stream unchanged. repo suite not run.

one gap at streaming.ts:71, it does not fully match #25622. index: 0 on every call with new ids still merges into 1 call. {index: 0, id, name} then an args fragment with no index or id now gives 2 calls, nameless one holds the args, develop gave 1. no index then indexed comes out reversed.

worth fixing before merge.

…dling

Reuse the id/index grouping of the OpenAI streaming accumulator: a new id on a taken index opens a new call, argument fragments without index or id continue the latest call, and calls keep their arrival order.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@pcbeingused333

Copy link
Copy Markdown
Contributor Author

Thanks @JosephDoUrden, good catch. Pushed 2725da7, which ports the same accumulator the OpenAI integration uses (getToolCallKey in #25622), so Workers AI streams now behave the same way:

  • index: 0 with a new id starts a new tool call instead of merging into the previous one.
  • An argument fragment with no index/id is appended to the call it belongs to, so the name is no longer empty with the args split off.
  • Calls are emitted in stream order.

I added tests for the three cases; they failed before the change and pass now.

This comment was drafted with Claude Code.

@pcbeingused333

Copy link
Copy Markdown
Contributor Author

Thanks @JosephDoUrden, good catch. Pushed a follow-up that ports the accumulator logic from the OpenAI integration (#25622) to the Workers AI streaming path:

  • {index: 0} with a new id now starts a new tool call instead of merging into the previous one.
  • An argument fragment with no index/id is appended to the call currently being built, so the name and arguments stay together.
  • Calls keep the order in which they arrived.

I added tests for these three cases; the suite passes locally.

This comment was drafted with Claude Code.

@RulaKhaled RulaKhaled left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for working on this! A few comments to look at.

also, which model sends tool calls without index? cloudflare's own workers-ai-provider keys them by index ?? 0 and its docs show index on every chunk that carries a name or arguments, so I'd like to see a captured stream before we handle more no-index shapes.
if there isn't one, this is a guard for openai-compatible routes rather than a workers ai fix and the PR description should say so. the description also still mentions getUnindexedToolCall and "three new cases", both out of date.

return undefined;
};

if (typeof toolCall.index === 'number') {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: the reverse order still splits: {id:'c1',...} then {index:0,id:'c1',function:{arguments:'1}'}} records two c1 entries, develop merged them. checking findById before opening a new index-N key fixes the id case. #25622 has the same gap so worth fixing in both if you extract the helper.

accumulator[index] = {
index,
accumulator.calls.set(key, {
index: toolCall.index ?? accumulator.calls.size,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: index: toolCall.index ?? calls.size still writes an index the provider never sent. with mixed streams it collides, e.g. unindexed cb then indexed ca both record index: 0. I'd keep whatever the provider sent, like #25622 does. the first new test will need updating since it expects index: 0 and index: 1.


it('does not merge a tool call without an index into one that has the same index number', async () => {
const { span, attributes } = createMockSpan();
const chunks = [

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: the new tests still only check gen_ai.response.tool_calls. can one of them assert gen_ai.output.messages too, since that's what the product reads? also missing index: null and an id that continues a call without an index. #25622's tests port over almost as is.

* Some providers omit the index and send each parallel call whole: a known `id` continues that call, a new `id` (or
* a name without an id) starts a new call, and anything else continues the latest one.
*/
function getToolCallKey(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

h: this is now getToolCallKey from openai/streaming.ts almost verbatim. can it move to ai/core and be shared? it only needs the calls map, keyByIndex and the last key, which openai's state already has, so each caller just passes in the normalized name.

return known;
}
}
if (toolCall.id || name || accumulator.lastKey === undefined) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: one more limit worth naming in the description: with no index and no id, {name:'a',arguments:'{"x":'} then {name:'a',arguments:'1}'} records two calls with half the args each. same for a nameless opener whose id arrives later. develop gave one call for both, by accident since it merged everything into index 0, so these shapes do get worse. #25622 behaves the same and I wouldn't add a heuristic unless a model actually streams like this.

@pcbeingused333
pcbeingused333 requested a review from a team November 11, 2026 13:58
@pcbeingused333

Copy link
Copy Markdown
Contributor Author

The key selection now lives in ai/core and is shared by Workers AI and OpenAI. A known id is resolved before a numeric index opens a new call, and Workers AI preserves the original index instead of synthesizing one. Tests cover the unindexed-to-indexed transition, null indices, a returning id after another call opens, and gen_ai.output.messages. The description also names the ambiguous shapes without adding heuristics.

This comment was drafted with Claude Code.

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

external PR from an external contributor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants