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.
- chuk_tool_processor/core/__init__.py +32 -1
- chuk_tool_processor/core/exceptions.py +225 -13
- chuk_tool_processor/core/processor.py +135 -104
- chuk_tool_processor/execution/strategies/__init__.py +6 -0
- chuk_tool_processor/execution/strategies/inprocess_strategy.py +142 -150
- chuk_tool_processor/execution/strategies/subprocess_strategy.py +202 -206
- chuk_tool_processor/execution/tool_executor.py +82 -84
- chuk_tool_processor/execution/wrappers/__init__.py +42 -0
- chuk_tool_processor/execution/wrappers/caching.py +150 -116
- chuk_tool_processor/execution/wrappers/circuit_breaker.py +370 -0
- chuk_tool_processor/execution/wrappers/rate_limiting.py +76 -43
- chuk_tool_processor/execution/wrappers/retry.py +116 -78
- chuk_tool_processor/logging/__init__.py +23 -17
- chuk_tool_processor/logging/context.py +40 -45
- chuk_tool_processor/logging/formatter.py +22 -21
- chuk_tool_processor/logging/helpers.py +28 -42
- chuk_tool_processor/logging/metrics.py +13 -15
- chuk_tool_processor/mcp/__init__.py +8 -12
- chuk_tool_processor/mcp/mcp_tool.py +158 -114
- chuk_tool_processor/mcp/register_mcp_tools.py +22 -22
- chuk_tool_processor/mcp/setup_mcp_http_streamable.py +57 -17
- chuk_tool_processor/mcp/setup_mcp_sse.py +57 -17
- chuk_tool_processor/mcp/setup_mcp_stdio.py +11 -11
- chuk_tool_processor/mcp/stream_manager.py +333 -276
- chuk_tool_processor/mcp/transport/__init__.py +22 -29
- chuk_tool_processor/mcp/transport/base_transport.py +180 -44
- chuk_tool_processor/mcp/transport/http_streamable_transport.py +505 -325
- chuk_tool_processor/mcp/transport/models.py +100 -0
- chuk_tool_processor/mcp/transport/sse_transport.py +607 -276
- chuk_tool_processor/mcp/transport/stdio_transport.py +597 -116
- chuk_tool_processor/models/__init__.py +21 -1
- chuk_tool_processor/models/execution_strategy.py +16 -21
- chuk_tool_processor/models/streaming_tool.py +28 -25
- chuk_tool_processor/models/tool_call.py +49 -31
- chuk_tool_processor/models/tool_export_mixin.py +22 -8
- chuk_tool_processor/models/tool_result.py +40 -77
- chuk_tool_processor/models/tool_spec.py +350 -0
- chuk_tool_processor/models/validated_tool.py +36 -18
- chuk_tool_processor/observability/__init__.py +30 -0
- chuk_tool_processor/observability/metrics.py +312 -0
- chuk_tool_processor/observability/setup.py +105 -0
- chuk_tool_processor/observability/tracing.py +345 -0
- chuk_tool_processor/plugins/__init__.py +1 -1
- chuk_tool_processor/plugins/discovery.py +11 -11
- chuk_tool_processor/plugins/parsers/__init__.py +1 -1
- chuk_tool_processor/plugins/parsers/base.py +1 -2
- chuk_tool_processor/plugins/parsers/function_call_tool.py +13 -8
- chuk_tool_processor/plugins/parsers/json_tool.py +4 -3
- chuk_tool_processor/plugins/parsers/openai_tool.py +12 -7
- chuk_tool_processor/plugins/parsers/xml_tool.py +4 -4
- chuk_tool_processor/registry/__init__.py +12 -12
- chuk_tool_processor/registry/auto_register.py +22 -30
- chuk_tool_processor/registry/decorators.py +127 -129
- chuk_tool_processor/registry/interface.py +26 -23
- chuk_tool_processor/registry/metadata.py +27 -22
- chuk_tool_processor/registry/provider.py +17 -18
- chuk_tool_processor/registry/providers/__init__.py +16 -19
- chuk_tool_processor/registry/providers/memory.py +18 -25
- chuk_tool_processor/registry/tool_export.py +42 -51
- chuk_tool_processor/utils/validation.py +15 -16
- chuk_tool_processor-0.9.7.dist-info/METADATA +1813 -0
- chuk_tool_processor-0.9.7.dist-info/RECORD +67 -0
- chuk_tool_processor-0.6.4.dist-info/METADATA +0 -697
- chuk_tool_processor-0.6.4.dist-info/RECORD +0 -60
- {chuk_tool_processor-0.6.4.dist-info → chuk_tool_processor-0.9.7.dist-info}/WHEEL +0 -0
- {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
|
|
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:
|
|
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) ->
|
|
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) ->
|
|
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:
|
|
73
|
+
self._seen_modules: set[str] = set()
|
|
74
74
|
|
|
75
75
|
# ------------------------------------------------------------------ #
|
|
76
|
-
def discover_plugins(self, package_paths:
|
|
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:
|
|
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.
|
|
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:
|
|
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:
|
|
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) ->
|
|
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
|
|
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:
|
|
44
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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) ->
|
|
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:
|
|
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
|
|
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) ->
|
|
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:
|
|
63
|
-
|
|
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) ->
|
|
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:
|
|
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
|
|