chuk-tool-processor 0.1.6__py3-none-any.whl → 0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of chuk-tool-processor might be problematic. Click here for more details.

Files changed (46) hide show
  1. chuk_tool_processor/core/processor.py +345 -132
  2. chuk_tool_processor/execution/strategies/inprocess_strategy.py +522 -71
  3. chuk_tool_processor/execution/strategies/subprocess_strategy.py +559 -64
  4. chuk_tool_processor/execution/tool_executor.py +282 -24
  5. chuk_tool_processor/execution/wrappers/caching.py +465 -123
  6. chuk_tool_processor/execution/wrappers/rate_limiting.py +199 -86
  7. chuk_tool_processor/execution/wrappers/retry.py +133 -23
  8. chuk_tool_processor/logging/__init__.py +83 -10
  9. chuk_tool_processor/logging/context.py +218 -22
  10. chuk_tool_processor/logging/formatter.py +56 -13
  11. chuk_tool_processor/logging/helpers.py +91 -16
  12. chuk_tool_processor/logging/metrics.py +75 -6
  13. chuk_tool_processor/mcp/mcp_tool.py +80 -35
  14. chuk_tool_processor/mcp/register_mcp_tools.py +74 -56
  15. chuk_tool_processor/mcp/setup_mcp_sse.py +41 -36
  16. chuk_tool_processor/mcp/setup_mcp_stdio.py +39 -37
  17. chuk_tool_processor/mcp/transport/sse_transport.py +351 -105
  18. chuk_tool_processor/models/execution_strategy.py +52 -3
  19. chuk_tool_processor/models/streaming_tool.py +110 -0
  20. chuk_tool_processor/models/tool_call.py +56 -4
  21. chuk_tool_processor/models/tool_result.py +115 -9
  22. chuk_tool_processor/models/validated_tool.py +15 -13
  23. chuk_tool_processor/plugins/discovery.py +115 -70
  24. chuk_tool_processor/plugins/parsers/base.py +13 -5
  25. chuk_tool_processor/plugins/parsers/{function_call_tool_plugin.py → function_call_tool.py} +39 -20
  26. chuk_tool_processor/plugins/parsers/json_tool.py +50 -0
  27. chuk_tool_processor/plugins/parsers/openai_tool.py +88 -0
  28. chuk_tool_processor/plugins/parsers/xml_tool.py +74 -20
  29. chuk_tool_processor/registry/__init__.py +46 -7
  30. chuk_tool_processor/registry/auto_register.py +92 -28
  31. chuk_tool_processor/registry/decorators.py +134 -11
  32. chuk_tool_processor/registry/interface.py +48 -14
  33. chuk_tool_processor/registry/metadata.py +52 -6
  34. chuk_tool_processor/registry/provider.py +75 -36
  35. chuk_tool_processor/registry/providers/__init__.py +49 -10
  36. chuk_tool_processor/registry/providers/memory.py +59 -48
  37. chuk_tool_processor/registry/tool_export.py +208 -39
  38. chuk_tool_processor/utils/validation.py +18 -13
  39. chuk_tool_processor-0.2.dist-info/METADATA +401 -0
  40. chuk_tool_processor-0.2.dist-info/RECORD +58 -0
  41. {chuk_tool_processor-0.1.6.dist-info → chuk_tool_processor-0.2.dist-info}/WHEEL +1 -1
  42. chuk_tool_processor/plugins/parsers/json_tool_plugin.py +0 -38
  43. chuk_tool_processor/plugins/parsers/openai_tool_plugin.py +0 -76
  44. chuk_tool_processor-0.1.6.dist-info/METADATA +0 -462
  45. chuk_tool_processor-0.1.6.dist-info/RECORD +0 -57
  46. {chuk_tool_processor-0.1.6.dist-info → chuk_tool_processor-0.2.dist-info}/top_level.txt +0 -0
@@ -1,18 +1,26 @@
1
1
  # chuk_tool_processor/parsers/base.py
2
+ """Async-native parser-plugin base interface."""
3
+
4
+ from __future__ import annotations
5
+
2
6
  from abc import ABC, abstractmethod
3
7
  from typing import List
8
+
4
9
  from chuk_tool_processor.models.tool_call import ToolCall
5
10
 
11
+ __all__ = ["ParserPlugin"]
12
+
6
13
 
7
14
  class ParserPlugin(ABC):
