Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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,33 @@ 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 a ``run_manager``
# (CallbackManagerForToolRun / its async variant) into a tool's
# ``_run``/``_arun`` at run time. It is not a user-facing
# argument and pydantic cannot build a schema for it, so skip
# it. We detect it by annotation when resolvable and, as a
# fallback for unresolvable (string) annotations, by the
# conventional parameter name.
annotation = type_hints.get(k, v.annotation)
if _is_callback_manager_annotation(annotation) or k == "run_manager":

Choose a reason for hiding this comment

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

Could we filter callbacks by name here as well? The pinned langchain-core defines FILTERED_ARGS = ("run_manager", "callbacks"), and Callbacks is a sequence of callback handlers, so _is_callback_manager_annotation does not match it. I reproduced this with a no-schema tool whose _run accepts callbacks: Callbacks = None: constructing LangChainToolAdapter raises PydanticSchemaGenerationError for BaseCallbackHandler. Using k in ("run_manager", "callbacks") and extending this regression test to include callbacks fixes the crash; focused pytest, Pyright, mypy, and Ruff all pass.

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 inferred-schema path now filters both reserved LangChain parameters, run_manager and callbacks, and the regression suite covers the callbacks case. The current head also includes the pinned Ruff formatting fix. Please re-review.

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
35 changes: 35 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 @@ -100,3 +100,38 @@ 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"
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 set(schema["parameters"]["required"]) == {"a", "b"}

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