Skip to content

Fix streamed tool call accumulation: omit id on argument delta chunks#3

Merged
khasinski merged 1 commit intokhasinski:mainfrom
shllg:fix-streamed-tool-call-ids
Mar 18, 2026
Merged

Fix streamed tool call accumulation: omit id on argument delta chunks#3
khasinski merged 1 commit intokhasinski:mainfrom
shllg:fix-streamed-tool-call-ids

Conversation

@shllg
Copy link
Copy Markdown
Contributor

@shllg shllg commented Mar 16, 2026

Problem

When streaming tool calls via the Responses API, StreamAccumulator fails to correctly reconstruct tool calls. This causes NoMethodError: undefined method 'to_sym' for nil when Chat#execute_tool tries to look up the tool by name.

Root cause

The OpenAI Responses API emits two types of events for a tool call:

  1. response.output_item.added which passes call_id and name (e.g. "get_weather")
  2. response.function_call_arguments.delta which passes argument fragments and a call_id, but no name

build_streaming_tool_call currently sets id: call_id on every delta chunk. StreamAccumulator#accumulate_tool_calls treats any chunk with a non-nil id as a new tool call entry, replacing whatever was stored at that key. This means:

  • When delta call_id matches the added event's call_id: each delta overwrites the entry, replacing name: "get_weather" with name: nil and discarding previously accumulated arguments.
  • When delta call_id differs from the added event's call_id (which happens in practice — the added event uses item.call_id while deltas use a separate function call ID): the delta creates an entirely new entry with name: nil.

In both cases, the final tool_calls hash contains entries with name: nil, and Chat#execute_tool crashes at tool_call.name.to_sym.

Reproduction

chat = RubyLLM.chat(model: "gpt-4.1-mini", provider: :openai_responses, assume_model_exists: true)
chat.with_tool(MyTool)
# This crashes with: NoMethodError: undefined method 'to_sym' for nil
chat.ask("Use the my_tool tool") { |chunk| }

Fix

One-line change in build_streaming_tool_call: set id: nil on argument delta chunks (when data['name'] is absent). This makes StreamAccumulator route the chunk through its "append to latest tool call" path instead of creating/overwriting an entry.

# Before: id always set → accumulator overwrites on every delta
id: call_id,

# After: id only set when name present (output_item.added event)
id: data['name'] ? call_id : nil,

The hash key (call_id =>) is unchanged and doesn't affect accumulator behavior — only the ToolCall#id attribute matters for the accumulator's branching logic.

Prevent StreamAccumulator from creating duplicate entries when argument
delta events carry the same call_id as the initial output_item.added
event and set id to nil on nameless delta chunks so accumulator appends
arguments to the existing tool call instead of overwriting it.
@shllg shllg marked this pull request as ready for review March 16, 2026 09:37
@AlexVPopov
Copy link
Copy Markdown

Confirming the fix works!

@khasinski khasinski merged commit 811f1c3 into khasinski:main Mar 18, 2026
4 of 5 checks passed
@khasinski
Copy link
Copy Markdown
Owner

Thanks, merged :)

khasinski added a commit that referenced this pull request Mar 18, 2026
- Fix streamed tool call accumulation: argument deltas no longer
  overwrite the tool name (#3 by @shllg)
- Fix gem file permissions from 600 to 644 (#2)
- Extract streaming unit tests to dedicated spec file
@AlexVPopov
Copy link
Copy Markdown

Thank you both!

noelblaschke pushed a commit to noelblaschke/ruby_llm-responses_api that referenced this pull request Mar 26, 2026
- Fix streamed tool call accumulation: argument deltas no longer
  overwrite the tool name (khasinski#3 by @shllg)
- Fix gem file permissions from 600 to 644 (khasinski#2)
- Extract streaming unit tests to dedicated spec file
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.

3 participants