diff --git a/libs/core/langchain_core/utils/_merge.py b/libs/core/langchain_core/utils/_merge.py index 4200ca304d6..233bbe16c40 100644 --- a/libs/core/langchain_core/utils/_merge.py +++ b/libs/core/langchain_core/utils/_merge.py @@ -109,7 +109,36 @@ def merge_lists(left: Optional[list], *others: Optional[list]) -> Optional[list] ) merged[to_merge[0]] = merge_dicts(merged[to_merge[0]], new_e) else: - merged.append(e) + # Special handling for tool call chunks: if this chunk appears to be + # a continuation (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. + # This fixes issues with models like Qwen3 that send inconsistent indices. + if ( + 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: merged.append(e) return merged diff --git a/libs/core/tests/unit_tests/test_messages.py b/libs/core/tests/unit_tests/test_messages.py index 58822433d7a..ac20e4abdcf 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(): + """ + 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: https://github.com/langchain-ai/langchain/issues/31511 + """ + # 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 = chunk1 + chunk2 + + # 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 (core functionality) + message = message_chunk_to_message(merged_chunk) + + 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"}