8
15
  """
9
- Minimal interface every parser plug-in must implement.
16
+ Every parser plugin **must** implement the async ``try_parse`` coroutine.
10
17
 
11
- The processor will feed the *raw text* (or dict) it receives from upstream
12
- into `try_parse`. If the plugin recognises the format it should return a
13
- list of ToolCall objects; otherwise return an empty list.
18
+ The processor awaits it and expects *a list* of :class:`ToolCall`
19
+ objects. If the plugin doesn’t recognise the input it should return an
20
+ empty list.
14
21
  """
15
22
 
16
23
  @abstractmethod
17
- def try_parse(self, raw: str) -> List[ToolCall]:
24
+ async def try_parse(self, raw: str | object) -> List[ToolCall]: # noqa: D401
25
+ """Attempt to parse *raw* into one or more :class:`ToolCall` objects."""
18
26
  ...
@@ -1,33 +1,49 @@
1
- # chuk_tool_processor/plugins/function_call_tool_plugin.py
2
- """Function-call parser plugin.
3
- * Accepts dict **or** string input.
4
- * Coerces non-dict `arguments` to `{}` instead of rejecting.
5
- * Inherits from ``ParserPlugin`` so discovery categorises it as a *parser*.
6
- """
1
+ # chuk_tool_processor/plugins/parsers/function_call_tool.py
2
+ """Async parser for OpenAI-style single `function_call` objects."""
3
+
7
4
  from __future__ import annotations
8
5
 
9
6
  import json
10
7
  import re
11
8
  from typing import Any, Dict, List
9
+
12
10
  from pydantic import ValidationError
13
11
 
14
- # imports
15
- from .base import ParserPlugin
16
- from chuk_tool_processor.models.tool_call import ToolCall
17
12
  from chuk_tool_processor.logging import get_logger
13
+ from chuk_tool_processor.models.tool_call import ToolCall
14
+ from chuk_tool_processor.plugins.parsers.base import ParserPlugin
15
+
16
+ __all__ = ["FunctionCallPlugin"]
18
17
 
19
- # logger
20
- logger = get_logger("chuk_tool_processor.plugins.function_call_tool")
18
+ logger = get_logger(__name__)
21
19
 
22
- # balanced‐brace JSON object regex – one level only (good enough for payloads)
20
+ # One-level balanced JSON object (good enough for embedded argument blocks)
23
21
  _JSON_OBJECT = re.compile(r"\{(?:[^{}]|(?:\{[^{}]*\}))*\}")
24
22
 
25
23
 
24
+ class PluginMeta:
25
+ """Optional self-description used by the plugin-discovery system (if present)."""
26
+
27
+ name: str = "function_call"
28
+ description: str = (
29
+ "Parses a single OpenAI-style `function_call` JSON object (including "
30
+ "strings that embed such an object)."
31
+ )
32
+ version: str = "1.0.0"
33
+ author: str = "chuk_tool_processor"
34
+
35
+
26
36
  class FunctionCallPlugin(ParserPlugin):
27
- """Parse OpenAI-style **single** ``function_call`` objects."""
37
+ """Parse messages containing a *single* `function_call` entry."""
38
+
39
+ # --------------------------------------------------------------------- #
40
+ # Public API
41
+ # --------------------------------------------------------------------- #
28
42
 
29
- def try_parse(self, raw: str | Dict[str, Any]) -> List[ToolCall]:
43
+ async def try_parse(self, raw: str | Dict[str, Any]) -> List[ToolCall]:
30
44
  payload: Dict[str, Any] | None
45
+
46
+ # 1️⃣ Primary path ─ whole payload is JSON
31
47
  if isinstance(raw, dict):
32
48
  payload = raw
33
49
  else:
@@ -38,22 +54,24 @@ class FunctionCallPlugin(ParserPlugin):
38
54
 
39
55
  calls: List[ToolCall] = []
40
56
 
41
- # primary path -----------------------------------------------------
42
57
  if isinstance(payload, dict):
43
58
  calls.extend(self._extract_from_payload(payload))
44
59
 
45
- # fallback scan raw text for nested JSON blocks ------------------
60
+ # 2️⃣ Fallback path scan for *nested* JSON objects inside a string
46
61
  if not calls and isinstance(raw, str):
47
- for m in _JSON_OBJECT.finditer(raw):
62
+ for match in _JSON_OBJECT.finditer(raw):
48
63
  try:
