Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

import asyncio
import inspect
from typing import TYPE_CHECKING, Any, Callable, Dict, Type, cast
import types
from typing import TYPE_CHECKING, Any, Callable, Dict, Type, Union, cast, get_args, get_origin, get_type_hints

from autogen_core import CancellationToken
from autogen_core.tools import BaseTool
Expand All @@ -12,6 +13,32 @@
from langchain_core.tools import BaseTool as LangChainTool


def _is_callback_manager_annotation(annotation: Any) -> bool:
"""Whether *annotation* is a LangChain callback-manager type.

LangChain injects a ``run_manager`` (``CallbackManagerForToolRun`` or
``AsyncCallbackManagerForToolRun``) into a tool's ``_run``/``_arun``. It is
not a real tool input and pydantic cannot generate a schema for it. Unwrap
``Optional[...]`` / ``Union[...]`` so both the bare type and
``Optional[CallbackManagerForToolRun]`` are detected.
"""
if annotation is None or annotation is inspect.Parameter.empty:
return False
origin = get_origin(annotation)
if origin is Union or (hasattr(types, "UnionType") and origin is types.UnionType):
return any(_is_callback_manager_annotation(arg) for arg in get_args(annotation))
try:
from langchain_core.callbacks.manager import (
AsyncCallbackManagerForToolRun,
CallbackManagerForToolRun,
)
except ImportError:
return False
return isinstance(annotation, type) and issubclass(
annotation, (CallbackManagerForToolRun, AsyncCallbackManagerForToolRun)
)


class LangChainToolAdapter(BaseTool[BaseModel, Any]):
"""Allows you to wrap a LangChain tool and make it available to AutoGen.

Expand Down Expand Up @@ -165,12 +192,37 @@ def __init__(self, langchain_tool: LangChainTool):
args_type = self._langchain_tool.args_schema # pyright: ignore
else:
# Infer args_type from the callable's signature
sig = inspect.signature(cast(Callable[..., Any], self._callable)) # type: ignore
fields = {
k: (v.annotation, Field(...))
for k, v in sig.parameters.items()
if k != "self" and v.kind not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
}
callable_ = cast(Callable[..., Any], self._callable) # type: ignore
sig = inspect.signature(callable_)
# Resolve annotations (they may be strings under
# ``from __future__ import annotations``) so we can recognize
# callback-manager-typed parameters.
try:
type_hints = get_type_hints(callable_)
except Exception:
type_hints = {}
fields = {}
for k, v in sig.parameters.items():
if k == "self" or v.kind in (
inspect.Parameter.VAR_POSITIONAL,
inspect.Parameter.VAR_KEYWORD,
):
continue
# LangChain injects runtime-only callback arguments into a
# tool's ``_run``/``_arun`` that are not user-facing and cannot
# be represented in a pydantic schema. LangChain itself excludes
# these via ``FILTERED_ARGS = ("run_manager", "callbacks")``:
# * ``run_manager`` (CallbackManagerForToolRun / its async
# variant) — detected by annotation when resolvable, and as
# a fallback for unresolvable (string) annotations, by the
# conventional parameter name.
# * ``callbacks`` (a ``Callbacks`` sequence of handlers) —
# ``_is_callback_manager_annotation`` does not match it, so
# it must be filtered by name.
annotation = type_hints.get(k, v.annotation)
if _is_callback_manager_annotation(annotation) or k in ("run_manager", "callbacks"):
continue
fields[k] = (annotation, Field(...))
args_type = create_model(f"{name}Args", **fields) # type: ignore
# Note: type ignore is used due to a LangChain typing limitation

Expand Down
73 changes: 73 additions & 0 deletions python/packages/autogen-ext/tests/tools/test_langchain_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from autogen_core import CancellationToken
from autogen_core.tools import Tool
from autogen_ext.tools.langchain import LangChainToolAdapter # type: ignore
from langchain_core.callbacks import Callbacks
from langchain_core.callbacks.manager import AsyncCallbackManagerForToolRun, CallbackManagerForToolRun
from langchain_core.tools import BaseTool as LangChainTool
from langchain_core.tools import tool # pyright: ignore
Expand Down Expand Up @@ -100,3 +101,75 @@ async def test_langchain_tool_adapter(caplog: pytest.LogCaptureFixture) -> None:
# Test run method for CustomCalculatorTool
custom_result = await custom_adapter.run_json({"a": 3, "b": 4}, CancellationToken())
assert custom_result == 12


class NoSchemaTool(LangChainTool):
name: str = "NoSchema"
description: str = "a tool without an explicit args schema"

def _run(self, a: int, b: int, run_manager: Optional[CallbackManagerForToolRun] = None) -> int:
return a + b

async def _arun(
self,
a: int,
b: int,
run_manager: Optional[AsyncCallbackManagerForToolRun] = None,
) -> int:
return a + b


@pytest.mark.asyncio
async def test_langchain_tool_adapter_skips_run_manager() -> None:
# Tools without an explicit args_schema get their args inferred from the
# callable's signature. LangChain injects a ``run_manager`` into ``_run``
# which is not a user-facing input and cannot be turned into a pydantic
# schema; the adapter must skip it (see #6385).
tool = NoSchemaTool()
adapter = LangChainToolAdapter(tool) # type: ignore

schema = adapter.schema
assert schema["name"] == "NoSchema"
assert "parameters" in schema
assert "properties" in schema["parameters"]
props = schema["parameters"]["properties"]
assert set(props.keys()) == {"a", "b"}
assert "required" in schema["parameters"]
assert set(schema["parameters"]["required"]) == {"a", "b"}

result = await adapter.run_json({"a": 2, "b": 3}, CancellationToken())
assert result == 5


class NoSchemaCallbacksTool(LangChainTool):
name: str = "NoSchemaCallbacks"
description: str = "a tool without an explicit args schema that also accepts callbacks"

def _run(self, a: int, b: int, callbacks: Callbacks = None) -> int:
return a + b

async def _arun(self, a: int, b: int, callbacks: Callbacks = None) -> int:
return a + b


@pytest.mark.asyncio
async def test_langchain_tool_adapter_skips_callbacks() -> None:
# In addition to ``run_manager``, LangChain also reserves ``callbacks`` (a
# ``Callbacks`` sequence of handlers) as a runtime-only argument -- it is in
# LangChain's ``FILTERED_ARGS``. Its annotation is not matched by
# ``_is_callback_manager_annotation``, so the adapter must skip it by name;
# otherwise inferring the args schema raises PydanticSchemaGenerationError.
tool = NoSchemaCallbacksTool()
adapter = LangChainToolAdapter(tool) # type: ignore

schema = adapter.schema
assert schema["name"] == "NoSchemaCallbacks"
assert "parameters" in schema
assert "properties" in schema["parameters"]
props = schema["parameters"]["properties"]

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pyright reports three reportTypedDictNotRequiredAccess errors here because parameters, properties, and required are optional TypedDict keys. The earlier test in this file already uses the safe pattern: assert each key is present before indexing it. Adding assert "parameters" in schema, assert "properties" in schema["parameters"], and assert "required" in schema["parameters"] makes targeted Pyright pass with 0 errors.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in c1b1fc2: the test now asserts parameters, properties, and required are present before indexing the optional TypedDict keys. Targeted tests and type checks pass on the current head. Please re-review.

assert set(props.keys()) == {"a", "b"}
assert "required" in schema["parameters"]
assert set(schema["parameters"]["required"]) == {"a", "b"}

result = await adapter.run_json({"a": 2, "b": 3}, CancellationToken())
assert result == 5