`InjectedToolCallId` is overridden by a value inside `args` when a custom `args_schema` does not declare the field
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-typesafe
- langchain-xai
- Other / not sure / general
Related Issues / PRs
#32729 (InjectedToolCallId Injection Fails When LLM Generates Parameter), fixed by PR #32766 on 2025-09-09 -- but only for schemas that declare the injected field, see below. #34169 is the inverse complaint about the same injection area.
Reproduction Steps / Example Code (Python)
Repro: with a custom args_schema that omits the injected field, a tool_call_id
inside args beats the real runtime ToolCall.id.
The tool body then reports one id while the ToolMessage carries another.
Exit code 1 means the two disagree, or the documented direct-id path broke.
import os
from typing import Annotated
os.environ["LANGSMITH_TRACING"] = "false"
from langchain_core.tools import InjectedToolCallId, StructuredTool
from pydantic import BaseModel
class PublicArgs(BaseModel):
x: int
def show(x: int, tool_call_id: Annotated[str, InjectedToolCallId]) -> str:
"""Return the call id the tool body actually received."""
return tool_call_id
inferred = StructuredTool.from_function(show, name="show")
custom = StructuredTool.from_function(show, name="show", args_schema=PublicArgs)
call = {"type": "tool_call", "name": "show", "id": "real",
"args": {"x": 1, "tool_call_id": "forged"}}
bad = []
for label, tool in (("inferred schema", inferred), ("custom schema", custom)):
result = tool.invoke(call)
consistent = result.content == "real" and result.tool_call_id == "real"
if not consistent:
bad.append(label)
print(f" {label:16s} tool body sees {result.content!r}, "
f"ToolMessage.tool_call_id {result.tool_call_id!r}{'' if consistent else ' <-- MISMATCH'}")
direct = custom.invoke({"x": 42, "tool_call_id": "direct_id"})
print(f" {'bare dict':16s} tool body sees {direct!r} (documented path, must keep working)")
if direct != "direct_id":
bad.append("bare dict path")
print(f"\nFAIL: {bad}" if bad else "\nOK: every path used the runtime id")
raise SystemExit(1 if bad else 0)Error Message and Stack Trace (if applicable)
"""Repro: with a custom args_schema that omits the injected field, a tool_call_id
inside args beats the real runtime ToolCall.id.
The tool body then reports one id while the ToolMessage carries another.
Exit code 1 means the two disagree, or the documented direct-id path broke.
"""
import os
from typing import Annotated
os.environ["LANGSMITH_TRACING"] = "false"
from langchain_core.tools import InjectedToolCallId, StructuredTool
from pydantic import BaseModel
class PublicArgs(BaseModel):
x: int
def show(x: int, tool_call_id: Annotated[str, InjectedToolCallId]) -> str:
"""Return the call id the tool body actually received."""
return tool_call_id
inferred = StructuredTool.from_function(show, name="show")
custom = StructuredTool.from_function(show, name="show", args_schema=PublicArgs)
call = {"type": "tool_call", "name": "show", "id": "real",
"args": {"x": 1, "tool_call_id": "forged"}}
bad = []
for label, tool in (("inferred schema", inferred), ("custom schema", custom)):
result = tool.invoke(call)
consistent = result.content == "real" and result.tool_call_id == "real"
if not consistent:
bad.append(label)
print(f" {label:16s} tool body sees {result.content!r}, "
f"ToolMessage.tool_call_id {result.tool_call_id!r}{'' if consistent else ' <-- MISMATCH'}")
direct = custom.invoke({"x": 42, "tool_call_id": "direct_id"})
print(f" {'bare dict':16s} tool body sees {direct!r} (documented path, must keep working)")
if direct != "direct_id":
bad.append("bare dict path")
print(f"\nFAIL: {bad}" if bad else "\nOK: every path used the runtime id")
raise SystemExit(1 if bad else 0)Description
A tool that takes tool_call_id: Annotated[str, InjectedToolCallId] gets the wrong id when the schema does not declare that field and the model puts a tool_call_id in args. With args_schema set to a model that only declares x, and a ToolCall whose id is "real" and whose args contain "tool_call_id": "forged", the tool body receives "forged". The ToolMessage it produces still carries "real", so the two disagree.
_parse_input ends with this, at libs/core/langchain_core/tools/base.py lines 889-902:
for k in self._injected_args_keys:
if k in tool_input:
validated_input[k] = tool_input[k]
elif k == "tool_call_id":
...
validated_input[k] = tool_call_id
Whatever the caller put in wins, and the runtime id is only consulted in the elif. The pre-fill that would overwrite it (lines 820-833, and 840-853 for the pydantic v1 branch) walks get_all_basemodel_annotations(input_args), so it only ever sees fields the schema declares. That is the actual dividing line here: not inferred-vs-custom schema, but whether the schema declares the injected field.
This looks like the leftover from #32729. PR #32766 fixed that by dropping the and k not in tool_input guard, but the branch it touches only handles schema-declared fields, so a schema that omits the field never reaches it. There is no test for this case either.
One thing a fix has to be careful about: passing tool_call_id straight in a plain dict is deliberate and test_tool_injected_tool_call_id_with_custom_schema asserts it, so that has to keep working. That test does its two invocations separately -- it never puts a ToolCall id and a tool_call_id inside args together, which is exactly this case.
System Info
langchain-core: 1.6.5 langchain: 1.4.2 langgraph: 1.2.11 pydantic: 2.12.5 Python: 3.11.15 upstream commit tested: 1ef23d6b7f6d83508a8cb531feeb88860e4dec40 OS: Windows 10 + WSL No API key or network access is needed for the reproduction.
Social handles (optional)
No response
Source: langchain-ai/langchain