49
- sub = json.loads(m.group(0))
64
+ sub = json.loads(match.group(0))
50
65
  except json.JSONDecodeError:
51
66
  continue
52
67
  calls.extend(self._extract_from_payload(sub))
53
68
 
54
69
  return calls
55
70
 
56
- # ------------------------------------------------------------------
71
+ # ------------------------------------------------------------------ #
72
+ # Helpers
73
+ # ------------------------------------------------------------------ #
74
+
57
75
  def _extract_from_payload(self, payload: Dict[str, Any]) -> List[ToolCall]:
58
76
  fc = payload.get("function_call")
59
77
  if not isinstance(fc, dict):
@@ -62,12 +80,13 @@ class FunctionCallPlugin(ParserPlugin):
62
80
  name = fc.get("name")
63
81
  args = fc.get("arguments", {})
64
82
 
65
- # arguments may be JSON‑encoded string or anything else
83
+ # Arguments may themselves be JSON in *string* form
66
84
  if isinstance(args, str):
67
85
  try:
68
86
  args = json.loads(args)
69
87
  except json.JSONDecodeError:
70
88
  args = {}
89
+
71
90
  if not isinstance(args, dict):
72
91
  args = {}
73
92
 
@@ -0,0 +1,50 @@
1
+ # chuk_tool_processor/plugins/parsers/json_tool.py
2
+ """Async JSON `tool_calls` parser plugin."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ from typing import Any, List
8
+
9
+ from pydantic import ValidationError
10
+
11
+ from chuk_tool_processor.logging import get_logger
12
+ from chuk_tool_processor.models.tool_call import ToolCall
13
+ from chuk_tool_processor.plugins.parsers.base import ParserPlugin
14
+
15
+ __all__ = ["JsonToolPlugin"]
16
+
17
+ logger = get_logger(__name__)
18
+
19
+
20
+ class PluginMeta:
21
+ """Optional self-description consumed by the plugin-discovery subsystem."""
22
+ name: str = "json_tool_calls"
23
+ description: str = "Parses a JSON object containing a `tool_calls` array."
24
+ version: str = "1.0.0"
25
+ author: str = "chuk_tool_processor"
26
+
27
+
28
+ class JsonToolPlugin(ParserPlugin):
29
+ """Extracts a *list* of :class:`ToolCall` objects from a `tool_calls` array."""
30
+
31
+ async def try_parse(self, raw: str | Any) -> List[ToolCall]: # noqa: D401
32
+ # Decode JSON if we were given a string
33
+ try:
34
+ data = json.loads(raw) if isinstance(raw, str) else raw
35
+ except json.JSONDecodeError:
36
+ logger.debug("json_tool: input is not valid JSON")
37
+ return []
38
+
39
+ if not isinstance(data, dict):
40
+ return []
41
+
42
+ calls: List[ToolCall] = []
43
+ for entry in data.get("tool_calls", []):
44
+ try:
45
+ calls.append(ToolCall(**entry))
46
+ except ValidationError:
47
+ logger.debug("json_tool: validation error on entry %s", entry)
48
+ continue
49
+
50
+ return calls
@@ -0,0 +1,88 @@
1
+ # chuk_tool_processor/plugins/parsers/openai_tool.py
2
+ """Async parser for OpenAI-style `tool_calls` arrays."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ from typing import Any, List
8
+
9
+ from pydantic import ValidationError
10
+
11
+ from chuk_tool_processor.logging import get_logger
12
+ from chuk_tool_processor.models.tool_call import ToolCall
13
+ from chuk_tool_processor.plugins.parsers.base import ParserPlugin
14
+
15
+ __all__ = ["OpenAIToolPlugin"]
16
+
17
+ logger = get_logger(__name__)
18
+
19
+
20
+ class PluginMeta:
21
+ """Optional descriptor consumed by the plugin-discovery system."""
22
+ name: str = "openai_tool_calls"
23
+ description: str = "Parses Chat-Completions responses containing `tool_calls`."
24
+ version: str = "1.0.0"
25
+ author: str = "chuk_tool_processor"
26
+
27
+
28
+ class OpenAIToolPlugin(ParserPlugin):
29
+ """
30
+ Understands structures like::
31
+
32
+ {
33
+ "tool_calls": [
34
+ {
35
+ "id": "call_abc",
36
+ "type": "function",
37
+ "function": {
38
+ "name": "weather",
39
+ "arguments": "{\"location\": \"New York\"}"
40
+ }
41
+ }
42
+ ]
43
+ }
44
+ """
45
+
46
+ async def try_parse(self, raw: str | Any) -> List[ToolCall]: # noqa: D401
47
+ # ------------------------------------------------------------------ #
48
+ # 1. Decode JSON when the input is a string
49
+ # ------------------------------------------------------------------ #
50
+ try:
51
+ data = json.loads(raw) if isinstance(raw, str) else raw
52
+ except json.JSONDecodeError:
53
+ logger.debug("openai_tool_plugin: input is not valid JSON")
54
+ return []
55
+
56
+ if not isinstance(data, dict) or "tool_calls" not in data:
57
+ return []
58
+
59
+ # ------------------------------------------------------------------ #
60
+ # 2. Build ToolCall objects
61
+ # ------------------------------------------------------------------ #
62
+ calls: List[ToolCall] = []
63
+ for entry in data["tool_calls"]:
64
+ fn = entry.get("function", {})
65
+ name = fn.get("name")
66
+ args = fn.get("arguments", {})
67
+
68
+ # Arguments may be double-encoded JSON
69
+ if isinstance(args, str):
70
+ try:
71
+ args = json.loads(args)
72
+ except json.JSONDecodeError:
73
+ args = {}
74
+
75
+ if not isinstance(name, str) or not name:
76
+ continue
77
+
78
+ try:
79
+ calls.append(
80
+ ToolCall(tool=name, arguments=args if isinstance(args, dict) else {})
81
+ )
82
+ except ValidationError:
83
+ logger.debug(
84
+ "openai_tool_plugin: validation error while building ToolCall for %s",
85
+ name,
86
+ )
87
+
88
+ return calls
@@ -1,45 +1,99 @@
1
- # chuk_tool_processor/plugins/xml_tool.py
2
- """XML tool-call parser plugin.
3
- Understands `<tool name="..." args='{"x":1}'/>` single-line constructs –
4
- format used by many examples and test-fixtures.
1
+ # chuk_tool_processor/plugins/parsers/xml_tool.py
5
2
  """
