chuk-tool-processor 0.6.4__py3-none-any.whl → 0.9.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 (66) hide show
  1. chuk_tool_processor/core/__init__.py +32 -1
  2. chuk_tool_processor/core/exceptions.py +225 -13
  3. chuk_tool_processor/core/processor.py +135 -104
  4. chuk_tool_processor/execution/strategies/__init__.py +6 -0
  5. chuk_tool_processor/execution/strategies/inprocess_strategy.py +142 -150
  6. chuk_tool_processor/execution/strategies/subprocess_strategy.py +202 -206
  7. chuk_tool_processor/execution/tool_executor.py +82 -84
  8. chuk_tool_processor/execution/wrappers/__init__.py +42 -0
  9. chuk_tool_processor/execution/wrappers/caching.py +150 -116
  10. chuk_tool_processor/execution/wrappers/circuit_breaker.py +370 -0
  11. chuk_tool_processor/execution/wrappers/rate_limiting.py +76 -43
  12. chuk_tool_processor/execution/wrappers/retry.py +116 -78
  13. chuk_tool_processor/logging/__init__.py +23 -17
  14. chuk_tool_processor/logging/context.py +40 -45
  15. chuk_tool_processor/logging/formatter.py +22 -21
  16. chuk_tool_processor/logging/helpers.py +28 -42
  17. chuk_tool_processor/logging/metrics.py +13 -15
  18. chuk_tool_processor/mcp/__init__.py +8 -12
  19. chuk_tool_processor/mcp/mcp_tool.py +158 -114
  20. chuk_tool_processor/mcp/register_mcp_tools.py +22 -22
  21. chuk_tool_processor/mcp/setup_mcp_http_streamable.py +57 -17
  22. chuk_tool_processor/mcp/setup_mcp_sse.py +57 -17
  23. chuk_tool_processor/mcp/setup_mcp_stdio.py +11 -11
  24. chuk_tool_processor/mcp/stream_manager.py +333 -276
  25. chuk_tool_processor/mcp/transport/__init__.py +22 -29
  26. chuk_tool_processor/mcp/transport/base_transport.py +180 -44
  27. chuk_tool_processor/mcp/transport/http_streamable_transport.py +505 -325
  28. chuk_tool_processor/mcp/transport/models.py +100 -0
  29. chuk_tool_processor/mcp/transport/sse_transport.py +607 -276
  30. chuk_tool_processor/mcp/transport/stdio_transport.py +597 -116
  31. chuk_tool_processor/models/__init__.py +21 -1
  32. chuk_tool_processor/models/execution_strategy.py +16 -21
  33. chuk_tool_processor/models/streaming_tool.py +28 -25
  34. chuk_tool_processor/models/tool_call.py +49 -31
  35. chuk_tool_processor/models/tool_export_mixin.py +22 -8
  36. chuk_tool_processor/models/tool_result.py +40 -77
  37. chuk_tool_processor/models/tool_spec.py +350 -0
  38. chuk_tool_processor/models/validated_tool.py +36 -18
  39. chuk_tool_processor/observability/__init__.py +30 -0
  40. chuk_tool_processor/observability/metrics.py +312 -0
  41. chuk_tool_processor/observability/setup.py +105 -0
  42. chuk_tool_processor/observability/tracing.py +345 -0
  43. chuk_tool_processor/plugins/__init__.py +1 -1
  44. chuk_tool_processor/plugins/discovery.py +11 -11
  45. chuk_tool_processor/plugins/parsers/__init__.py +1 -1
  46. chuk_tool_processor/plugins/parsers/base.py +1 -2
  47. chuk_tool_processor/plugins/parsers/function_call_tool.py +13 -8
  48. chuk_tool_processor/plugins/parsers/json_tool.py +4 -3
  49. chuk_tool_processor/plugins/parsers/openai_tool.py +12 -7
  50. chuk_tool_processor/plugins/parsers/xml_tool.py +4 -4
  51. chuk_tool_processor/registry/__init__.py +12 -12
  52. chuk_tool_processor/registry/auto_register.py +22 -30
  53. chuk_tool_processor/registry/decorators.py +127 -129
  54. chuk_tool_processor/registry/interface.py +26 -23
  55. chuk_tool_processor/registry/metadata.py +27 -22
  56. chuk_tool_processor/registry/provider.py +17 -18
  57. chuk_tool_processor/registry/providers/__init__.py +16 -19
  58. chuk_tool_processor/registry/providers/memory.py +18 -25
  59. chuk_tool_processor/registry/tool_export.py +42 -51
  60. chuk_tool_processor/utils/validation.py +15 -16
  61. chuk_tool_processor-0.9.7.dist-info/METADATA +1813 -0
  62. chuk_tool_processor-0.9.7.dist-info/RECORD +67 -0
  63. chuk_tool_processor-0.6.4.dist-info/METADATA +0 -697
  64. chuk_tool_processor-0.6.4.dist-info/RECORD +0 -60
  65. {chuk_tool_processor-0.6.4.dist-info → chuk_tool_processor-0.9.7.dist-info}/WHEEL +0 -0
  66. {chuk_tool_processor-0.6.4.dist-info → chuk_tool_processor-0.9.7.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,345 @@
1
+ """
2
+ OpenTelemetry tracing integration for chuk-tool-processor.
3
+
4
+ Provides drop-in distributed tracing with standardized span names and attributes.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from contextlib import contextmanager
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from chuk_tool_processor.logging import get_logger
13
+
14
+ if TYPE_CHECKING:
15
+ from opentelemetry.trace import Span, Tracer # type: ignore[import-not-found]
16
+
17
+ logger = get_logger("chuk_tool_processor.observability.tracing")
18
+
19
+ # Global tracer instance
20
+ _tracer: Tracer | None = None
21
+ _tracing_enabled = False
22
+
23
+
24
+ def init_tracer(service_name: str = "chuk-tool-processor") -> Tracer | NoOpTracer:
25
+ """
26
+ Initialize OpenTelemetry tracer with best-practice configuration.
27
+
28
+ Args:
29
+ service_name: Service name for tracing
30
+
31
+ Returns:
32
+ Configured OpenTelemetry tracer or NoOpTracer if initialization fails
33
+ """
34
+ global _tracer, _tracing_enabled
35
+
36
+ try:
37
+ from opentelemetry import trace # type: ignore[import-not-found]
38
+ from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( # type: ignore[import-not-found]
39
+ OTLPSpanExporter,
40
+ )
41
+ from opentelemetry.sdk.resources import Resource # type: ignore[import-not-found]
42
+ from opentelemetry.sdk.trace import TracerProvider # type: ignore[import-not-found]
43
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor # type: ignore[import-not-found]
44
+
45
+ # Create resource with service name
46
+ resource = Resource.create({"service.name": service_name})
47
+
48
+ # Create tracer provider
49
+ provider = TracerProvider(resource=resource)
50
+
51
+ # Add OTLP exporter (exports to OTEL collector)
52
+ otlp_exporter = OTLPSpanExporter()
53
+ provider.add_span_processor(BatchSpanProcessor(otlp_exporter))
54
+
55
+ # Set as global tracer provider
56
+ trace.set_tracer_provider(provider)
57
+
58
+ _tracer = trace.get_tracer(__name__)
59
+ _tracing_enabled = True
60
+
61
+ logger.info(f"OpenTelemetry tracing initialized for service: {service_name}")
62
+ return _tracer
63
+
64
+ except ImportError as e:
65
+ logger.warning(f"OpenTelemetry packages not installed: {e}. Tracing disabled.")
66
+ _tracing_enabled = False
67
+ return NoOpTracer()
68
+
69
+
70
+ def get_tracer() -> Tracer | NoOpTracer:
71
+ """
72
+ Get the current tracer instance.
73
+
74
+ Returns:
75
+ OpenTelemetry tracer or no-op tracer if not initialized
76
+ """
77
+ if _tracer is None:
78
+ return NoOpTracer()
79
+ return _tracer
80
+
81
+
82
+ def is_tracing_enabled() -> bool:
83
+ """Check if tracing is enabled."""
84
+ return _tracing_enabled
85
+
86
+
87
+ @contextmanager
88
+ def trace_tool_execution(
89
+ tool: str,
90
+ namespace: str | None = None,
91
+ attributes: dict[str, Any] | None = None,
92
+ ):
93
+ """
94
+ Context manager for tracing tool execution.
95
+
96
+ Creates a span with name "tool.execute" and standard attributes.
97
+
98
+ Args:
99
+ tool: Tool name
100
+ namespace: Optional tool namespace
101
+ attributes: Additional span attributes
102
+
103
+ Example:
104
+ with trace_tool_execution("calculator", attributes={"operation": "add"}):
105
+ result = await tool.execute(a=5, b=3)
106
+ """
107
+ if not _tracing_enabled or _tracer is None:
108
+ yield None
109
+ return
110
+
111
+ span_name = "tool.execute"
112
+ span_attributes: dict[str, str | int | float | bool] = {
113
+ "tool.name": tool,
114
+ }
115
+
116
+ if namespace:
117
+ span_attributes["tool.namespace"] = namespace
118
+
119
+ if attributes:
120
+ # Flatten attributes with "tool." prefix
121
+ for key, value in attributes.items():
122
+ # Convert value to string for OTEL compatibility
123
+ if isinstance(value, (str, int, float, bool)):
124
+ span_attributes[f"tool.{key}"] = value
125
+ else:
126
+ span_attributes[f"tool.{key}"] = str(value)
127
+
128
+ with _tracer.start_as_current_span(span_name, attributes=span_attributes) as span:
129
+ yield span
130
+
131
+
132
+ @contextmanager
133
+ def trace_cache_operation(
134
+ operation: str,
135
+ tool: str,
136
+ hit: bool | None = None,
137
+ attributes: dict[str, Any] | None = None,
138
+ ):
139
+ """
140
+ Context manager for tracing cache operations.
141
+
142
+ Args:
143
+ operation: Cache operation (lookup, set, invalidate)
144
+ tool: Tool name
145
+ hit: Whether cache hit (for lookup operations)
146
+ attributes: Additional span attributes
147
+
148
+ Example:
149
+ with trace_cache_operation("lookup", "calculator", hit=True):
150
+ result = await cache.get(tool, key)
151
+ """
152
+ if not _tracing_enabled or _tracer is None:
153
+ yield None
154
+ return
155
+
156
+ span_name = f"tool.cache.{operation}"
157
+ span_attributes: dict[str, str | int | float | bool] = {
158
+ "tool.name": tool,
159
+ "cache.operation": operation,
160
+ }
161
+
162
+ if hit is not None:
163
+ span_attributes["cache.hit"] = hit
164
+
165
+ if attributes:
166
+ for key, value in attributes.items():
167
+ if isinstance(value, (str, int, float, bool)):
168
+ span_attributes[f"cache.{key}"] = value
169
+ else:
170
+ span_attributes[f"cache.{key}"] = str(value)
171
+
172
+ with _tracer.start_as_current_span(span_name, attributes=span_attributes) as span:
173
+ yield span
174
+
175
+
176
+ @contextmanager
177
+ def trace_retry_attempt(
178
+ tool: str,
179
+ attempt: int,
180
+ max_retries: int,
181
+ attributes: dict[str, Any] | None = None,
182
+ ):
183
+ """
184
+ Context manager for tracing retry attempts.
185
+
186
+ Args:
187
+ tool: Tool name
188
+ attempt: Current attempt number (0-indexed)
189
+ max_retries: Maximum retry attempts
190
+ attributes: Additional span attributes
191
+
192
+ Example:
193
+ with trace_retry_attempt("api_tool", attempt=1, max_retries=3):
194
+ result = await executor.execute([call])
195
+ """
196
+ if not _tracing_enabled or _tracer is None:
197
+ yield None
198
+ return
199
+
200
+ span_name = "tool.retry.attempt"
201
+ span_attributes: dict[str, str | int | float | bool] = {
202
+ "tool.name": tool,
203
+ "retry.attempt": attempt,
204
+ "retry.max_attempts": max_retries,
205
+ }
206
+
207
+ if attributes:
208
+ for key, value in attributes.items():
209
+ if isinstance(value, (str, int, float, bool)):
210
+ span_attributes[f"retry.{key}"] = value
211
+ else:
212
+ span_attributes[f"retry.{key}"] = str(value)
213
+
214
+ with _tracer.start_as_current_span(span_name, attributes=span_attributes) as span:
215
+ yield span
216
+
217
+
218
+ @contextmanager
219
+ def trace_circuit_breaker(
220
+ tool: str,
221
+ state: str,
222
+ attributes: dict[str, Any] | None = None,
223
+ ):
224
+ """
225
+ Context manager for tracing circuit breaker operations.
226
+
227
+ Args:
228
+ tool: Tool name
229
+ state: Circuit breaker state (CLOSED, OPEN, HALF_OPEN)
230
+ attributes: Additional span attributes
231
+
232
+ Example:
233
+ with trace_circuit_breaker("api_tool", state="OPEN"):
234
+ can_execute = await breaker.can_execute()
235
+ """
236
+ if not _tracing_enabled or _tracer is None:
237
+ yield None
238
+ return
239
+
240
+ span_name = "tool.circuit_breaker.check"
241
+ span_attributes: dict[str, str | int | float | bool] = {
242
+ "tool.name": tool,
243
+ "circuit.state": state,
244
+ }
245
+
246
+ if attributes:
247
+ for key, value in attributes.items():
248
+ if isinstance(value, (str, int, float, bool)):
249
+ span_attributes[f"circuit.{key}"] = value
250
+ else:
251
+ span_attributes[f"circuit.{key}"] = str(value)
252
+
253
+ with _tracer.start_as_current_span(span_name, attributes=span_attributes) as span:
254
+ yield span
255
+
256
+
257
+ @contextmanager
258
+ def trace_rate_limit(
259
+ tool: str,
260
+ allowed: bool,
261
+ attributes: dict[str, Any] | None = None,
262
+ ):
263
+ """
264
+ Context manager for tracing rate limiting.
265
+
266
+ Args:
267
+ tool: Tool name
268
+ allowed: Whether request was allowed
269
+ attributes: Additional span attributes
270
+
271
+ Example:
272
+ with trace_rate_limit("api_tool", allowed=True):
273
+ await rate_limiter.acquire()
274
+ """
275
+ if not _tracing_enabled or _tracer is None:
276
+ yield None
277
+ return
278
+
279
+ span_name = "tool.rate_limit.check"
280
+ span_attributes: dict[str, str | int | float | bool] = {
281
+ "tool.name": tool,
282
+ "rate_limit.allowed": allowed,
283
+ }
284
+
285
+ if attributes:
286
+ for key, value in attributes.items():
287
+ if isinstance(value, (str, int, float, bool)):
288
+ span_attributes[f"rate_limit.{key}"] = value
289
+ else:
290
+ span_attributes[f"rate_limit.{key}"] = str(value)
291
+
292
+ with _tracer.start_as_current_span(span_name, attributes=span_attributes) as span:
293
+ yield span
294
+
295
+
296
+ def add_span_event(span: Span | None, name: str, attributes: dict[str, Any] | None = None) -> None:
297
+ """
298
+ Add an event to the current span.
299
+
300
+ Args:
301
+ span: Span to add event to (can be None)
302
+ name: Event name
303
+ attributes: Event attributes
304
+ """
305
+ if span is None or not _tracing_enabled:
306
+ return
307
+
308
+ try:
309
+ span.add_event(name, attributes=attributes or {})
310
+ except Exception as e:
311
+ logger.debug(f"Error adding span event: {e}")
312
+
313
+
314
+ def set_span_error(span: Span | None, error: Exception | str) -> None:
315
+ """
316
+ Mark span as error and record exception details.
317
+
318
+ Args:
319
+ span: Span to mark as error (can be None)
320
+ error: Error to record
321
+ """
322
+ if span is None or not _tracing_enabled:
323
+ return
324
+
325
+ try:
326
+ from opentelemetry.trace import Status, StatusCode
327
+
328
+ span.set_status(Status(StatusCode.ERROR, str(error)))
329
+
330
+ if isinstance(error, Exception):
331
+ span.record_exception(error)
332
+ else:
333
+ span.add_event("error", {"error.message": str(error)})
334
+
335
+ except Exception as e:
336
+ logger.debug(f"Error setting span error: {e}")
337
+
338
+
339
+ class NoOpTracer:
340
+ """No-op tracer when OpenTelemetry is not available."""
341
+
342
+ @contextmanager
343
+ def start_as_current_span(self, _name: str, **_kwargs):
344
+ """No-op span context manager."""
345
+ yield None
@@ -1 +1 @@
1
- # chuk_tool_processor/plugins/parsers__init__.py
1
+ # chuk_tool_processor/plugins/parsers__init__.py
@@ -8,10 +8,10 @@ import inspect
8
8
  import logging
9
9
  import pkgutil
10
10
  from types import ModuleType
11
- from typing import Any, Dict, List, Optional, Set, Type
11
+ from typing import Any
12
12
 
13
- from chuk_tool_processor.plugins.parsers.base import ParserPlugin
14
13
  from chuk_tool_processor.models.execution_strategy import ExecutionStrategy
14
+ from chuk_tool_processor.plugins.parsers.base import ParserPlugin
15
15
 
16
16
  __all__ = [
17
17
  "plugin_registry",
@@ -33,7 +33,7 @@ class PluginRegistry:
33
33
 
34
34
  def __init__(self) -> None:
35
35
  # category → {name → object}
36
- self._plugins: Dict[str, Dict[str, Any]] = {}
36
+ self._plugins: dict[str, dict[str, Any]] = {}
37
37
 
38
38
  # --------------------------------------------------------------------- #
39
39
  # Public API
@@ -42,10 +42,10 @@ class PluginRegistry:
42
42
  self._plugins.setdefault(category, {})[name] = plugin
43
43
  logger.debug("Registered plugin %s.%s", category, name)
44
44
 
45
- def get_plugin(self, category: str, name: str) -> Optional[Any]: # noqa: D401
45
+ def get_plugin(self, category: str, name: str) -> Any | None: # noqa: D401
46
46
  return self._plugins.get(category, {}).get(name)
47
47
 
48
- def list_plugins(self, category: str | None = None) -> Dict[str, List[str]]:
48
+ def list_plugins(self, category: str | None = None) -> dict[str, list[str]]:
49
49
  if category is not None:
50
50
  return {category: sorted(self._plugins.get(category, {}))}
51
51
  return {cat: sorted(names) for cat, names in self._plugins.items()}
@@ -70,10 +70,10 @@ class PluginDiscovery:
70
70
  # ------------------------------------------------------------------ #
71
71
  def __init__(self, registry: PluginRegistry) -> None:
72
72
  self._registry = registry
73
- self._seen_modules: Set[str] = set()
73
+ self._seen_modules: set[str] = set()
74
74
 
75
75
  # ------------------------------------------------------------------ #
76
- def discover_plugins(self, package_paths: List[str]) -> None:
76
+ def discover_plugins(self, package_paths: list[str]) -> None:
77
77
  """Import every package in *package_paths* and walk its subtree."""
78
78
  for pkg_path in package_paths:
79
79
  self._walk(pkg_path)
@@ -113,7 +113,7 @@ class PluginDiscovery:
113
113
  self._maybe_register(attr)
114
114
 
115
115
  # ------------------------------------------------------------------ #
116
- def _maybe_register(self, cls: Type) -> None:
116
+ def _maybe_register(self, cls: type) -> None:
117
117
  """Register *cls* in all matching plugin categories."""
118
118
  if inspect.isabstract(cls):
119
119
  return
@@ -121,7 +121,7 @@ class PluginDiscovery:
121
121
  # ------------------- Parser plugins -------------------------
122
122
  if issubclass(cls, ParserPlugin) and cls is not ParserPlugin:
123
123
  if not inspect.iscoroutinefunction(getattr(cls, "try_parse", None)):
124
- logger.warning("Skipping parser plugin %s: try_parse is not async", cls.__qualname__)
124
+ logger.debug("Skipping parser plugin %s: try_parse is not async", cls.__qualname__)
125
125
  else:
126
126
  try:
127
127
  self._registry.register_plugin("parser", cls.__name__, cls())
@@ -133,7 +133,7 @@ class PluginDiscovery:
133
133
  self._registry.register_plugin("execution_strategy", cls.__name__, cls)
134
134
 
135
135
  # ------------- Explicit @plugin decorator ------------------
136
- meta: Optional[dict] = getattr(cls, "_plugin_meta", None)
136
+ meta: dict | None = getattr(cls, "_plugin_meta", None)
137
137
  if meta:
138
138
  category = meta.get("category", "unknown")
139
139
  name = meta.get("name", cls.__name__)
@@ -178,6 +178,6 @@ def discover_default_plugins() -> None:
178
178
  PluginDiscovery(plugin_registry).discover_plugins(["chuk_tool_processor.plugins"])
179
179
 
180
180
 
181
- def discover_plugins(package_paths: List[str]) -> None:
181
+ def discover_plugins(package_paths: list[str]) -> None:
182
182
  """Discover plugins from arbitrary external *package_paths*."""
183
183
  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
@@ -4,7 +4,6 @@
4
4
  from __future__ import annotations
5
5
 
6
6
  from abc import ABC, abstractmethod
7
- from typing import List
8
7
 
9
8
  from chuk_tool_processor.models.tool_call import ToolCall
10
9
 
@@ -21,6 +20,6 @@ class ParserPlugin(ABC):
21
20
  """
22
21
 
23
22
  @abstractmethod
24
- async def try_parse(self, raw: str | object) -> List[ToolCall]: # noqa: D401
23
+ async def try_parse(self, raw: str | object) -> list[ToolCall]: # noqa: D401
25
24
  """Attempt to parse *raw* into one or more :class:`ToolCall` objects."""
26
25
  ...
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
 
6
6
  import json
7
7
  import re
8
- from typing import Any, Dict, List
8
+ from typing import Any
9
9
 
10
10
  from pydantic import ValidationError
11
11
 
@@ -26,8 +26,7 @@ class PluginMeta:
26
26
 
27
27
  name: str = "function_call"
28
28
  description: str = (
29
- "Parses a single OpenAI-style `function_call` JSON object (including "
30
- "strings that embed such an object)."
29
+ "Parses a single OpenAI-style `function_call` JSON object (including strings that embed such an object)."
31
30
  )
32
31
  version: str = "1.0.0"
33
32
  author: str = "chuk_tool_processor"
@@ -40,19 +39,25 @@ class FunctionCallPlugin(ParserPlugin):
40
39
  # Public API
41
40
  # --------------------------------------------------------------------- #
42
41
 
43
- async def try_parse(self, raw: str | Dict[str, Any]) -> List[ToolCall]:
44
- payload: Dict[str, Any] | None
42
+ async def try_parse(self, raw: Any) -> list[ToolCall]:
43
+ # Handle non-string, non-dict inputs gracefully
44
+ if not isinstance(raw, str | dict):
45
+ return []
46
+
47
+ payload: dict[str, Any] | None
45
48
 
46
49
  # 1️⃣ Primary path ─ whole payload is JSON
47
50
  if isinstance(raw, dict):
48
51
  payload = raw
49
- else:
52
+ elif isinstance(raw, str):
50
53
  try:
51
54
  payload = json.loads(raw)
52
55
  except json.JSONDecodeError:
53
56
  payload = None
57
+ else:
58
+ return []
54
59
 
55
- calls: List[ToolCall] = []
60
+ calls: list[ToolCall] = []
56
61
 
57
62
  if isinstance(payload, dict):
58
63
  calls.extend(self._extract_from_payload(payload))
@@ -72,7 +77,7 @@ class FunctionCallPlugin(ParserPlugin):
72
77
  # Helpers
73
78
  # ------------------------------------------------------------------ #
74
79
 
75
- def _extract_from_payload(self, payload: Dict[str, Any]) -> List[ToolCall]:
80
+ def _extract_from_payload(self, payload: dict[str, Any]) -> list[ToolCall]:
76
81
  fc = payload.get("function_call")
77
82
  if not isinstance(fc, dict):
78
83
  return []
@@ -4,7 +4,7 @@
4
4
  from __future__ import annotations
5
5
 
6
6
  import json
7
- from typing import Any, List
7
+ from typing import Any
8
8
 
9
9
  from pydantic import ValidationError
10
10
 
@@ -19,6 +19,7 @@ logger = get_logger(__name__)
19
19
 
20
20
  class PluginMeta:
21
21
  """Optional self-description consumed by the plugin-discovery subsystem."""
22
+
22
23
  name: str = "json_tool_calls"
23
24
  description: str = "Parses a JSON object containing a `tool_calls` array."
24
25
  version: str = "1.0.0"
@@ -28,7 +29,7 @@ class PluginMeta:
28
29
  class JsonToolPlugin(ParserPlugin):
29
30
  """Extracts a *list* of :class:`ToolCall` objects from a `tool_calls` array."""
30
31
 
31
- async def try_parse(self, raw: str | Any) -> List[ToolCall]: # noqa: D401
32
+ async def try_parse(self, raw: str | Any) -> list[ToolCall]: # noqa: D401
32
33
  # Decode JSON if we were given a string
33
34
  try:
34
35
  data = json.loads(raw) if isinstance(raw, str) else raw
@@ -39,7 +40,7 @@ class JsonToolPlugin(ParserPlugin):
39
40
  if not isinstance(data, dict):
40
41
  return []
41
42
 
42
- calls: List[ToolCall] = []
43
+ calls: list[ToolCall] = []
43
44
  for entry in data.get("tool_calls", []):
44
45
  try:
45
46
  calls.append(ToolCall(**entry))
@@ -4,7 +4,7 @@
4
4
  from __future__ import annotations
5
5
 
6
6
  import json
7
- from typing import Any, List
7
+ from typing import Any
8
8
 
9
9
  from pydantic import ValidationError
10
10
 
@@ -19,6 +19,7 @@ logger = get_logger(__name__)
19
19
 
20
20
  class PluginMeta:
21
21
  """Optional descriptor consumed by the plugin-discovery system."""
22
+
22
23
  name: str = "openai_tool_calls"
23
24
  description: str = "Parses Chat-Completions responses containing `tool_calls`."
24
25
  version: str = "1.0.0"
@@ -43,7 +44,7 @@ class OpenAIToolPlugin(ParserPlugin):
43
44
  }
