Skip to content

fix(streaming): aggregate OpenAI tool-call delta fragments#512

Merged
yuga-hashimoto merged 1 commit into
mainfrom
fix/streaming-tool-call-aggregation
Apr 22, 2026
Merged

fix(streaming): aggregate OpenAI tool-call delta fragments#512
yuga-hashimoto merged 1 commit into
mainfrom
fix/streaming-tool-call-aggregation

Conversation

@yuga-hashimoto

Copy link
Copy Markdown
Owner

Priority 2 — Local agent capabilities

Summary

OpenAI-compatible SSE streams emit tool calls as fragments:

1. {id:"call_1", name:"get_weather", arguments:""}
2. {id:"",       name:"",            arguments:"{\"lo"}
3. {id:"",       name:"",            arguments:"cation\":\"Tokyo\"}"}

ChatViewModel.sendMessage and OpenClawProvider.send both appended each delta to a toolCalls list verbatim, producing three broken ToolCallRequests per call:

  • one with the real id but empty arguments
  • two with empty ids and partial JSON

The dispatcher then ran the first with empty args (tool fell back to defaults or errored) and the later two as "Unknown tool" — a silent failure where the LLM thought the tool succeeded.

Fix

New StreamingToolCallAggregator glues fragments back together. Heuristic:

  • A delta with a non-empty id starts a new call (committing the pending one).
  • A delta with an empty id appends its name/arguments fragment to the pending call.

Wired into:

  • ChatViewModel.sendMessage streaming consumer.
  • OpenClawProvider.send (the non-streaming path that still consumes per-event WS messages).

Scope & limitations

  • Sequential tool streaming (OpenAI default) works.
  • True multi-tool interleaved streaming would need explicit index keys. Documented as a future extension.

Test plan

  • New StreamingToolCallAggregatorTest covers: complete single delta, fragment sequence re-assembly, multi-call id boundaries, name-fragment append, orphaned pre-pending fragment, empty chunk no-op, empty aggregator.
  • ./gradlew :app:testStandardDebugUnitTest — green
  • ./gradlew :app:assembleStandardDebug — green

## Priority 2 — Local agent capabilities

OpenAI-compatible SSE streams emit tool calls as *fragments*:

  1. {id:"call_1", name:"get_weather", arguments:""}
  2. {id:"",       name:"",            arguments:"{\"lo"}
  3. {id:"",       name:"",            arguments:"cation\":\"Tokyo\"}"}

ChatViewModel.sendMessage and OpenClawProvider.send both appended each
delta to a toolCalls list verbatim, producing three broken tool-call
requests per call — one with the real id but empty arguments, two
with empty ids and partial JSON. The dispatcher then ran the first
with empty args (tool fell back to defaults or errored) and the later
two as "Unknown tool".

StreamingToolCallAggregator glues fragments back together: a delta
with a non-empty id starts a new call (committing the pending one);
an empty-id delta appends its name/arguments fragment to the pending
call. Sequential tool streaming (the OpenAI default) works; true
multi-tool interleaved streaming would need explicit index keys and
is documented as a future extension.

Wired into ChatViewModel streaming and OpenClawProvider.send (the
non-streaming path that still consumes per-event WS messages).
@yuga-hashimoto yuga-hashimoto merged commit 0b3e848 into main Apr 22, 2026
2 checks passed
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.

1 participant