3
+ Async-native parser for single-line XML-style tool-call tags, e.g.
4
+
5
+ <tool name="translate" args="{\"text\": \"Hello\", \"target\": \"es\"}"/>
6
+
7
+ The *args* attribute may be
8
+
9
+ 1. A proper JSON object: args="{"x": 1}"
10
+ 2. A JSON-encoded string (most common): args="{\"x\": 1}"
11
+ 3. The empty string: args=""
12
+
13
+ All variants are normalised to a **dict** of arguments.
14
+ """
15
+
6
16
  from __future__ import annotations
7
17
 
8
18
  import json
9
19
  import re
10
20
  from typing import List
21
+
11
22
  from pydantic import ValidationError
12
23
 
13
- # imports
14
- from .base import ParserPlugin
24
+ from chuk_tool_processor.logging import get_logger
15
25
  from chuk_tool_processor.models.tool_call import ToolCall
26
+ from chuk_tool_processor.plugins.parsers.base import ParserPlugin
27
+
28
+ __all__: list[str] = ["XmlToolPlugin"]
29
+
30
+ logger = get_logger(__name__)
16
31
 
17
32
 
33
+ class PluginMeta:
34
+ """Optional descriptor that can be used by the plugin-discovery mechanism."""
35
+ name: str = "xml_tool_tag"
36
+ description: str = "Parses <tool …/> XML tags into ToolCall objects."
37
+ version: str = "1.0.0"
38
+ author: str = "chuk_tool_processor"
39
+
18
40
 
19
41
  class XmlToolPlugin(ParserPlugin):
20
- """Parse XML-like tool-call tags."""
42
+ """Convert `<tool …/>` tags into :class:`ToolCall` objects."""
21
43
 
22
44
  _TAG = re.compile(
23
45
  r"<tool\s+"
24
- r"name=(?P<q1>[\"\'])(?P<tool>.+?)(?P=q1)\s+"
25
- r"args=(?P<q2>[\"\'])(?P<args>.*?)(?P=q2)\s*/>"
46
+ r"name=(?P<q1>[\"'])(?P<tool>.+?)(?P=q1)\s+"
47
+ r"args=(?P<q2>[\"'])(?P<args>.*?)(?P=q2)\s*/>",
48
+ flags=re.IGNORECASE | re.DOTALL,
26
49
  )
27
50
 
28
- def try_parse(self, raw): # type: ignore[override]
29
- if not isinstance(raw, str): # XML form only exists in strings
51
+ # ------------------------------------------------------------------ #
52
+ async def try_parse(self, raw: str | object) -> List[ToolCall]: # noqa: D401
53
+ if not isinstance(raw, str):
30
54
  return []
31
55
 
32
56
  calls: List[ToolCall] = []
33
- for m in self._TAG.finditer(raw):
34
- tool_name = m.group("tool")
35
- raw_args = m.group("args")
36
- try:
37
- args = json.loads(raw_args) if raw_args else {}
38
- except json.JSONDecodeError:
39
- args = {}
57
+
58
+ for match in self._TAG.finditer(raw):
59
+ name = match.group("tool")
60
+ raw_args = match.group("args") or ""
61
+ args = self._decode_args(raw_args)
40
62
 
41
63
  try:
42
- calls.append(ToolCall(tool=tool_name, arguments=args))
64
+ calls.append(ToolCall(tool=name, arguments=args))
43
65
  except ValidationError:
44
- continue # skip malformed
66
+ logger.debug("xml_tool_plugin: validation error for <%s>", name)
67
+
45
68
  return calls
69
+
70
+ # ------------------------------------------------------------------ #
71
+ # Helper – robust JSON decode for the args attribute
72
+ # ------------------------------------------------------------------ #
73
+ @staticmethod
74
+ def _decode_args(raw_args: str) -> dict:
75
+ """Best-effort decoding of the *args* attribute to a dict."""
76
+ if not raw_args:
77
+ return {}
78
+
79
+ # 1️⃣ Try direct JSON
80
+ try:
81
+ parsed = json.loads(raw_args)
82
+ except json.JSONDecodeError:
83
+ parsed = None
84
+
85
+ # 2️⃣ If still None, the value might be a JSON-encoded string
86
+ if parsed is None:
87
+ try:
88
+ parsed = json.loads(raw_args.encode().decode("unicode_escape"))
89
+ except json.JSONDecodeError:
90
+ parsed = None
91
+
92
+ # 3️⃣ Last resort – naive unescaping of \" → "
93
+ if parsed is None:
94
+ try:
95
+ parsed = json.loads(raw_args.replace(r"\"", "\""))
96
+ except json.JSONDecodeError:
97
+ parsed = {}
98
+
99
+ return parsed if isinstance(parsed, dict) else {}
@@ -1,21 +1,60 @@
1
+ # chuk_tool_processor/registry/__init__.py
1
2
  """
