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:

<details>

<summary>Invalid forward reference MRE</summary>

```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: {}
```

</details>

<details>

<summary>Valid forward reference MRE</summary>

```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] = <factory>) -> None
Fields selected by get_all_basemodel_annotations: {'rows': list[__main__.UndefinedRow]}
Subset properties: {'rows': {'items': {'$ref': '#/$defs/UndefinedRow'}, 'title': 'Rows', 'type': 'array'}}
```
</details>

---

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
This commit is contained in:
Hunter Lovell authored and GitHub committed 2026-08-18 14:08:52 -07:00
1 parent 72fb0090bd
commit b5e8e2e85e
3 files changed
+32 -1

No files matched your search

+11
View File
@@ -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)
+2 -1
View File
@@ -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:
+19
View File
@@ -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.