core: `finalize_tool_call_chunk` coerces `id=None` to `""`, collapsing parallel id-less tool calls
Submission checklist
- This is a bug, not a usage question.
- I added a clear and descriptive title that summarizes this issue.
- I used the GitHub search to find a similar question and didn't find it.
- I am sure that this is a bug in LangChain rather than my code.
- The bug is not resolved by updating to the latest stable version of LangChain (or the specific integration package).
- This is not related to the langchain-community package.
- I posted a self-contained, minimal, reproducible example. A maintainer can copy it and run it AS IS.
Package (Required)
- langchain
- langchain-openai
- langchain-anthropic
- langchain-classic
- langchain-core
- langchain-model-profiles
- langchain-tests
- langchain-text-splitters
- langchain-chroma
- langchain-deepseek
- langchain-exa
- langchain-fireworks
- langchain-groq
- langchain-huggingface
- langchain-mistralai
- langchain-nomic
- langchain-ollama
- langchain-openrouter
- langchain-perplexity
- langchain-qdrant
- langchain-xai
- Other / not sure / general
Related Issues / PRs
- langchain-ai/deepagents#6302 - downstream collision case
- langchain-ai/deepagents#6316 - fix already merged at deepagents layer
Reproduction Steps / Example Code (Python)
from langchain_core.language_models._compat_bridge import chunks_to_events
from langchain_core.language_models.chat_model_stream import ChatModelStream
from langchain_core.messages import AIMessageChunk
from langchain_core.messages.tool import tool_call_chunk
from langchain_core.outputs import ChatGenerationChunk
def id_less_chunk(index, args):
# Mirrors what a provider parser yields when the upstream SSE frame
# had no `id` (e.g. args-delta frames after a lost opener).
return ChatGenerationChunk(
message=AIMessageChunk(
content="",
tool_call_chunks=[tool_call_chunk(name="search", args=args, id=None, index=index)],
),
)
v1_chunks = [
id_less_chunk(0, '{"q": "first"}'),
id_less_chunk(1, '{"q": "second"}'),
]
stream = ChatModelStream()
for event in chunks_to_events(iter(v1_chunks), message_id="msg_demo"):
stream.dispatch(event)
for i, tc in enumerate(stream.output.tool_calls):
print(f"tool_calls[{i}]: id={tc['id']!r} name={tc['name']!r}")Error Message and Stack Trace (if applicable)
Observed output from the MRE on unpatched master (langchain-core 1.6.3):
tool_calls[0]: id='' name='search'
tool_calls[1]: id='' name='search'
Both parallel tool_calls collapse onto the same empty string, so any downstream keyed off `tool_call["id"]` cannot tell them apart.Description
Summary
langchain_core.language_models._compat_bridge.finalize_tool_call_chunk coerces id_=None to ToolCall.id="" (and ServerToolCall.id=""). This normalization is applied on every path that goes through the compat bridge — stream_v2, stream_events(version="v3"), and LangGraph stream_mode="messages" — for every provider that doesn't implement the native _stream_chat_model_events hook (currently: all of them). Downstream consumers that key off tool_call["id"] cannot tell two id-less parallel tool_calls apart (concrete case: langchain-ai/deepagents#6302, where FilesystemMiddleware offloads all collided on /large_tool_results/unknown).
Where
libs/core/langchain_core/language_models/_compat_bridge.py, finalize_tool_call_chunk:
# tool_call finalize
finalized_tc = ToolCall(
type="tool_call",
id=id_ or "", # ← None -> ""
name=name or "",
args=parsed,
)
# server_tool_call finalize
finalized_stc = ServerToolCall(
type="server_tool_call",
id=id_ or "", # ← None -> ""
name=name or "",
args=parsed,
)Why this is a bug
1. Inconsistent with the sibling branch in the same function. The InvalidToolCall branch of finalize_tool_call_chunk preserves id=id_ (keeps None); only the tool_call and server_tool_call branches coerce. This looks like a local oversight rather than a deliberate policy.
2. Violates the declared type contract. ToolCall.id: str | None (langchain_core.messages.tool line 231) — None is a valid value. The coercion silently rewrites it.
3. Provider-layer workarounds are already fragmenting. Three partners synthesize a UUID themselves when the provider chunk didn't carry an id, each in a different way:
libs/partners/mistralai/langchain_mistralai/chat_models.py:217—uuid.uuid4().hex[:], conditionallibs/partners/ollama/langchain_ollama/chat_models.py:220—str(uuid4()), unconditionallangchain-google-genai/chat_models.py(external repo) —str(uuid.uuid4()), conditional
The rest (openai, anthropic, deepseek, fireworks, groq, perplexity, xai, openrouter, ...) rely on the provider always supplying an id. When that assumption breaks (e.g. LiteLLM proxy dropping the first tool_call SSE frame, per LiteLLM#39796, or community/self-hosted servers) the v3 stream user gets id="" tool_calls.
4. Downstream collision, concrete case. langchain-ai/deepagents#6302: FilesystemMiddleware's offload does if message.tool_call_id: ... else "unknown". Two parallel id="" tool messages both hit the "unknown" bucket, and the second offload silently overwrites the first at /large_tool_results/unknown.
Proposed fix
Two lines. Fall back to a synthesized hex uuid when id_ is falsy:
import uuid
# tool_call branch
id=id_ or uuid.uuid4().hex,
# server_tool_call branch
id=id_ or uuid.uuid4().hex,Behavior:
- Provider supplied a valid id (
id_truthy) → unchanged. - Provider supplied no id (
id_=None) → synthesized hex uuid, downstream is guaranteed a distinctstrid. - No breaking change.
- The three partners that already synthesize their own uuid still do so at the provider layer; their outputs are truthy so the new fallback is a no-op for them. Their per-partner workarounds become redundant and can be cleaned up in follow-ups (out of scope here).
Note - related sites downstream
libs/core/langchain_core/language_models/chat_model_stream.py:925, 1088 carry tcb.get("id", "") / tc.get("id", "") fallbacks too. Empirically these don't fire in practice: by the time execution reaches them the upstream finalize_tool_call_chunk has already produced an id key on the dict (either "" under the current code or the synthesized uuid under the proposed fix), so dict.get(key, default) returns the already-present value and the "" default is unreachable. They're defensive fallbacks that go dead once finalize_tool_call_chunk is fixed. Whether to align them for symmetry is a maintainer call.
System Info
System Information
OS: Windows OS Version: 10.0.26200 Python Version: 3.12.7 (tags/v3.12.7:0b05ead, Oct 1 2024, 03:06:41) [MSC v.1941 64 bit (AMD64)]
Package Information
langchain_core: 1.6.3 (reproduced against upstream/master) langsmith: 0.11.1 langchain_protocol: 0.0.18 langchain_tests: 1.1.9 langchain_text_splitters: 1.1.2
Other Dependencies (relevant)
pydantic: 2.13.4 typing-extensions: 4.16.0
Social handles (optional)
No response
Source: langchain-ai/langchain