chuk-tool-processor 0.1.0__py3-none-any.whl → 0.1.1__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 +1 -1
- chuk_tool_processor/execution/strategies/inprocess_strategy.py +110 -148
- chuk_tool_processor/execution/strategies/subprocess_strategy.py +1 -1
- chuk_tool_processor/logging/__init__.py +35 -0
- chuk_tool_processor/logging/context.py +47 -0
- chuk_tool_processor/logging/formatter.py +55 -0
- chuk_tool_processor/logging/helpers.py +112 -0
- chuk_tool_processor/logging/metrics.py +59 -0
- chuk_tool_processor/models/execution_strategy.py +1 -1
- chuk_tool_processor/models/tool_export_mixin.py +29 -0
- chuk_tool_processor/models/validated_tool.py +155 -0
- chuk_tool_processor/plugins/discovery.py +105 -172
- chuk_tool_processor/plugins/parsers/__init__.py +1 -1
- chuk_tool_processor/plugins/parsers/base.py +18 -0
- chuk_tool_processor/plugins/parsers/function_call_tool_plugin.py +81 -0
- chuk_tool_processor/plugins/parsers/json_tool_plugin.py +38 -0
- chuk_tool_processor/plugins/parsers/openai_tool_plugin.py +76 -0
- chuk_tool_processor/plugins/parsers/xml_tool.py +28 -24
- chuk_tool_processor/registry/__init__.py +11 -10
- chuk_tool_processor/registry/auto_register.py +125 -0
- chuk_tool_processor/registry/provider.py +84 -29
- chuk_tool_processor/registry/providers/memory.py +77 -112
- chuk_tool_processor/registry/tool_export.py +76 -0
- chuk_tool_processor/utils/validation.py +106 -177
- {chuk_tool_processor-0.1.0.dist-info → chuk_tool_processor-0.1.1.dist-info}/METADATA +5 -2
- chuk_tool_processor-0.1.1.dist-info/RECORD +47 -0
- chuk_tool_processor/plugins/parsers/function_call_tool.py +0 -105
- chuk_tool_processor/plugins/parsers/json_tool.py +0 -17
- chuk_tool_processor/utils/logging.py +0 -260
- chuk_tool_processor-0.1.0.dist-info/RECORD +0 -37
- {chuk_tool_processor-0.1.0.dist-info → chuk_tool_processor-0.1.1.dist-info}/WHEEL +0 -0
- {chuk_tool_processor-0.1.0.dist-info → chuk_tool_processor-0.1.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# chuk_tool_processor/models/tool_export_mix_in.py
|
|
2
|
+
from typing import Dict
|
|
3
|
+
|
|
4
|
+
class ToolExportMixin:
|
|
5
|
+
"""Mixin that lets any ValidatedTool advertise its schema."""
|
|
6
|
+
|
|
7
|
+
@classmethod
|
|
8
|
+
def to_openai(cls) -> Dict:
|
|
9
|
+
schema = cls.Arguments.model_json_schema()
|
|
10
|
+
return {
|
|
11
|
+
"type": "function",
|
|
12
|
+
"function": {
|
|
13
|
+
"name": cls.__name__.removesuffix("Tool").lower(), # or keep explicit name
|
|
14
|
+
"description": (cls.__doc__ or "").strip(),
|
|
15
|
+
"parameters": schema,
|
|
16
|
+
},
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def to_json_schema(cls) -> Dict:
|
|
21
|
+
return cls.Arguments.model_json_schema()
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def to_xml(cls) -> str:
|
|
25
|
+
"""Very small helper so existing XML-based parsers still work."""
|
|
26
|
+
name = cls.__name__.removesuffix("Tool").lower()
|
|
27
|
+
params = cls.Arguments.model_json_schema()["properties"]
|
|
28
|
+
args = ", ".join(params)
|
|
29
|
+
return f"<tool name=\"{name}\" args=\"{{{args}}}\"/>"
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# chuk_tool_processor/models/validated_tool.py
|
|
2
|
+
"""
|
|
3
|
+
Self-contained base-class for *declarative* tools.
|
|
4
|
+
|
|
5
|
+
Subclass it like so:
|
|
6
|
+
|
|
7
|
+
class Add(ValidatedTool):
|
|
8
|
+
class Arguments(BaseModel):
|
|
9
|
+
x: int
|
|
10
|
+
y: int
|
|
11
|
+
|
|
12
|
+
class Result(BaseModel):
|
|
13
|
+
sum: int
|
|
14
|
+
|
|
15
|
+
def _execute(self, *, x: int, y: int) -> Result:
|
|
16
|
+
return self.Result(sum=x + y)
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import html
|
|
21
|
+
import inspect
|
|
22
|
+
import json
|
|
23
|
+
from typing import Any, Dict, TypeVar, Callable
|
|
24
|
+
|
|
25
|
+
from pydantic import BaseModel, ValidationError
|
|
26
|
+
|
|
27
|
+
from chuk_tool_processor.core.exceptions import ToolValidationError
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"ValidatedTool",
|
|
31
|
+
"with_validation",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
T_Validated = TypeVar("T_Validated", bound="ValidatedTool")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# --------------------------------------------------------------------------- #
|
|
38
|
+
# Helper mix-in – serialise a *class* into assorted formats
|
|
39
|
+
# --------------------------------------------------------------------------- #
|
|
40
|
+
class _ExportMixin:
|
|
41
|
+
"""Static helpers that expose a tool class in other specs."""
|
|
42
|
+
|
|
43
|
+
# ------------------------------------------------------------------ #
|
|
44
|
+
# OpenAI Chat-Completions `tools=[…]`
|
|
45
|
+
# ------------------------------------------------------------------ #
|
|
46
|
+
@classmethod
|
|
47
|
+
def to_openai(
|
|
48
|
+
cls: type[T_Validated],
|
|
49
|
+
*,
|
|
50
|
+
registry_name: str | None = None,
|
|
51
|
+
) -> Dict[str, Any]:
|
|
52
|
+
"""
|
|
53
|
+
Build the structure expected by `tools=[…]`.
|
|
54
|
+
|
|
55
|
+
Parameters
|
|
56
|
+
----------
|
|
57
|
+
registry_name
|
|
58
|
+
When the registry has stored the tool under a *different* key
|
|
59
|
+
(e.g. ``"weather"`` vs class ``WeatherTool``) pass that key so
|
|
60
|
+
`function.name` and later look-ups line up.
|
|
61
|
+
"""
|
|
62
|
+
fn_name = registry_name or cls.__name__
|
|
63
|
+
description = (cls.__doc__ or f"{fn_name} tool").strip()
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
"type": "function",
|
|
67
|
+
"function": {
|
|
68
|
+
"name": fn_name,
|
|
69
|
+
"description": description,
|
|
70
|
+
"parameters": cls.Arguments.model_json_schema(), # type: ignore[attr-defined]
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# ------------------------------------------------------------------ #
|
|
75
|
+
# Plain JSON schema (arguments only)
|
|
76
|
+
# ------------------------------------------------------------------ #
|
|
77
|
+
@classmethod
|
|
78
|
+
def to_json_schema(cls: type[T_Validated]) -> Dict[str, Any]:
|
|
79
|
+
return cls.Arguments.model_json_schema() # type: ignore[attr-defined]
|
|
80
|
+
|
|
81
|
+
# ------------------------------------------------------------------ #
|
|
82
|
+
# Tiny XML tag – handy for unit-tests / demos
|
|
83
|
+
# ------------------------------------------------------------------ #
|
|
84
|
+
@classmethod
|
|
85
|
+
def to_xml_tag(cls: type[T_Validated], **arguments: Any) -> str:
|
|
86
|
+
return (
|
|
87
|
+
f'<tool name="{html.escape(cls.__name__)}" '
|
|
88
|
+
f"args='{html.escape(json.dumps(arguments))}'/>"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# --------------------------------------------------------------------------- #
|
|
93
|
+
# The public validated base-class
|
|
94
|
+
# --------------------------------------------------------------------------- #
|
|
95
|
+
class ValidatedTool(_ExportMixin, BaseModel):
|
|
96
|
+
"""Pydantic-validated base for new tools."""
|
|
97
|
+
|
|
98
|
+
# ------------------------------------------------------------------ #
|
|
99
|
+
# Inner models – override in subclasses
|
|
100
|
+
# ------------------------------------------------------------------ #
|
|
101
|
+
class Arguments(BaseModel): # noqa: D401 – acts as a namespace
|
|
102
|
+
"""Input model"""
|
|
103
|
+
|
|
104
|
+
class Result(BaseModel): # noqa: D401
|
|
105
|
+
"""Output model"""
|
|
106
|
+
|
|
107
|
+
# ------------------------------------------------------------------ #
|
|
108
|
+
# Public entry-point called by the processor
|
|
109
|
+
# ------------------------------------------------------------------ #
|
|
110
|
+
def execute(self: T_Validated, **kwargs: Any) -> BaseModel:
|
|
111
|
+
"""Validate *kwargs*, run `_execute`, validate the result."""
|
|
112
|
+
try:
|
|
113
|
+
args = self.Arguments(**kwargs) # type: ignore[arg-type]
|
|
114
|
+
res = self._execute(**args.model_dump()) # type: ignore[arg-type]
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
res
|
|
118
|
+
if isinstance(res, self.Result)
|
|
119
|
+
else self.Result(**(res if isinstance(res, dict) else {"value": res}))
|
|
120
|
+
)
|
|
121
|
+
except ValidationError as exc:
|
|
122
|
+
raise ToolValidationError(self.__class__.__name__, exc.errors()) from exc
|
|
123
|
+
|
|
124
|
+
# ------------------------------------------------------------------ #
|
|
125
|
+
# Sub-classes must implement this
|
|
126
|
+
# ------------------------------------------------------------------ #
|
|
127
|
+
def _execute(self, **_kwargs: Any): # noqa: D401 – expected override
|
|
128
|
+
raise NotImplementedError("Tool must implement _execute()")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# --------------------------------------------------------------------------- #
|
|
132
|
+
# Decorator to retrofit validation onto classic “imperative” tools
|
|
133
|
+
# --------------------------------------------------------------------------- #
|
|
134
|
+
def with_validation(cls): # noqa: D401 – factory
|
|
135
|
+
"""
|
|
136
|
+
Decorator that wraps an existing ``execute`` method with:
|
|
137
|
+
|
|
138
|
+
* argument validation (based on type hints)
|
|
139
|
+
* result validation (based on return annotation)
|
|
140
|
+
"""
|
|
141
|
+
from chuk_tool_processor.utils.validation import (
|
|
142
|
+
validate_arguments,
|
|
143
|
+
validate_result,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
original: Callable[..., Any] = cls.execute # type: ignore[attr-defined]
|
|
147
|
+
|
|
148
|
+
def _wrapper(self, **kwargs): # type: ignore[override]
|
|
149
|
+
tool_name = cls.__name__
|
|
150
|
+
validated = validate_arguments(tool_name, original, kwargs)
|
|
151
|
+
result = original(self, **validated)
|
|
152
|
+
return validate_result(tool_name, original, result)
|
|
153
|
+
|
|
154
|
+
cls.execute = _wrapper # type: ignore[assignment]
|
|
155
|
+
return cls
|
|
@@ -1,205 +1,138 @@
|
|
|
1
1
|
# chuk_tool_processor/plugins/discovery.py
|
|
2
|
+
"""Plugin discovery & registry utilities for chuk_tool_processor"""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
2
5
|
import importlib
|
|
3
6
|
import inspect
|
|
4
|
-
import pkgutil
|
|
5
|
-
import sys
|
|
6
|
-
from typing import Dict, List, Optional, Set, Type, Any
|
|
7
7
|
import logging
|
|
8
|
+
import pkgutil
|
|
9
|
+
from typing import Any, Dict, List, Optional, Set, Type
|
|
8
10
|
|
|
9
11
|
logger = logging.getLogger(__name__)
|
|
10
12
|
|
|
11
13
|
|
|
12
14
|
class PluginRegistry:
|
|
13
|
-
"""
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def __init__(self):
|
|
15
|
+
"""In‑memory registry keyed by *category → name*."""
|
|
16
|
+
|
|
17
|
+
def __init__(self) -> None: # no side‑effects in import time
|
|
17
18
|
self._plugins: Dict[str, Dict[str, Any]] = {}
|
|
18
|
-
|
|
19
|
+
|
|
20
|
+
# ------------------------------------------------------------------
|
|
19
21
|
def register_plugin(self, category: str, name: str, plugin: Any) -> None:
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
Args:
|
|
24
|
-
category: Plugin category (e.g., "parser", "executor").
|
|
25
|
-
name: Plugin name.
|
|
26
|
-
plugin: Plugin implementation.
|
|
27
|
-
"""
|
|
28
|
-
# Ensure category exists
|
|
29
|
-
if category not in self._plugins:
|
|
30
|
-
self._plugins[category] = {}
|
|
31
|
-
|
|
32
|
-
# Register plugin
|
|
33
|
-
self._plugins[category][name] = plugin
|
|
34
|
-
logger.debug(f"Registered plugin: {category}.{name}")
|
|
35
|
-
|
|
22
|
+
self._plugins.setdefault(category, {})[name] = plugin
|
|
23
|
+
logger.debug("Registered plugin %s.%s", category, name)
|
|
24
|
+
|
|
36
25
|
def get_plugin(self, category: str, name: str) -> Optional[Any]:
|
|
37
|
-
"""
|
|
38
|
-
Get a plugin from the registry.
|
|
39
|
-
|
|
40
|
-
Args:
|
|
41
|
-
category: Plugin category.
|
|
42
|
-
name: Plugin name.
|
|
43
|
-
|
|
44
|
-
Returns:
|
|
45
|
-
Plugin implementation or None if not found.
|
|
46
|
-
"""
|
|
47
26
|
return self._plugins.get(category, {}).get(name)
|
|
48
|
-
|
|
49
|
-
def list_plugins(self, category:
|
|
50
|
-
"""
|
|
51
|
-
List registered plugins.
|
|
52
|
-
|
|
53
|
-
Args:
|
|
54
|
-
category: Optional category filter.
|
|
55
|
-
|
|
56
|
-
Returns:
|
|
57
|
-
Dict mapping categories to lists of plugin names.
|
|
58
|
-
"""
|
|
27
|
+
|
|
28
|
+
def list_plugins(self, category: str | None = None) -> Dict[str, List[str]]:
|
|
59
29
|
if category:
|
|
60
|
-
return {category: list(self._plugins.get(category, {})
|
|
61
|
-
|
|
62
|
-
return {cat: list(plugins.keys()) for cat, plugins in self._plugins.items()}
|
|
30
|
+
return {category: list(self._plugins.get(category, {}))}
|
|
31
|
+
return {cat: list(names) for cat, names in self._plugins.items()}
|
|
63
32
|
|
|
64
33
|
|
|
65
34
|
class PluginDiscovery:
|
|
66
|
-
"""
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def __init__(self, registry: PluginRegistry):
|
|
70
|
-
"""
|
|
71
|
-
Initialize the plugin discovery.
|
|
72
|
-
|
|
73
|
-
Args:
|
|
74
|
-
registry: Plugin registry to register discovered plugins.
|
|
75
|
-
"""
|
|
35
|
+
"""Recursively scans packages for plugin classes and registers them."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, registry: PluginRegistry) -> None:
|
|
76
38
|
self.registry = registry
|
|
77
|
-
self.
|
|
78
|
-
|
|
39
|
+
self._seen: Set[str] = set()
|
|
40
|
+
|
|
41
|
+
# optional parser subsystem
|
|
42
|
+
try:
|
|
43
|
+
from chuk_tool_processor.parsers.base import ParserPlugin as _PP # noqa: WPS433
|
|
44
|
+
except ModuleNotFoundError:
|
|
45
|
+
_PP = None
|
|
46
|
+
self.ParserPlugin = _PP
|
|
47
|
+
|
|
48
|
+
# ExecutionStrategy always present inside core models
|
|
49
|
+
from chuk_tool_processor.models.execution_strategy import ExecutionStrategy # noqa: WPS433
|
|
50
|
+
|
|
51
|
+
self.ExecutionStrategy = ExecutionStrategy
|
|
52
|
+
|
|
53
|
+
# ------------------------------------------------------------------
|
|
79
54
|
def discover_plugins(self, package_paths: List[str]) -> None:
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
"""
|
|
86
|
-
for package_path in package_paths:
|
|
87
|
-
self._discover_in_package(package_path)
|
|
88
|
-
|
|
89
|
-
def _discover_in_package(self, package_path: str) -> None:
|
|
90
|
-
"""
|
|
91
|
-
Discover plugins in a single package.
|
|
92
|
-
|
|
93
|
-
Args:
|
|
94
|
-
package_path: Package path to search.
|
|
95
|
-
"""
|
|
55
|
+
for pkg in package_paths:
|
|
56
|
+
self._walk(pkg)
|
|
57
|
+
|
|
58
|
+
# ------------------------------------------------------------------
|
|
59
|
+
def _walk(self, pkg_path: str) -> None:
|
|
96
60
|
try:
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if is_pkg:
|
|
113
|
-
self._discover_in_package(name)
|
|
114
|
-
|
|
115
|
-
except ImportError as e:
|
|
116
|
-
logger.warning(f"Failed to import package {package_path}: {e}")
|
|
117
|
-
|
|
118
|
-
def _process_module(self, module_name: str) -> None:
|
|
119
|
-
"""
|
|
120
|
-
Process a module for plugins.
|
|
121
|
-
|
|
122
|
-
Args:
|
|
123
|
-
module_name: Module name to process.
|
|
124
|
-
"""
|
|
61
|
+
pkg = importlib.import_module(pkg_path)
|
|
62
|
+
except ImportError as exc: # pragma: no cover
|
|
63
|
+
logger.warning("Cannot import package %s: %s", pkg_path, exc)
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
for _, mod_name, is_pkg in pkgutil.iter_modules(pkg.__path__, pkg.__name__ + "."):
|
|
67
|
+
if mod_name in self._seen:
|
|
68
|
+
continue
|
|
69
|
+
self._seen.add(mod_name)
|
|
70
|
+
self._inspect_module(mod_name)
|
|
71
|
+
if is_pkg:
|
|
72
|
+
self._walk(mod_name)
|
|
73
|
+
|
|
74
|
+
# ------------------------------------------------------------------
|
|
75
|
+
def _inspect_module(self, mod_name: str) -> None:
|
|
125
76
|
try:
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
# Check if it's an execution strategy
|
|
155
|
-
if "ExecutionStrategy" in [base.__name__ for base in cls.__mro__]:
|
|
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
|
+
|
|
82
|
+
for attr in module.__dict__.values():
|
|
83
|
+
if inspect.isclass(attr):
|
|
84
|
+
self._maybe_register(attr)
|
|
85
|
+
|
|
86
|
+
# ------------------------------------------------------------------
|
|
87
|
+
def _maybe_register(self, cls: Type) -> None:
|
|
88
|
+
"""Register *cls* in all relevant plugin categories."""
|
|
89
|
+
|
|
90
|
+
# ---------------- parser plugins ------------------------------
|
|
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
|
+
):
|
|
156
105
|
self.registry.register_plugin("execution_strategy", cls.__name__, cls)
|
|
157
|
-
|
|
158
|
-
#
|
|
159
|
-
|
|
160
|
-
|
|
106
|
+
|
|
107
|
+
# --------------- explicit @plugin decorator -------------------
|
|
108
|
+
meta = getattr(cls, "_plugin_meta", None)
|
|
109
|
+
if meta and not inspect.isabstract(cls):
|
|
161
110
|
self.registry.register_plugin(meta.get("category", "unknown"), meta.get("name", cls.__name__), cls())
|
|
162
111
|
|
|
163
112
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
def try_parse(self, raw: str):
|
|
172
|
-
...
|
|
173
|
-
"""
|
|
113
|
+
# ----------------------------------------------------------------------
|
|
114
|
+
# public decorator helper
|
|
115
|
+
# ----------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
def plugin(category: str, name: str | None = None):
|
|
118
|
+
"""Decorator to mark a class as a plugin for explicit registration."""
|
|
119
|
+
|
|
174
120
|
def decorator(cls):
|
|
175
|
-
cls._plugin_meta = {
|
|
176
|
-
"category": category,
|
|
177
|
-
"name": name or cls.__name__
|
|
178
|
-
}
|
|
121
|
+
cls._plugin_meta = {"category": category, "name": name or cls.__name__}
|
|
179
122
|
return cls
|
|
123
|
+
|
|
180
124
|
return decorator
|
|
181
125
|
|
|
182
126
|
|
|
183
|
-
#
|
|
127
|
+
# ----------------------------------------------------------------------
|
|
128
|
+
# Singletons & convenience wrappers
|
|
129
|
+
# ----------------------------------------------------------------------
|
|
184
130
|
plugin_registry = PluginRegistry()
|
|
185
131
|
|
|
186
132
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
discovery.discover_plugins(["chuk_tool_processor.plugins"])
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
# Function to discover plugins in custom packages
|
|
197
|
-
def discover_plugins(package_paths: List[str]):
|
|
198
|
-
"""
|
|
199
|
-
Discover plugins in custom packages.
|
|
200
|
-
|
|
201
|
-
Args:
|
|
202
|
-
package_paths: List of package paths to search for plugins.
|
|
203
|
-
"""
|
|
204
|
-
discovery = PluginDiscovery(plugin_registry)
|
|
205
|
-
discovery.discover_plugins(package_paths)
|
|
133
|
+
def discover_default_plugins() -> None:
|
|
134
|
+
PluginDiscovery(plugin_registry).discover_plugins(["chuk_tool_processor.plugins"])
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def discover_plugins(package_paths: List[str]) -> None:
|
|
138
|
+
PluginDiscovery(plugin_registry).discover_plugins(package_paths)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
# chuk_tool_processor/plugins/
|
|
1
|
+
# chuk_tool_processor/plugins/parsers/__init__.py
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# chuk_tool_processor/parsers/base.py
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from typing import List
|
|
4
|
+
from chuk_tool_processor.models.tool_call import ToolCall
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ParserPlugin(ABC):
|
|
8
|
+
"""
|
|
9
|
+
Minimal interface every parser plug-in must implement.
|
|
10
|
+
|
|
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.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def try_parse(self, raw: str) -> List[ToolCall]:
|
|
18
|
+
...
|
|
@@ -0,0 +1,81 @@
|
|
|
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
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any, Dict, List
|
|
12
|
+
from pydantic import ValidationError
|
|
13
|
+
|
|
14
|
+
# imports
|
|
15
|
+
from .base import ParserPlugin
|
|
16
|
+
from chuk_tool_processor.models.tool_call import ToolCall
|
|
17
|
+
from chuk_tool_processor.logging import get_logger
|
|
18
|
+
|
|
19
|
+
# logger
|
|
20
|
+
logger = get_logger("chuk_tool_processor.plugins.function_call_tool")
|
|
21
|
+
|
|
22
|
+
# balanced‐brace JSON object regex – one level only (good enough for payloads)
|
|
23
|
+
_JSON_OBJECT = re.compile(r"\{(?:[^{}]|(?:\{[^{}]*\}))*\}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FunctionCallPlugin(ParserPlugin):
|
|
27
|
+
"""Parse OpenAI-style **single** ``function_call`` objects."""
|
|
28
|
+
|
|
29
|
+
def try_parse(self, raw: str | Dict[str, Any]) -> List[ToolCall]:
|
|
30
|
+
payload: Dict[str, Any] | None
|
|
31
|
+
if isinstance(raw, dict):
|
|
32
|
+
payload = raw
|
|
33
|
+
else:
|
|
34
|
+
try:
|
|
35
|
+
payload = json.loads(raw)
|
|
36
|
+
except json.JSONDecodeError:
|
|
37
|
+
payload = None
|
|
38
|
+
|
|
39
|
+
calls: List[ToolCall] = []
|
|
40
|
+
|
|
41
|
+
# primary path -----------------------------------------------------
|
|
42
|
+
if isinstance(payload, dict):
|
|
43
|
+
calls.extend(self._extract_from_payload(payload))
|
|
44
|
+
|
|
45
|
+
# fallback – scan raw text for nested JSON blocks ------------------
|
|
46
|
+
if not calls and isinstance(raw, str):
|
|
47
|
+
for m in _JSON_OBJECT.finditer(raw):
|
|
48
|
+
try:
|
|
49
|
+
sub = json.loads(m.group(0))
|
|
50
|
+
except json.JSONDecodeError:
|
|
51
|
+
continue
|
|
52
|
+
calls.extend(self._extract_from_payload(sub))
|
|
53
|
+
|
|
54
|
+
return calls
|
|
55
|
+
|
|
56
|
+
# ------------------------------------------------------------------
|
|
57
|
+
def _extract_from_payload(self, payload: Dict[str, Any]) -> List[ToolCall]:
|
|
58
|
+
fc = payload.get("function_call")
|
|
59
|
+
if not isinstance(fc, dict):
|
|
60
|
+
return []
|
|
61
|
+
|
|
62
|
+
name = fc.get("name")
|
|
63
|
+
args = fc.get("arguments", {})
|
|
64
|
+
|
|
65
|
+
# arguments may be JSON‑encoded string or anything else
|
|
66
|
+
if isinstance(args, str):
|
|
67
|
+
try:
|
|
68
|
+
args = json.loads(args)
|
|
69
|
+
except json.JSONDecodeError:
|
|
70
|
+
args = {}
|
|
71
|
+
if not isinstance(args, dict):
|
|
72
|
+
args = {}
|
|
73
|
+
|
|
74
|
+
if not isinstance(name, str) or not name:
|
|
75
|
+
return []
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
return [ToolCall(tool=name, arguments=args)]
|
|
79
|
+
except ValidationError:
|
|
80
|
+
logger.debug("Validation error while building ToolCall for %s", name)
|
|
81
|
+
return []
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# chuk_tool_processor/parsers/json_tool.py
|
|
2
|
+
"""JSON *tool_calls* parser plugin (drop-in).
|
|
3
|
+
|
|
4
|
+
Accepts raw‐string or dict input where the top-level object includes a
|
|
5
|
+
``tool_calls`` array – an early OpenAI Chat Completions schema.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any, List
|
|
11
|
+
from pydantic import ValidationError
|
|
12
|
+
|
|
13
|
+
# imports
|
|
14
|
+
from .base import ParserPlugin
|
|
15
|
+
from chuk_tool_processor.models.tool_call import ToolCall
|
|
16
|
+
|
|
17
|
+
class JsonToolPlugin(ParserPlugin):
|
|
18
|
+
"""Extracts ``tool_calls`` array from a JSON response."""
|
|
19
|
+
|
|
20
|
+
def try_parse(self, raw: str | Any) -> List[ToolCall]:
|
|
21
|
+
try:
|
|
22
|
+
data = json.loads(raw) if isinstance(raw, str) else raw
|
|
23
|
+
except json.JSONDecodeError:
|
|
24
|
+
return []
|
|
25
|
+
|
|
26
|
+
if not isinstance(data, dict):
|
|
27
|
+
return []
|
|
28
|
+
|
|
29
|
+
calls = data.get("tool_calls", [])
|
|
30
|
+
out: List[ToolCall] = []
|
|
31
|
+
|
|
32
|
+
for c in calls:
|
|
33
|
+
try:
|
|
34
|
+
out.append(ToolCall(**c))
|
|
35
|
+
except ValidationError:
|
|
36
|
+
continue
|
|
37
|
+
return out
|
|
38
|
+
|