chuk-tool-processor 0.1.5__py3-none-any.whl → 0.1.7__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.
- chuk_tool_processor/core/processor.py +345 -132
- chuk_tool_processor/execution/strategies/inprocess_strategy.py +512 -68
- chuk_tool_processor/execution/strategies/subprocess_strategy.py +523 -63
- chuk_tool_processor/execution/tool_executor.py +282 -24
- chuk_tool_processor/execution/wrappers/caching.py +465 -123
- chuk_tool_processor/execution/wrappers/rate_limiting.py +199 -86
- chuk_tool_processor/execution/wrappers/retry.py +133 -23
- chuk_tool_processor/logging/__init__.py +83 -10
- chuk_tool_processor/logging/context.py +218 -22
- chuk_tool_processor/logging/formatter.py +56 -13
- chuk_tool_processor/logging/helpers.py +91 -16
- chuk_tool_processor/logging/metrics.py +75 -6
- chuk_tool_processor/mcp/mcp_tool.py +80 -35
- chuk_tool_processor/mcp/register_mcp_tools.py +74 -56
- chuk_tool_processor/mcp/setup_mcp_sse.py +41 -36
- chuk_tool_processor/mcp/setup_mcp_stdio.py +39 -37
- chuk_tool_processor/mcp/stream_manager.py +28 -0
- chuk_tool_processor/models/execution_strategy.py +52 -3
- chuk_tool_processor/models/streaming_tool.py +110 -0
- chuk_tool_processor/models/tool_call.py +56 -4
- chuk_tool_processor/models/tool_result.py +115 -9
- chuk_tool_processor/models/validated_tool.py +15 -13
- chuk_tool_processor/plugins/discovery.py +115 -70
- chuk_tool_processor/plugins/parsers/base.py +13 -5
- chuk_tool_processor/plugins/parsers/{function_call_tool_plugin.py → function_call_tool.py} +39 -20
- chuk_tool_processor/plugins/parsers/json_tool.py +50 -0
- chuk_tool_processor/plugins/parsers/openai_tool.py +88 -0
- chuk_tool_processor/plugins/parsers/xml_tool.py +74 -20
- chuk_tool_processor/registry/__init__.py +46 -7
- chuk_tool_processor/registry/auto_register.py +92 -28
- chuk_tool_processor/registry/decorators.py +134 -11
- chuk_tool_processor/registry/interface.py +48 -14
- chuk_tool_processor/registry/metadata.py +52 -6
- chuk_tool_processor/registry/provider.py +75 -36
- chuk_tool_processor/registry/providers/__init__.py +49 -10
- chuk_tool_processor/registry/providers/memory.py +59 -48
- chuk_tool_processor/registry/tool_export.py +208 -39
- chuk_tool_processor/utils/validation.py +18 -13
- chuk_tool_processor-0.1.7.dist-info/METADATA +401 -0
- chuk_tool_processor-0.1.7.dist-info/RECORD +58 -0
- {chuk_tool_processor-0.1.5.dist-info → chuk_tool_processor-0.1.7.dist-info}/WHEEL +1 -1
- chuk_tool_processor/plugins/parsers/json_tool_plugin.py +0 -38
- chuk_tool_processor/plugins/parsers/openai_tool_plugin.py +0 -76
- chuk_tool_processor-0.1.5.dist-info/METADATA +0 -462
- chuk_tool_processor-0.1.5.dist-info/RECORD +0 -57
- {chuk_tool_processor-0.1.5.dist-info → chuk_tool_processor-0.1.7.dist-info}/top_level.txt +0 -0
|
@@ -1,121 +1,164 @@
|
|
|
1
1
|
# chuk_tool_processor/plugins/discovery.py
|
|
2
|
-
"""
|
|
2
|
+
"""Async-friendly plugin discovery & registry utilities for chuk_tool_processor."""
|
|
3
|
+
|
|
3
4
|
from __future__ import annotations
|
|
4
5
|
|
|
5
6
|
import importlib
|
|
6
7
|
import inspect
|
|
7
8
|
import logging
|
|
8
9
|
import pkgutil
|
|
10
|
+
from types import ModuleType
|
|
9
11
|
from typing import Any, Dict, List, Optional, Set, Type
|
|
10
12
|
|
|
13
|
+
from chuk_tool_processor.plugins.parsers.base import ParserPlugin
|
|
14
|
+
from chuk_tool_processor.models.execution_strategy import ExecutionStrategy
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"plugin_registry",
|
|
18
|
+
"PluginRegistry",
|
|
19
|
+
"PluginDiscovery",
|
|
20
|
+
"discover_default_plugins",
|
|
21
|
+
"discover_plugins",
|
|
22
|
+
"plugin",
|
|
23
|
+
]
|
|
24
|
+
|
|
11
25
|
logger = logging.getLogger(__name__)
|
|
12
26
|
|
|
13
27
|
|
|
28
|
+
# -----------------------------------------------------------------------------
|
|
29
|
+
# In-memory registry
|
|
30
|
+
# -----------------------------------------------------------------------------
|
|
14
31
|
class PluginRegistry:
|
|
15
|
-
"""
|
|
32
|
+
"""Thread-safe (GIL) in-memory registry keyed by *category → name*."""
|
|
16
33
|
|
|
17
|
-
def __init__(self) -> None:
|
|
34
|
+
def __init__(self) -> None:
|
|
35
|
+
# category → {name → object}
|
|
18
36
|
self._plugins: Dict[str, Dict[str, Any]] = {}
|
|
19
37
|
|
|
20
|
-
#
|
|
38
|
+
# --------------------------------------------------------------------- #
|
|
39
|
+
# Public API
|
|
40
|
+
# --------------------------------------------------------------------- #
|
|
21
41
|
def register_plugin(self, category: str, name: str, plugin: Any) -> None:
|
|
22
42
|
self._plugins.setdefault(category, {})[name] = plugin
|
|
23
43
|
logger.debug("Registered plugin %s.%s", category, name)
|
|
24
44
|
|
|
25
|
-
def get_plugin(self, category: str, name: str) -> Optional[Any]:
|
|
45
|
+
def get_plugin(self, category: str, name: str) -> Optional[Any]: # noqa: D401
|
|
26
46
|
return self._plugins.get(category, {}).get(name)
|
|
27
47
|
|
|
28
48
|
def list_plugins(self, category: str | None = None) -> Dict[str, List[str]]:
|
|
29
|
-
if category:
|
|
30
|
-
return {category:
|
|
31
|
-
return {cat:
|
|
49
|
+
if category is not None:
|
|
50
|
+
return {category: sorted(self._plugins.get(category, {}))}
|
|
51
|
+
return {cat: sorted(names) for cat, names in self._plugins.items()}
|
|
32
52
|
|
|
33
53
|
|
|
54
|
+
# -----------------------------------------------------------------------------
|
|
55
|
+
# Discovery
|
|
56
|
+
# -----------------------------------------------------------------------------
|
|
34
57
|
class PluginDiscovery:
|
|
35
|
-
"""
|
|
58
|
+
"""
|
|
59
|
+
Recursively scans *package_paths* for plugin classes and registers them.
|
|
36
60
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
self._seen: Set[str] = set()
|
|
61
|
+
* Parser plugins – concrete subclasses of :class:`ParserPlugin`
|
|
62
|
+
with an **async** ``try_parse`` coroutine.
|
|
40
63
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
from chuk_tool_processor.parsers.base import ParserPlugin as _PP # noqa: WPS433
|
|
44
|
-
except ModuleNotFoundError:
|
|
45
|
-
_PP = None
|
|
46
|
-
self.ParserPlugin = _PP
|
|
64
|
+
* Execution strategies – concrete subclasses of
|
|
65
|
+
:class:`ExecutionStrategy`.
|
|
47
66
|
|
|
48
|
-
|
|
49
|
-
|
|
67
|
+
* Explicitly-decorated plugins – classes tagged with ``@plugin(...)``.
|
|
68
|
+
"""
|
|
50
69
|
|
|
51
|
-
|
|
70
|
+
# ------------------------------------------------------------------ #
|
|
71
|
+
def __init__(self, registry: PluginRegistry) -> None:
|
|
72
|
+
self._registry = registry
|
|
73
|
+
self._seen_modules: Set[str] = set()
|
|
52
74
|
|
|
53
|
-
# ------------------------------------------------------------------
|
|
75
|
+
# ------------------------------------------------------------------ #
|
|
54
76
|
def discover_plugins(self, package_paths: List[str]) -> None:
|
|
55
|
-
|
|
56
|
-
|
|
77
|
+
"""Import every package in *package_paths* and walk its subtree."""
|
|
78
|
+
for pkg_path in package_paths:
|
|
79
|
+
self._walk(pkg_path)
|
|
57
80
|
|
|
58
|
-
# ------------------------------------------------------------------
|
|
81
|
+
# ------------------------------------------------------------------ #
|
|
82
|
+
# Internal helpers
|
|
83
|
+
# ------------------------------------------------------------------ #
|
|
59
84
|
def _walk(self, pkg_path: str) -> None:
|
|
60
85
|
try:
|
|
61
|
-
|
|
86
|
+
root_pkg = importlib.import_module(pkg_path)
|
|
62
87
|
except ImportError as exc: # pragma: no cover
|
|
63
88
|
logger.warning("Cannot import package %s: %s", pkg_path, exc)
|
|
64
89
|
return
|
|
65
90
|
|
|
66
|
-
|
|
67
|
-
|
|
91
|
+
self._inspect_module(root_pkg)
|
|
92
|
+
|
|
93
|
+
for _, mod_name, is_pkg in pkgutil.iter_modules(root_pkg.__path__, root_pkg.__name__ + "."):
|
|
94
|
+
if mod_name in self._seen_modules:
|
|
95
|
+
continue
|
|
96
|
+
self._seen_modules.add(mod_name)
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
mod = importlib.import_module(mod_name)
|
|
100
|
+
except ImportError as exc: # pragma: no cover
|
|
101
|
+
logger.debug("Cannot import module %s: %s", mod_name, exc)
|
|
68
102
|
continue
|
|
69
|
-
|
|
70
|
-
self._inspect_module(
|
|
103
|
+
|
|
104
|
+
self._inspect_module(mod)
|
|
105
|
+
|
|
71
106
|
if is_pkg:
|
|
72
107
|
self._walk(mod_name)
|
|
73
108
|
|
|
74
|
-
# ------------------------------------------------------------------
|
|
75
|
-
def _inspect_module(self,
|
|
76
|
-
try:
|
|
77
|
-
module = importlib.import_module(mod_name)
|
|
78
|
-
except ImportError as exc: # pragma: no cover
|
|
79
|
-
logger.warning("Cannot import module %s: %s", mod_name, exc)
|
|
80
|
-
return
|
|
81
|
-
|
|
109
|
+
# ------------------------------------------------------------------ #
|
|
110
|
+
def _inspect_module(self, module: ModuleType) -> None:
|
|
82
111
|
for attr in module.__dict__.values():
|
|
83
112
|
if inspect.isclass(attr):
|
|
84
113
|
self._maybe_register(attr)
|
|
85
114
|
|
|
86
|
-
# ------------------------------------------------------------------
|
|
115
|
+
# ------------------------------------------------------------------ #
|
|
87
116
|
def _maybe_register(self, cls: Type) -> None:
|
|
88
|
-
"""Register *cls* in all
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
looks_like_parser = callable(getattr(cls, "try_parse", None))
|
|
92
|
-
if looks_like_parser and not inspect.isabstract(cls):
|
|
93
|
-
# skip ABC base itself if available
|
|
94
|
-
if self.ParserPlugin and cls is self.ParserPlugin:
|
|
95
|
-
pass
|
|
96
|
-
else:
|
|
97
|
-
self.registry.register_plugin("parser", cls.__name__, cls())
|
|
98
|
-
|
|
99
|
-
# --------------- execution strategies -------------------------
|
|
100
|
-
if (
|
|
101
|
-
issubclass(cls, self.ExecutionStrategy)
|
|
102
|
-
and cls is not self.ExecutionStrategy
|
|
103
|
-
and not inspect.isabstract(cls)
|
|
104
|
-
):
|
|
105
|
-
self.registry.register_plugin("execution_strategy", cls.__name__, cls)
|
|
106
|
-
|
|
107
|
-
# --------------- explicit @plugin decorator -------------------
|
|
108
|
-
meta = getattr(cls, "_plugin_meta", None)
|
|
109
|
-
if meta and not inspect.isabstract(cls):
|
|
110
|
-
self.registry.register_plugin(meta.get("category", "unknown"), meta.get("name", cls.__name__), cls())
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
# ----------------------------------------------------------------------
|
|
114
|
-
# public decorator helper
|
|
115
|
-
# ----------------------------------------------------------------------
|
|
117
|
+
"""Register *cls* in all matching plugin categories."""
|
|
118
|
+
if inspect.isabstract(cls):
|
|
119
|
+
return
|
|
116
120
|
|
|
121
|
+
# ------------------- Parser plugins -------------------------
|
|
122
|
+
if issubclass(cls, ParserPlugin) and cls is not ParserPlugin:
|
|
123
|
+
if not inspect.iscoroutinefunction(getattr(cls, "try_parse", None)):
|
|
124
|
+
logger.warning("Skipping parser plugin %s: try_parse is not async", cls.__qualname__)
|
|
125
|
+
else:
|
|
126
|
+
try:
|
|
127
|
+
self._registry.register_plugin("parser", cls.__name__, cls())
|
|
128
|
+
except Exception as exc: # pragma: no cover
|
|
129
|
+
logger.warning("Cannot instantiate parser plugin %s: %s", cls.__qualname__, exc)
|
|
130
|
+
|
|
131
|
+
# ---------------- Execution strategies ---------------------
|
|
132
|
+
if issubclass(cls, ExecutionStrategy) and cls is not ExecutionStrategy:
|
|
133
|
+
self._registry.register_plugin("execution_strategy", cls.__name__, cls)
|
|
134
|
+
|
|
135
|
+
# ------------- Explicit @plugin decorator ------------------
|
|
136
|
+
meta: Optional[dict] = getattr(cls, "_plugin_meta", None)
|
|
137
|
+
if meta:
|
|
138
|
+
category = meta.get("category", "unknown")
|
|
139
|
+
name = meta.get("name", cls.__name__)
|
|
140
|
+
try:
|
|
141
|
+
plugin_obj: Any = cls() if callable(getattr(cls, "__init__", None)) else cls
|
|
142
|
+
self._registry.register_plugin(category, name, plugin_obj)
|
|
143
|
+
except Exception as exc: # pragma: no cover
|
|
144
|
+
logger.warning("Cannot instantiate decorated plugin %s: %s", cls.__qualname__, exc)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# -----------------------------------------------------------------------------
|
|
148
|
+
# Decorator helper
|
|
149
|
+
# -----------------------------------------------------------------------------
|
|
117
150
|
def plugin(category: str, name: str | None = None):
|
|
118
|
-
"""
|
|
151
|
+
"""
|
|
152
|
+
Decorator that marks a concrete class as a plugin for *category*.
|
|
153
|
+
|
|
154
|
+
Example
|
|
155
|
+
-------
|
|
156
|
+
```python
|
|
157
|
+
@plugin("transport", name="sse")
|
|
158
|
+
class MySSETransport:
|
|
159
|
+
...
|
|
160
|
+
```
|
|
161
|
+
"""
|
|
119
162
|
|
|
120
163
|
def decorator(cls):
|
|
121
164
|
cls._plugin_meta = {"category": category, "name": name or cls.__name__}
|
|
@@ -124,15 +167,17 @@ def plugin(category: str, name: str | None = None):
|
|
|
124
167
|
return decorator
|
|
125
168
|
|
|
126
169
|
|
|
127
|
-
#
|
|
170
|
+
# -----------------------------------------------------------------------------
|
|
128
171
|
# Singletons & convenience wrappers
|
|
129
|
-
#
|
|
172
|
+
# -----------------------------------------------------------------------------
|
|
130
173
|
plugin_registry = PluginRegistry()
|
|
131
174
|
|
|
132
175
|
|
|
133
176
|
def discover_default_plugins() -> None:
|
|
177
|
+
"""Discover plugins shipped inside *chuk_tool_processor.plugins*."""
|
|
134
178
|
PluginDiscovery(plugin_registry).discover_plugins(["chuk_tool_processor.plugins"])
|
|
135
179
|
|
|
136
180
|
|
|
137
181
|
def discover_plugins(package_paths: List[str]) -> None:
|
|
182
|
+
"""Discover plugins from arbitrary external *package_paths*."""
|
|
138
183
|
PluginDiscovery(plugin_registry).discover_plugins(package_paths)
|
|
@@ -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
|
-
|
|
16
|
+
Every parser plugin **must** implement the async ``try_parse`` coroutine.
|
|
10
17
|
|
|
11
|
-
The processor
|
|
12
|
-
|
|
13
|
-
|
|
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/
|
|
2
|
-
"""
|
|
3
|
-
|
|
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
|
-
|
|
20
|
-
logger = get_logger("chuk_tool_processor.plugins.function_call_tool")
|
|
18
|
+
logger = get_logger(__name__)
|
|
21
19
|
|
|
22
|
-
# balanced
|
|
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
|
|
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
|
-
#
|
|
60
|
+
# 2️⃣ Fallback path ─ scan for *nested* JSON objects inside a string
|
|
46
61
|
if not calls and isinstance(raw, str):
|
|
47
|
-
for
|
|
62
|
+
for match in _JSON_OBJECT.finditer(raw):
|
|
48
63
|
try:
|
|
49
|
-
sub = json.loads(
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
"""
|
|
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>[\"
|
|
25
|
-
r"args=(?P<q2>[\"
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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=
|
|
64
|
+
calls.append(ToolCall(tool=name, arguments=args))
|
|
43
65
|
except ValidationError:
|
|
44
|
-
|
|
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 {}
|