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.