Skip to content

Commit 916e7dc

Browse files
committed
supports imports of langchain tools
1 parent 8ab3a7a commit 916e7dc

File tree

7 files changed

+1451
-154
lines changed

7 files changed

+1451
-154
lines changed

examples/demo_langchain_tool.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# examples/demo_langchain_tool.py
2+
"""
3+
Demo: register a LangChain `BaseTool` with chuk-tool-processor and invoke it.
4+
"""
5+
6+
from __future__ import annotations
7+
import asyncio
8+
from typing import ClassVar, Any
9+
10+
from langchain.tools.base import BaseTool
11+
from chuk_tool_processor.registry.auto_register import register_langchain_tool
12+
from chuk_tool_processor.core.processor import ToolProcessor
13+
from chuk_tool_processor.models.tool_call import ToolCall
14+
15+
16+
# ── LangChain tool definition ───────────────────────────────────────────────
17+
class PalindromeTool(BaseTool):
18+
# pydantic requires concrete type annotations here
19+
name: ClassVar[str] = "palindrome_tool"
20+
description: ClassVar[str] = (
21+
"Return whether the given text is a palindrome."
22+
)
23+
24+
# synchronous implementation (BaseTool will call this from .run/.arun)
25+
def _run(self, tool_input: str, *args: Any, **kwargs: Any) -> dict:
26+
is_pal = tool_input.lower() == tool_input[::-1].lower()
27+
return {"text": tool_input, "palindrome": is_pal}
28+
29+
# asynchronous implementation (optional but nice to have)
30+
async def _arun(
31+
self, tool_input: str, run_manager: Any | None = None, **kwargs: Any
32+
) -> dict: # noqa: D401
33+
# Just delegate to the sync version for this demo
34+
return self._run(tool_input)
35+
36+
37+
# ── register with the global registry ───────────────────────────────────────
38+
register_langchain_tool(PalindromeTool())
39+
40+
41+
# ── quick test run ──────────────────────────────────────────────────────────
42+
async def main() -> None:
43+
proc = ToolProcessor(enable_caching=False)
44+
45+
# Pretend the LLM called the tool with {"tool_input": "Madam"}
46+
call = ToolCall(tool="palindrome_tool", arguments={"tool_input": "Madam"})
47+
[result] = await proc.executor.execute([call])
48+
49+
print("Tool results:")
50+
print("·", result.tool, result.result)
51+
52+
53+
if __name__ == "__main__":
54+
asyncio.run(main())

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ dependencies = [
1212
"dotenv>=0.9.9",
1313
"openai>=1.76.0",
1414
"pydantic>=2.11.3",
15+
"uuid>=1.30",
1516
]
1617