44
45
  """
45
46
 
46
- async def try_parse(self, raw: str | Any) -> List[ToolCall]: # noqa: D401
47
+ async def try_parse(self, raw: str | Any) -> list[ToolCall]: # noqa: D401
47
48
  # ------------------------------------------------------------------ #
48
49
  # 1. Decode JSON when the input is a string
49
50
  # ------------------------------------------------------------------ #
@@ -59,8 +60,14 @@ class OpenAIToolPlugin(ParserPlugin):
59
60
  # ------------------------------------------------------------------ #
60
61
  # 2. Build ToolCall objects
61
62
  # ------------------------------------------------------------------ #
62
- calls: List[ToolCall] = []
63
- for entry in data["tool_calls"]:
63
+ calls: list[ToolCall] = []
64
+
65
+ # Ensure tool_calls is a list
66
+ tool_calls = data.get("tool_calls", [])
67
+ if not isinstance(tool_calls, list):
68
+ return []
69
+
70
+ for entry in tool_calls:
64
71
  fn = entry.get("function", {})
65
72
  name = fn.get("name")
66
73
  args = fn.get("arguments", {})
@@ -76,9 +83,7 @@ class OpenAIToolPlugin(ParserPlugin):
76
83
  continue
77
84
 
78
85
  try:
79
- calls.append(
80
- ToolCall(tool=name, arguments=args if isinstance(args, dict) else {})
81
- )
86
+ calls.append(ToolCall(tool=name, arguments=args if isinstance(args, dict) else {}))
82
87
  except ValidationError:
83
88
  logger.debug(
84
89
  "openai_tool_plugin: validation error while building ToolCall for %s",
@@ -17,7 +17,6 @@ from __future__ import annotations
17
17
 
18
18
  import json
19
19
  import re
20
- from typing import List
21
20
 
22
21
  from pydantic import ValidationError
23
22
 
@@ -32,6 +31,7 @@ logger = get_logger(__name__)
32
31
 
33
32
  class PluginMeta:
34
33
  """Optional descriptor that can be used by the plugin-discovery mechanism."""
34
+
35
35
  name: str = "xml_tool_tag"
36
36
  description: str = "Parses <tool …/> XML tags into ToolCall objects."
37
37
  version: str = "1.0.0"
@@ -49,11 +49,11 @@ class XmlToolPlugin(ParserPlugin):
49
49
  )
50
50
 
51
51
  # ------------------------------------------------------------------ #
52
- async def try_parse(self, raw: str | object) -> List[ToolCall]: # noqa: D401
52
+ async def try_parse(self, raw: str | object) -> list[ToolCall]: # noqa: D401
53
53
  if not isinstance(raw, str):
54
54
  return []
55
55
 
56
- calls: List[ToolCall] = []
56
+ calls: list[ToolCall] = []
57
57
 
58
58
  for match in self._TAG.finditer(raw):
59
59
  name = match.group("tool")
@@ -92,7 +92,7 @@ class XmlToolPlugin(ParserPlugin):
92
92
  # 3️⃣ Last resort - naive unescaping of \" → "
93
93
  if parsed is None:
94
94
  try:
95
- parsed = json.loads(raw_args.replace(r"\"", "\""))
95
+ parsed = json.loads(raw_args.replace(r"\"", '"'))
96
96
  except json.JSONDecodeError:
97
97
  parsed = {}
98
98