2
- Tool registry package for managing and accessing tool implementations.
3
+ Async-native tool registry package for managing and accessing tool implementations.
3
4
  """
4
5
 
6
+ import asyncio
7
+ from typing import Optional
8
+
5
9
  from chuk_tool_processor.registry.interface import ToolRegistryInterface
6
- from chuk_tool_processor.registry.metadata import ToolMetadata
7
- from chuk_tool_processor.registry.provider import ToolRegistryProvider
8
- from chuk_tool_processor.registry.decorators import register_tool
10
+ from chuk_tool_processor.registry.metadata import ToolMetadata, StreamingToolMetadata
11
+ from chuk_tool_processor.registry.provider import ToolRegistryProvider, get_registry
12
+ from chuk_tool_processor.registry.decorators import register_tool, ensure_registrations, discover_decorated_tools
9
13
 
10
14
  # --------------------------------------------------------------------------- #
11
- # Expose the *singleton* registry that every part of the library should use
15
+ # The default_registry is now an async function instead of direct property access
12
16
  # --------------------------------------------------------------------------- #
13
- default_registry = ToolRegistryProvider.get_registry()
17
+ async def get_default_registry() -> ToolRegistryInterface:
18
+ """
19
+ Get the default registry instance.
20
+
21
+ This is a convenience function that calls ToolRegistryProvider.get_registry()
22
+
23
+ Returns:
24
+ The default tool registry
25
+ """
26
+ return await ToolRegistryProvider.get_registry()
14
27
 
15
28
  __all__ = [
16
29
  "ToolRegistryInterface",
17
30
  "ToolMetadata",
31
+ "StreamingToolMetadata",
18
32
  "ToolRegistryProvider",
19
33
  "register_tool",
20
- "default_registry",
34
+ "ensure_registrations",
35
+ "discover_decorated_tools",
36
+ "get_default_registry",
37
+ "get_registry",
21
38
  ]
39
+
40
+ # --------------------------------------------------------------------------- #
41
+ # Initialization helper that should be called at application startup
42
+ # --------------------------------------------------------------------------- #
43
+ async def initialize():
44
+ """
45
+ Initialize the registry system.
46
+
47
+ This function should be called during application startup to:
48
+ 1. Ensure the registry is created
49
+ 2. Register all tools decorated with @register_tool
50
+
51
+ Returns:
52
+ The initialized registry
53
+ """
54
+ # Initialize registry
55
+ registry = await get_default_registry()
56
+
57
+ # Process all pending tool registrations
58
+ await ensure_registrations()
59
+
60
+ return registry
@@ -1,11 +1,12 @@
1
1
  # chuk_tool_processor/registry/auto_register.py
2
2
  """
