mirror of
https://github.com/langchain-ai/langchain.git
synced 2026-10-05 09:25:14 +03:00
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:
1 parent
72fb0090bd
commit
b5e8e2e85e
3 files changed
+32
-1
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user