From b5e8e2e85e63bb97491a0fce63f006983911621c Mon Sep 17 00:00:00 2001
From: Hunter Lovell <40191806+hntrl@users.noreply.github.com>
Date: Tue, 18 Aug 2026 14:08:52 -0700
Subject: [PATCH] fix(core): fail fast when tool schemas can't resolve forward
refs during serialization (#39570)
fixes #39099
We currently allow forward refs in pydantic v2 schemas upon creation:
```python
class Container(BaseModel):
rows: list["Row"] = [] # "Row" is declared below, after the tool is decorated
@tool
def my_tool(container: Container):
"""A tool whose schema depends on a forward reference that is not resolvable yet."""
return "ok"
class Row(BaseModel):
name: str
```
When it comes time to introspect the tool schema (notably in
`count_tokens_approximately` and `convert_to_openai_tool`), we rely on
[signature
introspection](https://github.com/langchain-ai/langchain/blob/943dd700ef7c33e3f1f21d3e280c9c249b88259c/libs/core/langchain_core/tools/base.py#L1654-L1661)
to extract the tool's input schema. If that contains invalid forward
references, there's no schema fields to extract which results in an
empty dict:
Invalid forward reference MRE
```python
from __future__ import annotations
import inspect
from pydantic import BaseModel, Field
from pydantic.errors import PydanticUndefinedAnnotation
from langchain_core.tools.base import get_all_basemodel_annotations
from langchain_core.utils.pydantic import _create_subset_model, model_json_schema
class Container(BaseModel):
"""A model with a nested forward reference that can never resolve."""
rows: list["UndefinedRow"] = Field(default_factory=list)
def main() -> None:
"""Print the field-selection inputs and their zero-field subset result."""
selected_annotations = get_all_basemodel_annotations(Container)
subset_schema = _create_subset_model(
"ContainerSubset",
Container,
list(selected_annotations),
fn_description=Container.__doc__,
)
print(f"Pydantic complete: {Container.__pydantic_complete__}")
print(f"Pydantic fields: {list(Container.model_fields)}")
print(f"inspect.signature: {inspect.signature(Container)}")
print(f"Fields selected by get_all_basemodel_annotations: {selected_annotations}")
print(f"Subset properties: {model_json_schema(subset_schema)['properties']}")
if __name__ == "__main__":
main()
```
```output
Pydantic complete: False
Pydantic fields: ['rows']
inspect.signature: (**data: 'Any') -> 'None'
Fields selected by get_all_basemodel_annotations: {}
Subset properties: {}
```
Valid forward reference MRE
```python
from __future__ import annotations
import inspect
from pydantic import BaseModel, Field
from pydantic.errors import PydanticUndefinedAnnotation
from langchain_core.tools.base import get_all_basemodel_annotations
from langchain_core.utils.pydantic import _create_subset_model, model_json_schema
class Container(BaseModel):
"""A model with a nested forward reference that can never resolve."""
rows: list["UndefinedRow"] = Field(default_factory=list)
class UndefinedRow(BaseModel):
name: str = Field()
def main() -> None:
"""Print the field-selection inputs and their zero-field subset result."""
Container.model_rebuild()
selected_annotations = get_all_basemodel_annotations(Container)
subset_schema = _create_subset_model(
"ContainerSubset",
Container,
list(selected_annotations),
fn_description=Container.__doc__,
)
print(f"Pydantic complete: {Container.__pydantic_complete__}")
print(f"Pydantic fields: {list(Container.model_fields)}")
print(f"inspect.signature: {inspect.signature(Container)}")
print(f"Fields selected by get_all_basemodel_annotations: {selected_annotations}")
print(f"Subset properties: {model_json_schema(subset_schema)['properties']}")
if __name__ == "__main__":
main()
```
```output
Pydantic complete: True
Pydantic fields: ['rows']
inspect.signature: (*, rows: list[__main__.UndefinedRow] = ) -> None
Fields selected by get_all_basemodel_annotations: {'rows': list[__main__.UndefinedRow]}
Subset properties: {'rows': {'items': {'$ref': '#/$defs/UndefinedRow'}, 'title': 'Rows', 'type': 'array'}}
```
---
The fix is to
* at introspection time, resolve forward references using
`.model_rebuild()` that raises a pydantic exception if forward
references cant be resolved
* i'm also widening a pydantic utility to use a type guard instead of
having to use bool + cast
I'm intentionally not rebuilding pydantic v1 schemas in the same way
since
* forward references are specified by explicitly passing names into
`update_forward_refs`
* pydantic v1 is old news
---
libs/core/langchain_core/tools/base.py | 11 +++++++++++
libs/core/langchain_core/utils/pydantic.py | 3 ++-
libs/core/tests/unit_tests/test_tools.py | 19 +++++++++++++++++++
3 files changed, 32 insertions(+), 1 deletion(-)
diff --git a/libs/core/langchain_core/tools/base.py b/libs/core/langchain_core/tools/base.py
index 5dcea96b17..a1cd5502f1 100644
--- a/libs/core/langchain_core/tools/base.py
+++ b/libs/core/langchain_core/tools/base.py
@@ -701,6 +701,17 @@ class ChildTool(BaseTool):
full_schema = self.get_input_schema()
fields = []
+
+ # Accommodates a condition where forward references were not resolved
+ # during model construction. At introspection time, we fail fast if
+ # the model schema is not complete so the underlying serialized schema
+ # doesn't narrow the propreties in the tool json schema to an empty dict
+ if (
+ is_pydantic_v2_subclass(full_schema)
+ and not full_schema.__pydantic_complete__
+ ):
+ full_schema.model_rebuild()
+
for name, type_ in get_all_basemodel_annotations(full_schema).items():
if not _is_injected_arg_type(type_):
fields.append(name)
diff --git a/libs/core/langchain_core/utils/pydantic.py b/libs/core/langchain_core/utils/pydantic.py
index ed6b3027cb..48de9c1305 100644
--- a/libs/core/langchain_core/utils/pydantic.py
+++ b/libs/core/langchain_core/utils/pydantic.py
@@ -11,6 +11,7 @@ from types import GenericAlias
from typing import (
TYPE_CHECKING,
Any,
+ TypeGuard,
TypeVar,
cast,
overload,
@@ -84,7 +85,7 @@ def is_pydantic_v1_subclass(cls: type) -> bool:
return issubclass(cls, BaseModelV1)
-def is_pydantic_v2_subclass(cls: type) -> bool:
+def is_pydantic_v2_subclass(cls: type) -> TypeGuard[type[BaseModel]]:
"""Check if the given class is Pydantic v2-like.
Returns:
diff --git a/libs/core/tests/unit_tests/test_tools.py b/libs/core/tests/unit_tests/test_tools.py
index d304b51fa2..9b16fa8b5f 100644
--- a/libs/core/tests/unit_tests/test_tools.py
+++ b/libs/core/tests/unit_tests/test_tools.py
@@ -31,6 +31,7 @@ from pydantic import (
RootModel,
ValidationError,
)
+from pydantic.errors import PydanticUndefinedAnnotation
from pydantic.v1 import BaseModel as BaseModelV1
from pydantic.v1 import ValidationError as ValidationErrorV1
from typing_extensions import TypedDict, override
@@ -3191,6 +3192,24 @@ def test_tool_decorator_description() -> None:
)
+def test_inferred_args_schema_raises_for_unresolved_nested_forward_ref() -> None:
+ """Tool schemas should not silently drop incomplete Pydantic model fields."""
+
+ class Container(BaseModel):
+ # Intentionally unresolved; schema conversion must fail loudly.
+ rows: list["UndefinedRow"] = Field( # type: ignore[name-defined] # noqa: F821
+ default_factory=list
+ )
+
+ @tool
+ def my_tool(real_arg: str, container: Container) -> str:
+ """Process a container."""
+ return "ok"
+
+ with pytest.raises(PydanticUndefinedAnnotation, match="UndefinedRow"):
+ convert_to_openai_tool(my_tool)
+
+
def test_title_property_preserved() -> None:
"""Test that the title property is preserved when generating schema.