From 23dba2fa7bf702fce5191d09ff6cdfd4fe3e0390 Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Mon, 30 Mar 2026 21:51:39 +0100 Subject: [PATCH] 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 --- backend/app/core/llm.py | 54 ++++++++++++++++++++++++++++++++++------- 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/backend/app/core/llm.py b/backend/app/core/llm.py index da2a7ee..e28dced 100644 --- a/backend/app/core/llm.py +++ b/backend/app/core/llm.py @@ -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)}