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.

Files changed (32) hide show
  1. chuk_tool_processor/core/processor.py +1 -1
  2. chuk_tool_processor/execution/strategies/inprocess_strategy.py +110 -148
  3. chuk_tool_processor/execution/strategies/subprocess_strategy.py +1 -1
  4. chuk_tool_processor/logging/__init__.py +35 -0
  5. chuk_tool_processor/logging/context.py +47 -0
  6. chuk_tool_processor/logging/formatter.py +55 -0
  7. chuk_tool_processor/logging/helpers.py +112 -0
  8. chuk_tool_processor/logging/metrics.py +59 -0
  9. chuk_tool_processor/models/execution_strategy.py +1 -1
  10. chuk_tool_processor/models/tool_export_mixin.py +29 -0
  11. chuk_tool_processor/models/validated_tool.py +155 -0
  12. chuk_tool_processor/plugins/discovery.py +105 -172
  13. chuk_tool_processor/plugins/parsers/__init__.py +1 -1
  14. chuk_tool_processor/plugins/parsers/base.py +18 -0
  15. chuk_tool_processor/plugins/parsers/function_call_tool_plugin.py +81 -0
  16. chuk_tool_processor/plugins/parsers/json_tool_plugin.py +38 -0
  17. chuk_tool_processor/plugins/parsers/openai_tool_plugin.py +76 -0
  18. chuk_tool_processor/plugins/parsers/xml_tool.py +28 -24
  19. chuk_tool_processor/registry/__init__.py +11 -10
  20. chuk_tool_processor/registry/auto_register.py +125 -0
  21. chuk_tool_processor/registry/provider.py +84 -29
  22. chuk_tool_processor/registry/providers/memory.py +77 -112
  23. chuk_tool_processor/registry/tool_export.py +76 -0
  24. chuk_tool_processor/utils/validation.py +106 -177
  25. {chuk_tool_processor-0.1.0.dist-info → chuk_tool_processor-0.1.1.dist-info}/METADATA +5 -2
  26. chuk_tool_processor-0.1.1.dist-info/RECORD +47 -0
  27. chuk_tool_processor/plugins/parsers/function_call_tool.py +0 -105
  28. chuk_tool_processor/plugins/parsers/json_tool.py +0 -17
  29. chuk_tool_processor/utils/logging.py +0 -260
  30. chuk_tool_processor-0.1.0.dist-info/RECORD +0 -37
  31. {chuk_tool_processor-0.1.0.dist-info → chuk_tool_processor-0.1.1.dist-info}/WHEEL +0 -0
  32. {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
- Registry for discovered plugins.
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
- Register a plugin in the registry.
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: Optional[str] = None) -> Dict[str, List[str]]:
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, {}).keys())}
61
- else:
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
- Discovers and loads plugins from specified packages.
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._discovered_modules: Set[str] = set()
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
- Discover plugins in the specified packages.
82
-
83
- Args:
84
- package_paths: List of package paths to search for plugins.
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
- # Import the package
98
- package = importlib.import_module(package_path)
99
-
100
- # Walk through package modules
101
- for _, name, is_pkg in pkgutil.iter_modules(package.__path__, package.__name__ + "."):
102
- # Skip if already processed
103
- if name in self._discovered_modules:
104
- continue
105
-
106
- self._discovered_modules.add(name)
107
-
108
- # Process module
109
- self._process_module(name)
110
-
111
- # Recurse into subpackages
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
- # Import the module
127
- module = importlib.import_module(module_name)
128
-
129
- # Find all classes in the module
130
- for attr_name in dir(module):
131
- attr = getattr(module, attr_name)
132
-
133
- # Skip non-classes
134
- if not inspect.isclass(attr):
135
- continue
136
-
137
- # Check if it's a plugin
138
- self._register_if_plugin(attr)
139
-
140
- except ImportError as e:
141
- logger.warning(f"Failed to import module {module_name}: {e}")
142
-
143
- def _register_if_plugin(self, cls: Type) -> None:
144
- """
145
- Register a class if it's a plugin.
146
-
147
- Args:
148
- cls: Class to check.
149
- """
150
- # Check if it's a parser plugin
151
- if hasattr(cls, "try_parse") and callable(getattr(cls, "try_parse")):
152
- self.registry.register_plugin("parser", cls.__name__, cls())
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
- # Check if it has plugin metadata
159
- if hasattr(cls, "_plugin_meta"):
160
- meta = getattr(cls, "_plugin_meta")
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
- def plugin(category: str, name: Optional[str] = None):
165
- """
166
- Decorator to mark a class as a plugin.
167
-
168
- Example:
169
- @plugin(category="parser", name="custom_format")
170
- class CustomFormatParser:
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
- # Initialize the global plugin registry
127
+ # ----------------------------------------------------------------------
128
+ # Singletons & convenience wrappers
129
+ # ----------------------------------------------------------------------
184
130
  plugin_registry = PluginRegistry()
185
131
 
186
132
 
187
- # Function to discover plugins in the default package
188
- def discover_default_plugins():
189
- """
190
- Discover plugins in the default package.
191
- """
192
- discovery = PluginDiscovery(plugin_registry)
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/parsers__init__.py
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
+