Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion libs/core/langchain_core/tools/convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from langchain_core.tools.base import ArgsSchema, BaseTool
from langchain_core.tools.simple import Tool
from langchain_core.tools.structured import StructuredTool
from langchain_core.utils.pydantic import _get_own_doc


@overload
Expand Down Expand Up @@ -316,7 +317,7 @@ def invoke_wrapper(
)
# If someone doesn't want a schema applied, we must treat it as
# a simple string->string function
if dec_func.__doc__ is None:
if _get_own_doc(dec_func) is None:
msg = (
"Function must have a docstring if "
"description not provided and infer_schema is False."
Expand Down
6 changes: 3 additions & 3 deletions libs/core/langchain_core/tools/structured.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
_is_injected_arg_type,
create_schema_from_function,
)
from langchain_core.utils.pydantic import is_basemodel_subclass
from langchain_core.utils.pydantic import _get_own_doc, is_basemodel_subclass

if TYPE_CHECKING:
from langchain_core.messages import ToolCall
Expand Down Expand Up @@ -211,10 +211,10 @@ def add(a: int, b: int) -> int:
)
description_ = description
if description is None and not parse_docstring:
description_ = source_function.__doc__ or None
description_ = _get_own_doc(source_function) or None
if description_ is None and args_schema:
if isinstance(args_schema, type) and is_basemodel_subclass(args_schema):
description_ = args_schema.__doc__
description_ = _get_own_doc(args_schema)
if (
description_
and "A base class for creating Pydantic models" in description_
Expand Down
23 changes: 21 additions & 2 deletions libs/core/langchain_core/utils/pydantic.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,25 @@ def get_pydantic_major_version() -> int:
TBaseModel = TypeVar("TBaseModel", bound=PydanticBaseModel)


def _get_own_doc(obj: Any) -> str | None:
"""Get the docstring defined directly on obj, not inherited from parents.

For classes, Python's ``__doc__`` traverses the MRO, so a child class
without its own docstring will return the parent's. This function checks
``__dict__`` directly to avoid that inheritance. For non-class objects
(functions, methods), ``__doc__`` is always their own.

Args:
obj: A class, function, or other object to inspect.

Returns:
The docstring if defined directly on ``obj``, otherwise ``None``.
"""
if isinstance(obj, type):
return obj.__dict__.get("__doc__")
return obj.__doc__


def is_pydantic_v1_subclass(cls: type) -> bool:
"""Check if the given class is Pydantic v1-like.

Expand Down Expand Up @@ -224,7 +243,7 @@ def _create_subset_model_v1(
fields[field_name] = (t, field.field_info)

rtn = cast("type[BaseModelV1]", create_model_v1(name, **fields)) # type: ignore[call-overload]
rtn.__doc__ = textwrap.dedent(fn_description or model.__doc__ or "")
rtn.__doc__ = textwrap.dedent(fn_description or _get_own_doc(model) or "")
return rtn


Expand Down Expand Up @@ -270,7 +289,7 @@ def _create_subset_model_v2(
]

rtn.__annotations__ = dict(selected_annotations)
rtn.__doc__ = textwrap.dedent(fn_description or model.__doc__ or "")
rtn.__doc__ = textwrap.dedent(fn_description or _get_own_doc(model) or "")
return rtn


Expand Down
127 changes: 127 additions & 0 deletions libs/core/tests/unit_tests/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -3653,3 +3653,130 @@ def some_func(names: list[str] | None = None) -> None:
schema = convert_to_openai_tool(some_func)
params = schema["function"]["parameters"]
assert "names" not in params.get("required", [])


class TestInheritedDocstrings:
"""Tests for issue #32066: docstrings shouldn't inherit from parents."""

def test_child_class_with_own_docstring_uses_own(self) -> None:
"""A child class with its own docstring should use it, not the parent's."""

class ParentSchema(BaseModel):
"""Parent description."""

foo: str

class ChildSchema(ParentSchema):
"""Child description."""

bar: str

structured_tool = StructuredTool.from_function(
func=lambda foo, bar: None,
name="child_tool",
args_schema=ChildSchema,
description=None,
)
assert structured_tool.description == "Child description."

def test_child_class_without_own_docstring_does_not_inherit(self) -> None:
"""A child class without its own docstring should NOT inherit parent's."""

class ParentSchema(BaseModel):
"""Parent description."""

foo: str

class ChildSchema(ParentSchema):
bar: str

# The child has no own docstring, so it should not silently use the
# parent's. Since no description is provided, this should raise.
with pytest.raises(ValueError, match="Function must have a docstring"):
StructuredTool.from_function(
func=lambda foo, bar: None,
name="child_tool",
args_schema=ChildSchema,
description=None,
)

def test_tool_decorator_child_class_with_own_docstring(self) -> None:
"""@tool on a function with a child schema uses the function's docstring."""

class ParentSchema(BaseModel):
"""Parent description."""

foo: str

class ChildSchema(ParentSchema):
"""Child description."""

bar: str

@tool(args_schema=ChildSchema)
def my_tool(foo: str, bar: str) -> str:
"""My tool description."""
return foo + bar

assert my_tool.description == "My tool description."

def test_explicit_description_overrides_inheritance(self) -> None:
"""An explicit description should always be used regardless of inheritance."""

class ParentSchema(BaseModel):
"""Parent description."""

foo: str

class ChildSchema(ParentSchema):
bar: str

structured_tool = StructuredTool.from_function(
func=lambda foo, bar: None,
name="child_tool",
args_schema=ChildSchema,
description="Explicit description.",
)
assert structured_tool.description == "Explicit description."

def test_function_with_own_docstring_still_works(self) -> None:
"""Regression: normal functions with docstrings should still work."""

def my_func(x: int) -> str:
"""My function description."""
return str(x)

structured_tool = StructuredTool.from_function(my_func, name="my_func")
assert structured_tool.description == "My function description."

def test_tool_decorator_function_docstring_with_inherited_schema(self) -> None:
"""@tool with func docstring should use it even if schema has inherited doc."""

class ParentSchema(BaseModel):
"""Parent description."""

x: int

class ChildSchema(ParentSchema):
pass

@tool(args_schema=ChildSchema)
def my_tool(x: int) -> str:
"""Function description wins."""
return str(x)

assert my_tool.description == "Function description wins."

def test_base_model_without_custom_docstring(self) -> None:
"""A direct BaseModel subclass without docstring should raise."""

class MySchema(BaseModel):
x: int

with pytest.raises(ValueError, match="Function must have a docstring"):
StructuredTool.from_function(
func=lambda x: None,
name="my_tool",
args_schema=MySchema,
description=None,
)
Loading