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.