1718
# Tell setuptools to look in src/ for your a2a package
@@ -31,4 +32,7 @@ asyncio_mode = "strict"
3132
dev = [
3233
"pytest-asyncio>=0.26.0",
3334
"pytest>=8.3.5",
35+
"langchain>=0.3.24",
36+
"langchain-community>=0.3.22",
37+
"langchain-openai>=0.3.14",
3438
]
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# chuk_tool_processor/plugins/exporters/langchain/__init__.py
2+
"""
3+
Exporter plugin: expose every registry tool as a LangChain `BaseTool`.
4+
Category = "exporter", Name = "langchain"
5+
"""
6+
from __future__ import annotations
7+
8+
from chuk_tool_processor.plugins.discovery import plugin_registry
9+
from .bridge import registry_as_langchain_tools # lazy import happens inside
10+
11+
# --------------------------------------------------------------------------- #
12+
# build the exporter instance *once* ---------------------------------------- #
13+
# --------------------------------------------------------------------------- #
14+
class _LCExporter:
15+
"""Callable wrapper – behaves like any other exporter plugin."""
16+
17+
def __call__(self, *, filter_names: list[str] | None = None):
18+
return registry_as_langchain_tools(filter_names)
19+
20+
# keep the `.run()` synonym that LangChain likes to use
21+
run = __call__
22+
23+
24+
# --------------------------------------------------------------------------- #
25+
# register **the instance** (not the factory) ------------------------------- #
26+
# --------------------------------------------------------------------------- #
27+
plugin_registry.register_plugin( # type: ignore[attr-defined]
28+
"exporter",
29+
"langchain",
30+
_LCExporter(), # ← note the *call* – we store the object
31+
)
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# chuk_tool_processor/registry/auto_register.py
2+
"""
3+
Tiny “auto-register” helpers so you can do
4+
5+
register_fn_tool(my_function)
6+
register_langchain_tool(my_langchain_tool)
7+
8+
and they immediately show up in the global registry.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import asyncio
14+
import inspect
15+
import types
16+
from typing import Callable, ForwardRef, Type, get_type_hints
17+
18+
import anyio
19+
from pydantic import BaseModel, create_model
20+
21+
try: # optional dependency
22+
from langchain.tools.base import BaseTool # type: ignore
23+
except ModuleNotFoundError: # pragma: no cover
24+
BaseTool = None # noqa: N816 – keep the name for isinstance() checks
25+
26+
from chuk_tool_processor.registry.decorators import register_tool
27+
28+
29+
# ────────────────────────────────────────────────────────────────────────────
30+
# internals – build a Pydantic schema from an arbitrary callable
31+
# ────────────────────────────────────────────────────────────────────────────
32+
33+
34+
def _auto_schema(func: Callable) -> Type[BaseModel]:
35+
"""
36+
Turn a function signature into a `pydantic.BaseModel` subclass.
37+
38+
*Unknown* or *un-imported* annotations (common with third-party libs that
39+
use forward-refs without importing the target – e.g. ``uuid.UUID`` in
40+
LangChain’s `CallbackManagerForToolRun`) default to ``str`` instead of
41+
crashing `get_type_hints()`.
42+
"""
43+
try:
44+
hints = get_type_hints(func)
45+
except Exception:
46+
hints = {}
47+
48+
fields: dict[str, tuple[type, object]] = {}
49+
for param in inspect.signature(func).parameters.values():
50+
raw_hint = hints.get(param.name, param.annotation)
51+
# Default to ``str`` for ForwardRef / string annotations or if we
52+
# couldn’t resolve the type.
53+
hint: type = (
54+
raw_hint
55+
if raw_hint not in (inspect._empty, None, str)
56+
and not isinstance(raw_hint, (str, ForwardRef))
57+
else str
58+
)
59+
fields[param.name] = (hint, ...) # “...” → required
60+
61+
return create_model(f"{func.__name__.title()}Args", **fields) # type: ignore
62+
63+
64+
# ────────────────────────────────────────────────────────────────────────────
65+
# 1️⃣ plain Python function (sync **or** async)
66+
# ────────────────────────────────────────────────────────────────────────────
67+
68+
69+
def register_fn_tool(
70+
func: Callable,
71+
*,
72+
name: str | None = None,
73+
description: str | None = None,
74+
) -> None:
75+
"""Register a plain function as a tool – one line is all you need."""
76+
77+
schema = _auto_schema(func)
78+
name = name or func.__name__
79+
description = (description or func.__doc__ or "").strip()
80+
81+
@register_tool(name=name, description=description, arg_schema=schema)
82+
class _Tool: # noqa: D401, N801 – internal auto-wrapper
83+
async def _execute(self, **kwargs):
84+
if inspect.iscoroutinefunction(func):
85+
return await func(**kwargs)
86+
# off-load blocking sync work
87+
return await anyio.to_thread.run_sync(func, **kwargs)
88+
89+
90+
# ────────────────────────────────────────────────────────────────────────────
91+
# 2️⃣ LangChain BaseTool (or anything that quacks like it)
92+
# ────────────────────────────────────────────────────────────────────────────
93+
94+
95+
def register_langchain_tool(
96+
tool,
97+
*,
98+
name: str | None = None,
99+
description: str | None = None,
100+
) -> None:
101+
"""
102+
Register a **LangChain** `BaseTool` instance (or anything exposing
103+
``.run`` / ``.arun``).
104+
105+
If LangChain isn’t installed you’ll get a clear error instead of an import
106+
failure deep in the stack.
107+
"""
108+
if BaseTool is None:
109+
raise RuntimeError(
110+
"register_langchain_tool() requires LangChain - "
111+
"install with `pip install langchain`"
112+
)
113+
114+
if not isinstance(tool, BaseTool): # pragma: no cover
115+
raise TypeError(
116+
"Expected a langchain.tools.base.BaseTool instance – got "
117+
f"{type(tool).__name__}"
118+
)
119+
120+
fn = tool.arun if hasattr(tool, "arun") else tool.run # prefer async
121+
register_fn_tool(
122+
fn,
123+
name=name or tool.name or tool.__class__.__name__,
124+
description=description or tool.description or (tool.__doc__ or ""),
125+
)
Lines changed: 39 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,44 @@
11
# chuk_tool_processor/registry/provider.py
22
"""
3-
Registry provider that maintains a global tool registry.
3+
Global access to *the* tool-registry instance.
44
"""
5+
from __future__ import annotations
6+
57
from typing import Optional
68

7-
# imports
8-
from chuk_tool_processor.registry.interface import ToolRegistryInterface
9-
from chuk_tool_processor.registry.providers import get_registry
10-
11-
12-
class ToolRegistryProvider:
13-
"""
14-
Global provider for a ToolRegistryInterface implementation.
15-
Use `set_registry` to override (e.g., for testing).
16-
17-
This class provides a singleton-like access to a registry implementation,
18-
allowing components throughout the application to access the same registry
19-
without having to pass it explicitly.
20-
"""
21-
# Initialize with default registry
22-
_registry: Optional[ToolRegistryInterface] = None
23-
24-
@classmethod
25-
def get_registry(cls) -> ToolRegistryInterface:
26-
"""
27-
Get the current registry instance.
28-
29-
Returns:
30-
The current registry instance.
31-
"""
32-
if cls._registry is None:
33-
cls._registry = get_registry()
34-
return cls._registry
35-
36-
@classmethod
37-
def set_registry(cls, registry: ToolRegistryInterface) -> None:
38-
"""
39-
Set the global registry instance.
40-
41-
Args:
42-
registry: The registry instance to use.
43-
"""
44-
cls._registry = registry
9+
from .interface import ToolRegistryInterface
10+
from .providers.memory import InMemoryToolRegistry # default impl
11+
12+
# ───────────────────────────────────────────────────────────────────────────
13+
_REGISTRY: Optional[ToolRegistryInterface] = None
14+
# ───────────────────────────────────────────────────────────────────────────
15+
16+
17+
def get_registry() -> ToolRegistryInterface:
18+
"""Return the single, process-wide registry instance."""
19+
global _REGISTRY
20+
if _REGISTRY is None:
21+
_REGISTRY = InMemoryToolRegistry()
22+
return _REGISTRY
23+
24+
25+
def set_registry(registry: ToolRegistryInterface) -> None:
26+
"""Swap in another implementation (tests, multi-process, …)."""
27+
global _REGISTRY
28+
_REGISTRY = registry
29+
30+
31+
# ------------------------------------------------------------------------- #
32+
# 🔌 backward-compat shim – lets old `from … import ToolRegistryProvider`
33+
# statements keep working without changes.
34+
# ------------------------------------------------------------------------- #
35+
class ToolRegistryProvider: # noqa: D401
36+
"""Compatibility wrapper around the new helpers."""
37+
38+
@staticmethod
39+
def get_registry() -> ToolRegistryInterface: # same signature
40+
return get_registry()
41+
42+
@staticmethod
43+
def set_registry(registry: ToolRegistryInterface) -> None:
44+
set_registry(registry)

0 commit comments

Comments
 (0)