diff --git a/libs/core/langchain_core/utils/_merge.py b/libs/core/langchain_core/utils/_merge.py index 4200ca304d6..78d95c3a3ba 100644 --- a/libs/core/langchain_core/utils/_merge.py +++ b/libs/core/langchain_core/utils/_merge.py @@ -108,6 +108,39 @@ def merge_lists(left: Optional[list], *others: Optional[list]) -> Optional[list] else e ) merged[to_merge[0]] = merge_dicts(merged[to_merge[0]], new_e) + # Special handling for tool call chunks: if this chunk appears to be + # a continuation of a prior chunk (has None name/id) and no matching + # index was found, try to merge with the most recent tool call chunk + # that has a name/id. + # Fixes issues with models that send inconsistent indices. + # See #31511 for more. + elif ( + e.get("type") == "tool_call_chunk" + and e.get("name") is None + and e.get("id") is None + and merged + ): + # Find the most recent tool call chunk with a valid name or id + for i in reversed(range(len(merged))): + if ( + isinstance(merged[i], dict) + and merged[i].get("type") == "tool_call_chunk" + and ( + merged[i].get("name") is not None + or merged[i].get("id") is not None + ) + ): + # Merge with this chunk + new_e = ( + {k: v for k, v in e.items() if k != "type"} + if "type" in e + else e + ) + merged[i] = merge_dicts(merged[i], new_e) + break + else: + # No suitable chunk found, append as new + merged.append(e) else: merged.append(e) else: diff --git a/libs/core/tests/unit_tests/test_messages.py b/libs/core/tests/unit_tests/test_messages.py index 58822433d7a..4c3a06d2293 100644 --- a/libs/core/tests/unit_tests/test_messages.py +++ b/libs/core/tests/unit_tests/test_messages.py @@ -1197,3 +1197,65 @@ def test_convert_to_openai_image_block() -> None: } result = convert_to_openai_image_block(input_block) assert result == expected + + +def test_tool_call_streaming_different_indices() -> None: + """Test that tool call chunks with different indices but logically part of the same + tool call are merged correctly. This addresses issues with models like Qwen3 that + send inconsistent indices during streaming. + + See #31511. + + """ # noqa: D205 + # Create chunks that simulate Qwen3 behavior: + # First chunk has index=1, subsequent chunks have index=0 with name=None, id=None + chunk1 = AIMessageChunk( + content="", + tool_call_chunks=[ + create_tool_call_chunk( + name="search_function", + args='{"query": "langchain', + id="call_123", + index=1, # Initial index + ) + ], + ) + + chunk2 = AIMessageChunk( + content="", + tool_call_chunks=[ + create_tool_call_chunk( + name=None, # Continuation chunk + args=' tutorial"}', + id=None, # Continuation chunk + index=0, # Different index + ) + ], + ) + + # Merge chunks as happens during streaming + merged_chunk: AIMessageChunk = chunk1 + chunk2 # type: ignore[assignment] + + # Should result in a single merged tool call chunk + assert len(merged_chunk.tool_call_chunks) == 1 + assert merged_chunk.tool_call_chunks[0]["name"] == "search_function" + assert merged_chunk.tool_call_chunks[0]["args"] == '{"query": "langchain tutorial"}' + assert merged_chunk.tool_call_chunks[0]["id"] == "call_123" + + # Should result in a single valid tool call + assert len(merged_chunk.tool_calls) == 1 + assert len(merged_chunk.invalid_tool_calls) == 0 + + # Verify the final tool call is correct + tool_call = merged_chunk.tool_calls[0] + assert tool_call["name"] == "search_function" + assert tool_call["args"] == {"query": "langchain tutorial"} + assert tool_call["id"] == "call_123" + + # Test with message_chunk_to_message + message: AIMessage = message_chunk_to_message(merged_chunk) # type: ignore[assignment] + + assert len(message.tool_calls) == 1 + assert len(message.invalid_tool_calls) == 0 + assert message.tool_calls[0]["name"] == "search_function" + assert message.tool_calls[0]["args"] == {"query": "langchain tutorial"}