3
- Tiny auto-register helpers so you can do
3
+ Async auto-register helpers for registering functions and LangChain tools.
4
4
 
5
- register_fn_tool(my_function)
6
- register_langchain_tool(my_langchain_tool)
5
+ Usage:
6
+ await register_fn_tool(my_function)
7
+ await register_langchain_tool(my_langchain_tool)
7
8
 
8
- and they immediately show up in the global registry.
9
+ These tools will immediately show up in the global registry.
9
10
  """
10
11
 
11
12
  from __future__ import annotations
@@ -13,7 +14,7 @@ from __future__ import annotations
13
14
  import asyncio
14
15
  import inspect
15
16
  import types
16
- from typing import Callable, ForwardRef, Type, get_type_hints
17
+ from typing import Callable, ForwardRef, Type, get_type_hints, Any, Optional, Dict, Union
17
18
 
18
19
  import anyio
19
20
  from pydantic import BaseModel, create_model
@@ -23,7 +24,9 @@ try: # optional dependency
23
24
  except ModuleNotFoundError: # pragma: no cover
24
25
  BaseTool = None # noqa: N816 – keep the name for isinstance() checks
25
26
 
26
- from chuk_tool_processor.registry.decorators import register_tool
27
+ # registry
28
+ from .decorators import register_tool
29
+ from .provider import ToolRegistryProvider
27
30
 
28
31
 
29
32
  # ────────────────────────────────────────────────────────────────────────────
@@ -37,7 +40,7 @@ def _auto_schema(func: Callable) -> Type[BaseModel]:
37
40
 
38
41
  *Unknown* or *un-imported* annotations (common with third-party libs that
39
42
  use forward-refs without importing the target – e.g. ``uuid.UUID`` in
40
- LangChains `CallbackManagerForToolRun`) default to ``str`` instead of
43
+ LangChain's `CallbackManagerForToolRun`) default to ``str`` instead of
41
44
  crashing `get_type_hints()`.
42
45
  """
43
46
  try:
@@ -49,14 +52,14 @@ def _auto_schema(func: Callable) -> Type[BaseModel]:
49
52
  for param in inspect.signature(func).parameters.values():
50
53
  raw_hint = hints.get(param.name, param.annotation)
51
54
  # Default to ``str`` for ForwardRef / string annotations or if we
52
- # couldnt resolve the type.
55
+ # couldn't resolve the type.
53
56
  hint: type = (
54
57
  raw_hint
55
58
  if raw_hint not in (inspect._empty, None, str)
56
59
  and not isinstance(raw_hint, (str, ForwardRef))
57
60
  else str
58
61
  )
59
- fields[param.name] = (hint, ...) # “...” → required
62
+ fields[param.name] = (hint, ...) # "..." → required
60
63
 
61
64
  return create_model(f"{func.__name__.title()}Args", **fields) # type: ignore
62
65
 
@@ -66,25 +69,54 @@ def _auto_schema(func: Callable) -> Type[BaseModel]:
66
69
  # ────────────────────────────────────────────────────────────────────────────
67
70
 
68
71
 
