fix: read tool_call_chunks for Anthropic streaming tool args

Anthropic/LangChain streams tool arguments as JSON string deltas in
chunk.tool_call_chunks (not chunk.tool_calls which has empty args dict).
Collect and accumulate both sources, then merge by tool_call_id.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-03-30 21:51:39 +01:00
parent fed7a2eab7
commit 23dba2fa7b

View file

@ -342,7 +342,8 @@ class LLMFactory:
for _round in range(max_rounds):
full_content = ""
tool_calls_in_response = []
tool_calls_in_response = [] # from chunk.tool_calls (id + name, often empty args)
tool_call_chunks_raw = [] # from chunk.tool_call_chunks (actual args as string deltas)
# Stream the LLM response
async for chunk in llm_with_tools.astream(lc_messages):
@ -368,14 +369,38 @@ class LLMFactory:
if tc:
tool_calls_in_response.extend(tc)
# If no tool calls, we're done
if not tool_calls_in_response:
# Collect tool_call_chunks — Anthropic streams args as JSON string deltas here
if hasattr(chunk, 'tool_call_chunks') and chunk.tool_call_chunks:
tool_call_chunks_raw.extend(chunk.tool_call_chunks)
# If no tool calls at all, we're done
if not tool_calls_in_response and not tool_call_chunks_raw:
return
# Accumulate tool calls across streaming chunks.
# Anthropic: first chunk has name + empty args dict; later chunks have args but no name.
# OpenAI: args arrive as incremental JSON string fragments across chunks.
# Strategy: accumulate name, merge dict args, concatenate string args per id.
# Build per-call accumulator keyed by index (tool_call_chunks use index, not id)
# Then enrich with id/name from tool_calls_in_response.
#
# tool_call_chunks structure (Anthropic/LangChain):
# first chunk: {"id": "toolu_01", "name": "code_interpreter", "args": "", "index": 0}
# later chunks: {"id": None, "name": None, "args": '{"code":"import...', "index": 0}
#
# tool_calls structure (all providers, but args often empty for Anthropic):
# {"id": "toolu_01", "name": "code_interpreter", "args": {}}
by_index: dict = {} # index -> {id, name, args_str}
for tcc in tool_call_chunks_raw:
idx = tcc.get("index", 0)
if idx not in by_index:
by_index[idx] = {"id": "", "name": "", "args_str": ""}
entry = by_index[idx]
if tcc.get("id") and not entry["id"]:
entry["id"] = tcc["id"]
if tcc.get("name") and not entry["name"]:
entry["name"] = tcc["name"]
if tcc.get("args"):
entry["args_str"] += tcc["args"]
# Also accumulate from tool_calls (handles OpenAI-style and non-chunk providers)
acc: dict = {} # tc_id -> {id, name, args_dict, args_str}
for tc in tool_calls_in_response:
tc_id = tc.get("id") or None
@ -385,7 +410,6 @@ class LLMFactory:
tc_args = tc.get("args")
if tc_args is None:
tc_args = tc.get("function", {}).get("arguments", "")
if tc_id not in acc:
acc[tc_id] = {"id": tc_id, "name": "", "args_dict": {}, "args_str": ""}
entry = acc[tc_id]
@ -396,7 +420,19 @@ class LLMFactory:
elif isinstance(tc_args, str) and tc_args:
entry["args_str"] += tc_args
# Fall back to raw list if no ids were found (shouldn't happen)
# Merge by_index (Anthropic chunks) into acc
for idx_entry in by_index.values():
tc_id = idx_entry["id"]
if not tc_id:
continue
if tc_id not in acc:
acc[tc_id] = {"id": tc_id, "name": "", "args_dict": {}, "args_str": ""}
if idx_entry["name"] and not acc[tc_id]["name"]:
acc[tc_id]["name"] = idx_entry["name"]
if idx_entry["args_str"]:
acc[tc_id]["args_str"] += idx_entry["args_str"]
# Fall back to raw list if no ids were found
if not acc:
acc = {i: {"id": None, "name": tc.get("name", ""), "args_dict": tc.get("args", {}), "args_str": ""}
for i, tc in enumerate(tool_calls_in_response)}