#40879·langchain

`InjectedToolCallId` is overridden by a value inside `args` when a custom `args_schema` does not declare the field

Author: Iris070308Created Sep 28, 2026Updated Sep 28, 2026
Labelsbugcoreexternal

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)

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)

bash
"""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