69
- def register_fn_tool(
72
+ async def register_fn_tool(
70
73
  func: Callable,
71
74
  *,
72
75
  name: str | None = None,
73
76
  description: str | None = None,
77
+ namespace: str = "default",
74
78
  ) -> None:
75
- """Register a plain function as a tool – one line is all you need."""
76
-
79
+ """
80
+ Register a plain function as a tool asynchronously.
81
+
82
+ Args:
83
+ func: The function to register (can be sync or async)
84
+ name: Optional name for the tool (defaults to function name)
85
+ description: Optional description (defaults to function docstring)
86
+ namespace: Registry namespace (defaults to "default")
87
+ """
77
88
  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)
89
+ tool_name = name or func.__name__
90
+ tool_description = (description or func.__doc__ or "").strip()
91
+
92
+ # Create the tool wrapper class
82
93
  class _Tool: # noqa: D401, N801 – internal auto-wrapper
83
- async def _execute(self, **kwargs):
94
+ """Auto-generated tool wrapper for function."""
95
+
96
+ async def execute(self, **kwargs: Any) -> Any:
97
+ """Execute the wrapped function."""
84
98
  if inspect.iscoroutinefunction(func):
85
99
  return await func(**kwargs)
86
100
  # off-load blocking sync work
87
101
  return await anyio.to_thread.run_sync(func, **kwargs)
102
+
103
+ # Set the docstring
104
+ _Tool.__doc__ = tool_description
105
+
106
+ # Get the registry and register directly
107
+ registry = await ToolRegistryProvider.get_registry()
108
+ await registry.register_tool(
109
+ _Tool(),
110
+ name=tool_name,
111
+ namespace=namespace,
112
+ metadata={
113
+ "description": tool_description,
114
+ "is_async": True,
115
+ "argument_schema": schema.model_json_schema(),
116
+ "source": "function",
117
+ "source_name": func.__qualname__,
118
+ }
119
+ )
88
120
 
89
121
 
90
122
  # ────────────────────────────────────────────────────────────────────────────
@@ -92,18 +124,27 @@ def register_fn_tool(
92
124
  # ────────────────────────────────────────────────────────────────────────────
93
125
 
94
126
 
95
- def register_langchain_tool(
96
- tool,
127
+ async def register_langchain_tool(
128
+ tool: Any,
97
129
  *,
98
130
  name: str | None = None,
99
131
  description: str | None = None,
132
+ namespace: str = "default",
100
133
  ) -> None:
101
134
  """
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.
135
+ Register a **LangChain** `BaseTool` instance asynchronously.
136
+
137
+ Works with any object exposing `.run` / `.arun` methods.
138
+
139
+ Args:
140
+ tool: The LangChain tool to register
141
+ name: Optional name for the tool (defaults to tool.name)
142
+ description: Optional description (defaults to tool.description)
143
+ namespace: Registry namespace (defaults to "default")
144
+
145
+ Raises:
146
+ RuntimeError: If LangChain isn't installed
147
+ TypeError: If the object isn't a LangChain BaseTool
107
148
  """
108
149
  if BaseTool is None:
109
150
  raise RuntimeError(
@@ -117,9 +158,32 @@ def register_langchain_tool(
117
158
  f"{type(tool).__name__}"
118
159
  )
119
160
 
120
- fn = tool.arun if hasattr(tool, "arun") else tool.run # prefer async
121
- register_fn_tool(
161
+ # Prefer async implementation if available
162
+ fn = tool.arun if hasattr(tool, "arun") else tool.run
163
+
164
+ tool_name = name or tool.name or tool.__class__.__name__
165
+ tool_description = description or tool.description or (tool.__doc__ or "")
166
+
167
+ await register_fn_tool(
122
168
  fn,
123
- name=name or tool.name or tool.__class__.__name__,
124
- description=description or tool.description or (tool.__doc__ or ""),
169
+ name=tool_name,
170
+ description=tool_description,
171
+ namespace=namespace,
125
172
  )
173
+
174
+ # Update the metadata to include LangChain info
175
+ registry = await ToolRegistryProvider.get_registry()
176
+ metadata = await registry.get_metadata(tool_name, namespace)
177
+
178
+ if metadata:
179
+ updated_metadata = metadata.model_copy()
180
+ # Update source info
181
+ updated_metadata.tags.add("langchain")
182
+
183
+ # Re-register with updated metadata
184
+ await registry.register_tool(
185
+ await registry.get_tool(tool_name, namespace),
186
+ name=tool_name,
187
+ namespace=namespace,
188
+ metadata=updated_metadata.model_dump(),
189
+ )