From fd9aac924361f581d8fbc36bddeef39d9f3a5cc3 Mon Sep 17 00:00:00 2001 From: ZouYonghe <1259085392z@gmail.com> Date: Sun, 11 Oct 2026 20:38:02 +0900 Subject: [PATCH 1/4] fix(dspy): accept LangChain tools with JSON schema args --- dspy/utils/langchain_tool.py | 18 ++++++++------- tests/utils/test_langchain_tool.py | 36 ++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/dspy/utils/langchain_tool.py b/dspy/utils/langchain_tool.py index 306ede8b92..0686e70c67 100644 --- a/dspy/utils/langchain_tool.py +++ b/dspy/utils/langchain_tool.py @@ -9,7 +9,7 @@ def convert_langchain_tool(tool: "BaseTool") -> Tool: """Build a DSPy tool from a LangChain tool. - + This function converts a LangChain tool (either created with @tool decorator or by subclassing BaseTool) into a DSPy Tool. @@ -29,13 +29,15 @@ async def func(**kwargs): # Get args_schema from the tool # https://python.langchain.com/api_reference/core/tools/langchain_core.tools.base.BaseTool.html#langchain_core.tools.base.BaseTool.args_schema args_schema = tool.args_schema - args, _, arg_desc = convert_input_schema_to_tool_args(args_schema.model_json_schema()) - - # The args_schema of Langchain tool is a pydantic model, so we can get the type hints from the model fields - arg_types = { - key: field.annotation if field.annotation is not None else Any - for key, field in args_schema.model_fields.items() - } + if isinstance(args_schema, dict): + args, arg_types, arg_desc = convert_input_schema_to_tool_args(args_schema) + else: + args, _, arg_desc = convert_input_schema_to_tool_args(args_schema.model_json_schema()) + # Preserve model annotations for nested Pydantic arguments. + arg_types = { + key: field.annotation if field.annotation is not None else Any + for key, field in args_schema.model_fields.items() + } return Tool( func=func, diff --git a/tests/utils/test_langchain_tool.py b/tests/utils/test_langchain_tool.py index 8d8c8904ed..51632e0992 100644 --- a/tests/utils/test_langchain_tool.py +++ b/tests/utils/test_langchain_tool.py @@ -50,3 +50,39 @@ def get_age(profile: Profile) -> int: assert tool.arg_types == {"profile": Profile} assert tool.arg_desc == {"profile": "No description provided. (Required)"} assert await tool.acall(profile=Profile(name="John", age=20)) == 20 + + +@pytest.mark.asyncio +@pytest.mark.extra +async def test_convert_tool_with_json_schema_dict(): + from langchain_core.tools import StructuredTool + + schema = { + "type": "object", + "properties": { + "query": {"type": "string"}, + "limit": {"type": "integer", "default": 5}, + }, + "required": ["query"], + } + + calls = [] + + async def search(query: str, limit: int = 5) -> str: + calls.append((query, limit)) + return f"{query}:{limit}" + + langchain_tool = StructuredTool.from_function( + coroutine=search, + name="search", + description="Search records.", + args_schema=schema, + ) + dspy_tool = convert_langchain_tool(langchain_tool) + + assert await dspy_tool.acall(query="cats") == "cats:5" + assert dspy_tool.args == schema["properties"] + assert dspy_tool.format_as_litellm_function_call()["function"]["parameters"]["required"] == ["query"] + with pytest.raises(ValueError, match="Arg query"): + await dspy_tool.acall(query=3) + assert calls == [("cats", 5)] From 446858768944d23e68a82de597098e2943f81655 Mon Sep 17 00:00:00 2001 From: ZouYonghe <1259085392z@gmail.com> Date: Sun, 11 Oct 2026 20:45:54 +0900 Subject: [PATCH 2/4] test(dspy): cover LangChain schema compatibility floor --- tests/utils/test_langchain_tool.py | 35 +++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/tests/utils/test_langchain_tool.py b/tests/utils/test_langchain_tool.py index 51632e0992..b61061f418 100644 --- a/tests/utils/test_langchain_tool.py +++ b/tests/utils/test_langchain_tool.py @@ -1,4 +1,5 @@ import importlib +from types import SimpleNamespace import pytest @@ -55,8 +56,6 @@ def get_age(profile: Profile) -> int: @pytest.mark.asyncio @pytest.mark.extra async def test_convert_tool_with_json_schema_dict(): - from langchain_core.tools import StructuredTool - schema = { "type": "object", "properties": { @@ -72,11 +71,14 @@ async def search(query: str, limit: int = 5) -> str: calls.append((query, limit)) return f"{query}:{limit}" - langchain_tool = StructuredTool.from_function( - coroutine=search, + async def ainvoke(kwargs): + return await search(**kwargs) + + langchain_tool = SimpleNamespace( + args_schema=schema, name="search", description="Search records.", - args_schema=schema, + ainvoke=ainvoke, ) dspy_tool = convert_langchain_tool(langchain_tool) @@ -86,3 +88,26 @@ async def search(query: str, limit: int = 5) -> str: with pytest.raises(ValueError, match="Arg query"): await dspy_tool.acall(query=3) assert calls == [("cats", 5)] + + +@pytest.mark.asyncio +@pytest.mark.extra +async def test_convert_real_langchain_tool_with_json_schema_dict(): + from langchain_core.tools import StructuredTool + + schema = {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]} + + async def search(query: str) -> str: + return f"found:{query}" + + try: + source = StructuredTool.from_function( + coroutine=search, name="search", description="Search records.", args_schema=schema + ) + except TypeError as error: + if "args_schema must be a subclass of pydantic BaseModel" not in str(error): + raise + pytest.skip("This LangChain version does not support JSON-dict args_schema") + + assert await source.ainvoke({"query": "cats"}) == "found:cats" + assert await convert_langchain_tool(source).acall(query="cats") == "found:cats" From dc14339888ad5454f9af2a292993362c5e211900 Mon Sep 17 00:00:00 2001 From: ZouYonghe <1259085392z@gmail.com> Date: Sun, 11 Oct 2026 21:08:40 +0900 Subject: [PATCH 3/4] fix(dspy): retain LangChain dict schema validation contract --- dspy/adapters/types/tool.py | 13 +++-- dspy/utils/langchain_tool.py | 6 ++- tests/utils/test_langchain_tool.py | 85 +++++++++++++++++++++++++++++- 3 files changed, 99 insertions(+), 5 deletions(-) diff --git a/dspy/adapters/types/tool.py b/dspy/adapters/types/tool.py index f8124024c6..d5d105050e 100644 --- a/dspy/adapters/types/tool.py +++ b/dspy/adapters/types/tool.py @@ -5,7 +5,7 @@ import json_repair import pydantic from jsonschema import ValidationError, validate -from pydantic import BaseModel, TypeAdapter, create_model +from pydantic import BaseModel, PrivateAttr, TypeAdapter, create_model from dspy.adapters.types.base_type import Type from dspy.dsp.utils.settings import settings @@ -36,6 +36,7 @@ class Tool(Type): arg_types: dict[str, Any] | None = None arg_desc: dict[str, str] | None = None has_kwargs: bool = False + _input_schema: dict[str, Any] | None = PrivateAttr(default=None) def __init__( self, @@ -122,6 +123,11 @@ def _parse_function(self, func: Callable, arg_desc: dict[str, str] | None = None self.has_kwargs = any(param.kind == param.VAR_KEYWORD for param in sig.parameters.values()) def _validate_and_parse_args(self, **kwargs): + if self._input_schema is not None: + try: + validate(instance=kwargs, schema=self._input_schema) + except ValidationError as e: + raise ValueError(f"Tool arguments are invalid: {e.message}") from e # Validate the args value comply to the json schema. for k, v in kwargs.items(): if k not in self.args: @@ -159,7 +165,7 @@ def format_as_litellm_function_call(self): "function": { "name": self.name, "description": self.desc, - "parameters": { + "parameters": self._input_schema if self._input_schema is not None else { "type": "object", "properties": self.args, "required": [k for k in self.args if "default" not in self.args[k]], @@ -570,7 +576,8 @@ def convert_input_schema_to_tool_args( if len(defs) > 0: prop = _resolve_json_schema_reference({"$defs": defs, **prop}) args[name] = prop - arg_types[name] = _TYPE_MAPPING.get(prop.get("type"), Any) + prop_type = prop.get("type") + arg_types[name] = _TYPE_MAPPING.get(prop_type, Any) if isinstance(prop_type, str) else Any arg_desc[name] = prop.get("description", "No description provided.") if name in required: arg_desc[name] += " (Required)" diff --git a/dspy/utils/langchain_tool.py b/dspy/utils/langchain_tool.py index 0686e70c67..9e526fb6cd 100644 --- a/dspy/utils/langchain_tool.py +++ b/dspy/utils/langchain_tool.py @@ -1,3 +1,4 @@ +from copy import deepcopy from typing import TYPE_CHECKING, Any from dspy.adapters.types.tool import Tool, convert_input_schema_to_tool_args @@ -39,7 +40,7 @@ async def func(**kwargs): for key, field in args_schema.model_fields.items() } - return Tool( + converted = Tool( func=func, name=tool.name, desc=tool.description, @@ -47,3 +48,6 @@ async def func(**kwargs): arg_types=arg_types, arg_desc=arg_desc ) + if isinstance(args_schema, dict): + converted._input_schema = deepcopy(args_schema) + return converted diff --git a/tests/utils/test_langchain_tool.py b/tests/utils/test_langchain_tool.py index b61061f418..36be1d92eb 100644 --- a/tests/utils/test_langchain_tool.py +++ b/tests/utils/test_langchain_tool.py @@ -85,11 +85,94 @@ async def ainvoke(kwargs): assert await dspy_tool.acall(query="cats") == "cats:5" assert dspy_tool.args == schema["properties"] assert dspy_tool.format_as_litellm_function_call()["function"]["parameters"]["required"] == ["query"] - with pytest.raises(ValueError, match="Arg query"): + with pytest.raises(ValueError, match="arguments are invalid"): await dspy_tool.acall(query=3) assert calls == [("cats", 5)] +@pytest.mark.asyncio +@pytest.mark.extra +async def test_convert_tool_preserves_nullable_types_and_required_schema(): + schema = { + "type": "object", + "properties": { + "query": {"type": ["string", "null"]}, + "limit": {"type": "integer"}, + }, + "required": ["query"], + } + + async def search(query: str | None, limit: int = 5) -> str: + return f"{query}:{limit}" + + source = SimpleNamespace( + args_schema=schema, + name="search", + description="Search records.", + ainvoke=lambda kwargs: search(**kwargs), + ) + dspy_tool = convert_langchain_tool(source) + + assert dspy_tool.args == schema["properties"] + assert dspy_tool.format_as_litellm_function_call()["function"]["parameters"]["required"] == ["query"] + assert await dspy_tool.acall(query=None) == "None:5" + assert await dspy_tool.acall(query="cats") == "cats:5" + with pytest.raises(ValueError): + await dspy_tool.acall(query=3) + + +@pytest.mark.asyncio +@pytest.mark.extra +async def test_dict_schema_union_preserves_values_and_root_constraints(): + schema = { + "type": "object", + "properties": {"query": {"type": ["string", "integer", "null"]}}, + "required": ["query"], + "anyOf": [{"properties": {"query": {"const": 3}}}, {"properties": {"query": {"type": "null"}}}], + } + + async def ainvoke(kwargs): + return kwargs["query"] + + converted = convert_langchain_tool( + SimpleNamespace(args_schema=schema, name="search", description="Search.", ainvoke=ainvoke) + ) + result = await converted.acall(query=3) + assert result == 3 and type(result) is int + assert await converted.acall(query=None) is None + with pytest.raises(ValueError): + await converted.acall(query="cats") + + +@pytest.mark.asyncio +@pytest.mark.extra +@pytest.mark.parametrize("required_default", [False, True]) +async def test_dict_schema_preserves_required_and_rejects_invalid_calls(required_default): + schema = { + "type": "object", + "properties": {"query": {"type": "string"}, "limit": {"type": "integer"}}, + "required": ["query"], + "additionalProperties": False, + } + if required_default: + schema["properties"]["query"]["default"] = "cats" + calls = [] + + async def ainvoke(kwargs): + calls.append(kwargs) + return kwargs + + converted = convert_langchain_tool( + SimpleNamespace(args_schema=schema, name="search", description="Search.", ainvoke=ainvoke) + ) + assert converted.format_as_litellm_function_call()["function"]["parameters"] == schema + assert await converted.acall(query="cats") == {"query": "cats"} + for arguments in ({}, {"query": "cats", "unknown": 1}, {"query": 1}): + with pytest.raises(ValueError): + await converted.acall(**arguments) + assert calls == [{"query": "cats"}] + + @pytest.mark.asyncio @pytest.mark.extra async def test_convert_real_langchain_tool_with_json_schema_dict(): From 8cad0340b14543fa7e4609047f63a0d7db69dd70 Mon Sep 17 00:00:00 2001 From: ZouYonghe <1259085392z@gmail.com> Date: Mon, 12 Oct 2026 16:56:05 +0900 Subject: [PATCH 4/4] fix(dspy): pin compatible pymdown for documentation builds --- docs/requirements.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/requirements.txt b/docs/requirements.txt index da3337a3cb..cd20796e9e 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -4,6 +4,8 @@ mdformat==0.7.22 mdformat-tables==1.0.0 mkdocstrings==1.0.6 mkdocstrings-python==2.0.9 +# mkdocstrings 1.0.6 does not pass the md argument required by pymdown 12.2. +pymdown-extensions==12.1 nbconvert==7.17.1 pillow==12.3.0 zensical==0.0.64