chuk-tool-processor 0.1.6__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.

Files changed (45) hide show
  1. chuk_tool_processor/core/processor.py +345 -132
  2. chuk_tool_processor/execution/strategies/inprocess_strategy.py +512 -68
  3. chuk_tool_processor/execution/strategies/subprocess_strategy.py +523 -63
  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/models/execution_strategy.py +52 -3
  18. chuk_tool_processor/models/streaming_tool.py +110 -0
  19. chuk_tool_processor/models/tool_call.py +56 -4
  20. chuk_tool_processor/models/tool_result.py +115 -9
  21. chuk_tool_processor/models/validated_tool.py +15 -13
  22. chuk_tool_processor/plugins/discovery.py +115 -70
  23. chuk_tool_processor/plugins/parsers/base.py +13 -5
  24. chuk_tool_processor/plugins/parsers/{function_call_tool_plugin.py → function_call_tool.py} +39 -20
  25. chuk_tool_processor/plugins/parsers/json_tool.py +50 -0
  26. chuk_tool_processor/plugins/parsers/openai_tool.py +88 -0
  27. chuk_tool_processor/plugins/parsers/xml_tool.py +74 -20
  28. chuk_tool_processor/registry/__init__.py +46 -7
  29. chuk_tool_processor/registry/auto_register.py +92 -28
  30. chuk_tool_processor/registry/decorators.py +134 -11
  31. chuk_tool_processor/registry/interface.py +48 -14
  32. chuk_tool_processor/registry/metadata.py +52 -6
  33. chuk_tool_processor/registry/provider.py +75 -36
  34. chuk_tool_processor/registry/providers/__init__.py +49 -10
  35. chuk_tool_processor/registry/providers/memory.py +59 -48
  36. chuk_tool_processor/registry/tool_export.py +208 -39
  37. chuk_tool_processor/utils/validation.py +18 -13
  38. chuk_tool_processor-0.1.7.dist-info/METADATA +401 -0
  39. chuk_tool_processor-0.1.7.dist-info/RECORD +58 -0
  40. {chuk_tool_processor-0.1.6.dist-info → chuk_tool_processor-0.1.7.dist-info}/WHEEL +1 -1
  41. chuk_tool_processor/plugins/parsers/json_tool_plugin.py +0 -38
  42. chuk_tool_processor/plugins/parsers/openai_tool_plugin.py +0 -76
  43. chuk_tool_processor-0.1.6.dist-info/METADATA +0 -462
  44. chuk_tool_processor-0.1.6.dist-info/RECORD +0 -57
  45. {chuk_tool_processor-0.1.6.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
- """Plugin discovery & registry utilities for chuk_tool_processor"""
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
- """In‑memory registry keyed by *category → name*."""
32
+ """Thread-safe (GIL) in-memory registry keyed by *category → name*."""
16
33
 
17
- def __init__(self) -> None: # no side‑effects in import time
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: list(self._plugins.get(category, {}))}
31
- return {cat: list(names) for cat, names in self._plugins.items()}
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
- """Recursively scans packages for plugin classes and registers them."""
58
+ """
59
+ Recursively scans *package_paths* for plugin classes and registers them.
36
60
 
37
- def __init__(self, registry: PluginRegistry) -> None:
38
- self.registry = registry
39
- self._seen: Set[str] = set()
61
+ * Parser plugins concrete subclasses of :class:`ParserPlugin`
62
+ with an **async** ``try_parse`` coroutine.
40
63
 
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
64
+ * Execution strategies – concrete subclasses of
65
+ :class:`ExecutionStrategy`.
47
66
 
48
- # ExecutionStrategy always present inside core models
49
- from chuk_tool_processor.models.execution_strategy import ExecutionStrategy # noqa: WPS433
67
+ * Explicitly-decorated plugins classes tagged with ``@plugin(...)``.
68
+ """
50
69
 
51
- self.ExecutionStrategy = ExecutionStrategy
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
- for pkg in package_paths:
56
- self._walk(pkg)
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
- pkg = importlib.import_module(pkg_path)
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
- for _, mod_name, is_pkg in pkgutil.iter_modules(pkg.__path__, pkg.__name__ + "."):
67
- if mod_name in self._seen:
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
- self._seen.add(mod_name)
70
- self._inspect_module(mod_name)
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, mod_name: str) -> None:
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 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
- ):
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
- """Decorator to mark a class as a plugin for explicit registration."""
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
- 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 {}