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,370 @@
1
+ # chuk_tool_processor/execution/wrappers/circuit_breaker.py
2
+ """
3
+ Circuit breaker pattern for tool execution.
4
+
5
+ Prevents cascading failures by tracking failure rates and temporarily
6
+ blocking calls to failing tools. Implements a state machine:
7
+
8
+ CLOSED → OPEN → HALF_OPEN → CLOSED (or back to OPEN)
9
+
10
+ States:
11
+ - CLOSED: Normal operation, requests pass through
12
+ - OPEN: Too many failures, requests blocked immediately
13
+ - HALF_OPEN: Testing if service recovered, limited requests allowed
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import time
20
+ from datetime import UTC, datetime
21
+ from enum import Enum
22
+ from typing import Any
23
+
24
+ from chuk_tool_processor.core.exceptions import ToolCircuitOpenError
25
+ from chuk_tool_processor.logging import get_logger
26
+ from chuk_tool_processor.models.tool_call import ToolCall
27
+ from chuk_tool_processor.models.tool_result import ToolResult
28
+
29
+ logger = get_logger("chuk_tool_processor.execution.wrappers.circuit_breaker")
30
+
31
+ # Optional observability imports
32
+ try:
33
+ from chuk_tool_processor.observability.metrics import get_metrics
34
+ from chuk_tool_processor.observability.tracing import trace_circuit_breaker
35
+
36
+ _observability_available = True
37
+ except ImportError:
38
+ _observability_available = False
39
+
40
+ # No-op functions when observability not available
41
+ def get_metrics():
42
+ return None
43
+
44
+ def trace_circuit_breaker(*_args, **_kwargs):
45
+ from contextlib import nullcontext
46
+
47
+ return nullcontext()
48
+
49
+
50
+ # --------------------------------------------------------------------------- #
51
+ # Circuit breaker state
52
+ # --------------------------------------------------------------------------- #
53
+ class CircuitState(str, Enum):
54
+ """Circuit breaker states."""
55
+
56
+ CLOSED = "closed" # Normal operation
57
+ OPEN = "open" # Blocking requests due to failures
58
+ HALF_OPEN = "half_open" # Testing recovery with limited requests
59
+
60
+
61
+ class CircuitBreakerConfig:
62
+ """Configuration for circuit breaker behavior."""
63
+
64
+ def __init__(
65
+ self,
66
+ failure_threshold: int = 5,
67
+ success_threshold: int = 2,
68
+ reset_timeout: float = 60.0,
69
+ half_open_max_calls: int = 1,
70
+ timeout_threshold: float | None = None,
71
+ ):
72
+ """
73
+ Initialize circuit breaker configuration.
74
+
75
+ Args:
76
+ failure_threshold: Number of failures before opening circuit
77
+ success_threshold: Number of successes in HALF_OPEN to close circuit
78
+ reset_timeout: Seconds to wait before trying HALF_OPEN
79
+ half_open_max_calls: Max concurrent calls in HALF_OPEN state
80
+ timeout_threshold: Optional timeout (s) to consider as failure
81
+ """
82
+ self.failure_threshold = failure_threshold
83
+ self.success_threshold = success_threshold
84
+ self.reset_timeout = reset_timeout
85
+ self.half_open_max_calls = half_open_max_calls
86
+ self.timeout_threshold = timeout_threshold
87
+
88
+
89
+ class CircuitBreakerState:
90
+ """Per-tool circuit breaker state tracking."""
91
+
92
+ def __init__(self, config: CircuitBreakerConfig):
93
+ self.config = config
94
+ self.state = CircuitState.CLOSED
95
+ self.failure_count = 0
96
+ self.success_count = 0
97
+ self.last_failure_time: float | None = None
98
+ self.opened_at: float | None = None
99
+ self.half_open_calls = 0
100
+ self._lock = asyncio.Lock()
101
+
102
+ async def record_success(self) -> None:
103
+ """Record a successful call."""
104
+ async with self._lock:
105
+ if self.state == CircuitState.HALF_OPEN:
106
+ self.success_count += 1
107
+ logger.debug(f"Circuit HALF_OPEN: success {self.success_count}/{self.config.success_threshold}")
108
+
109
+ # Enough successes? Close the circuit
110
+ if self.success_count >= self.config.success_threshold:
111
+ logger.info("Circuit breaker: Transitioning to CLOSED (service recovered)")
112
+ self.state = CircuitState.CLOSED
113
+ self.failure_count = 0
114
+ self.success_count = 0
115
+ self.opened_at = None
116
+ self.half_open_calls = 0
117
+ else:
118
+ # In CLOSED state, just reset failure count
119
+ self.failure_count = 0
120
+
121
+ async def record_failure(self) -> None:
122
+ """Record a failed call."""
123
+ async with self._lock:
124
+ self.failure_count += 1
125
+ self.last_failure_time = time.monotonic()
126
+ logger.debug(f"Circuit: failure {self.failure_count}/{self.config.failure_threshold}")
127
+
128
+ if self.state == CircuitState.CLOSED:
129
+ # Check if we should open
130
+ if self.failure_count >= self.config.failure_threshold:
131
+ logger.warning(f"Circuit breaker: OPENING after {self.failure_count} failures")
132
+ self.state = CircuitState.OPEN
133
+ self.opened_at = time.monotonic()
134
+ elif self.state == CircuitState.HALF_OPEN:
135
+ # Failed during test → back to OPEN
136
+ logger.warning("Circuit breaker: Back to OPEN (test failed)")
137
+ self.state = CircuitState.OPEN
138
+ self.success_count = 0
139
+ self.opened_at = time.monotonic()
140
+ self.half_open_calls = 0
141
+
142
+ async def can_execute(self) -> bool:
143
+ """Check if a call should be allowed through."""
144
+ async with self._lock:
145
+ if self.state == CircuitState.CLOSED:
146
+ return True
147
+
148
+ if self.state == CircuitState.HALF_OPEN:
149
+ # Limit concurrent calls in HALF_OPEN
150
+ if self.half_open_calls < self.config.half_open_max_calls:
151
+ self.half_open_calls += 1
152
+ return True
153
+ return False
154
+
155
+ # OPEN state: check if we should try HALF_OPEN
156
+ if self.opened_at is not None:
157
+ elapsed = time.monotonic() - self.opened_at
158
+ if elapsed >= self.config.reset_timeout:
159
+ logger.info("Circuit breaker: Transitioning to HALF_OPEN (testing recovery)")
160
+ self.state = CircuitState.HALF_OPEN
161
+ self.half_open_calls = 1
162
+ self.success_count = 0
163
+ return True
164
+
165
+ return False
166
+
167
+ async def release_half_open_slot(self) -> None:
168
+ """Release a HALF_OPEN slot after call completes."""
169
+ async with self._lock:
170
+ if self.state == CircuitState.HALF_OPEN:
171
+ self.half_open_calls = max(0, self.half_open_calls - 1)
172
+
173
+ def get_state(self) -> dict[str, Any]:
174
+ """Get current state as dict."""
175
+ return {
176
+ "state": self.state.value,
177
+ "failure_count": self.failure_count,
178
+ "success_count": self.success_count,
179
+ "opened_at": self.opened_at,
180
+ "time_until_half_open": (
181
+ max(0, self.config.reset_timeout - (time.monotonic() - self.opened_at))
182
+ if self.opened_at and self.state == CircuitState.OPEN
183
+ else None
184
+ ),
185
+ }
186
+
187
+
188
+ # --------------------------------------------------------------------------- #
189
+ # Circuit breaker executor wrapper
190
+ # --------------------------------------------------------------------------- #
191
+ class CircuitBreakerExecutor:
192
+ """
193
+ Executor wrapper that implements circuit breaker pattern.
194
+
195
+ Tracks failures per tool and opens circuit breakers to prevent
196
+ cascading failures when tools are consistently failing.
197
+ """
198
+
199
+ def __init__(
200
+ self,
201
+ executor: Any,
202
+ *,
203
+ default_config: CircuitBreakerConfig | None = None,
204
+ tool_configs: dict[str, CircuitBreakerConfig] | None = None,
205
+ ):
206
+ """
207
+ Initialize circuit breaker executor.
208
+
209
+ Args:
210
+ executor: Underlying executor to wrap
211
+ default_config: Default circuit breaker configuration
212
+ tool_configs: Per-tool circuit breaker configurations
213
+ """
214
+ self.executor = executor
215
+ self.default_config = default_config or CircuitBreakerConfig()
216
+ self.tool_configs = tool_configs or {}
217
+ self._states: dict[str, CircuitBreakerState] = {}
218
+ self._states_lock = asyncio.Lock()
219
+
220
+ async def _get_state(self, tool: str) -> CircuitBreakerState:
221
+ """Get or create circuit breaker state for a tool."""
222
+ if tool not in self._states:
223
+ async with self._states_lock:
224
+ if tool not in self._states:
225
+ config = self.tool_configs.get(tool, self.default_config)
226
+ self._states[tool] = CircuitBreakerState(config)
227
+ return self._states[tool]
228
+
229
+ async def execute(
230
+ self,
231
+ calls: list[ToolCall],
232
+ *,
233
+ timeout: float | None = None,
234
+ use_cache: bool = True,
235
+ ) -> list[ToolResult]:
236
+ """
237
+ Execute tool calls with circuit breaker protection.
238
+
239
+ Args:
240
+ calls: List of tool calls to execute
241
+ timeout: Optional timeout for execution
242
+ use_cache: Whether to use cached results
243
+
244
+ Returns:
245
+ List of tool results
246
+ """
247
+ if not calls:
248
+ return []
249
+
250
+ results: list[ToolResult] = []
251
+
252
+ for call in calls:
253
+ state = await self._get_state(call.tool)
254
+
255
+ # Record circuit breaker state
256
+ metrics = get_metrics()
257
+ if metrics:
258
+ metrics.record_circuit_breaker_state(call.tool, state.state.value)
259
+
260
+ # Check if circuit allows execution with tracing
261
+ with trace_circuit_breaker(call.tool, state.state.value):
262
+ can_execute = await state.can_execute()
263
+
264
+ if not can_execute:
265
+ # Circuit is OPEN - reject immediately
266
+ state_info = state.get_state()
267
+ logger.warning(f"Circuit breaker OPEN for {call.tool} (failures: {state.failure_count})")
268
+
269
+ reset_time = state_info.get("time_until_half_open")
270
+ error = ToolCircuitOpenError(
271
+ tool_name=call.tool,
272
+ failure_count=state.failure_count,
273
+ reset_timeout=reset_time,
274
+ )
275
+
276
+ now = datetime.now(UTC)
277
+ results.append(
278
+ ToolResult(
279
+ tool=call.tool,
280
+ result=None,
281
+ error=str(error),
282
+ start_time=now,
283
+ end_time=now,
284
+ machine="circuit_breaker",
285
+ pid=0,
286
+ )
287
+ )
288
+ continue
289
+
290
+ # Execute the call
291
+ start_time = time.monotonic()
292
+ try:
293
+ # Execute single call
294
+ executor_kwargs = {"timeout": timeout}
295
+ if hasattr(self.executor, "use_cache"):
296
+ executor_kwargs["use_cache"] = use_cache
297
+
298
+ result_list = await self.executor.execute([call], **executor_kwargs)
299
+ result = result_list[0]
300
+
301
+ # Check if successful
302
+ duration = time.monotonic() - start_time
303
+
304
+ # Determine success/failure
305
+ is_timeout = state.config.timeout_threshold is not None and duration > state.config.timeout_threshold
306
+ is_error = result.error is not None
307
+
308
+ if is_error or is_timeout:
309
+ await state.record_failure()
310
+ # Record circuit breaker failure metric
311
+ if metrics:
312
+ metrics.record_circuit_breaker_failure(call.tool)
313
+ else:
314
+ await state.record_success()
315
+
316
+ results.append(result)
317
+
318
+ except Exception as e:
319
+ # Exception during execution
320
+ await state.record_failure()
321
+
322
+ now = datetime.now(UTC)
323
+ results.append(
324
+ ToolResult(
325
+ tool=call.tool,
326
+ result=None,
327
+ error=f"Circuit breaker caught exception: {str(e)}",
328
+ start_time=now,
329
+ end_time=now,
330
+ machine="circuit_breaker",
331
+ pid=0,
332
+ )
333
+ )
334
+
335
+ finally:
336
+ # Release HALF_OPEN slot if applicable
337
+ if state.state == CircuitState.HALF_OPEN:
338
+ await state.release_half_open_slot()
339
+
340
+ return results
341
+
342
+ async def get_circuit_states(self) -> dict[str, dict[str, Any]]:
343
+ """
344
+ Get current state of all circuit breakers.
345
+
346
+ Returns:
347
+ Dict mapping tool name to state info
348
+ """
349
+ states = {}
350
+ async with self._states_lock:
351
+ for tool, state in self._states.items():
352
+ states[tool] = state.get_state()
353
+ return states
354
+
355
+ async def reset_circuit(self, tool: str) -> None:
356
+ """
357
+ Manually reset a circuit breaker.
358
+
359
+ Args:
360
+ tool: Tool name to reset
361
+ """
362
+ if tool in self._states:
363
+ state = self._states[tool]
364
+ async with state._lock:
365
+ state.state = CircuitState.CLOSED
366
+ state.failure_count = 0
367
+ state.success_count = 0
368
+ state.opened_at = None
369
+ state.half_open_calls = 0
370
+ logger.info(f"Manually reset circuit breaker for {tool}")
@@ -7,44 +7,64 @@ Two layers of limits are enforced:
7
7
  * **Global** - ``<N requests> / <period>`` over *all* tools.
8
8
  * **Per-tool** - independent ``<N requests> / <period>`` windows.
9
9
 
10
- A simple sliding-window algorithm with timestamp queues is used.
10
+ A simple sliding-window algorithm with timestamp queues is used.
11
11
  `asyncio.Lock` guards shared state so the wrapper can be used safely from
12
12
  multiple coroutines.
13
13
  """
14
+
14
15
  from __future__ import annotations
15
16
 
16
17
  import asyncio
17
18
  import inspect
18
19
  import time
19
- from typing import Any, Dict, List, Optional, Tuple, Union
20
+ from typing import Any
20
21
 
22
+ from chuk_tool_processor.logging import get_logger
21
23
  from chuk_tool_processor.models.tool_call import ToolCall
22
24
  from chuk_tool_processor.models.tool_result import ToolResult
23
- from chuk_tool_processor.logging import get_logger
24
25
 
25
26
  logger = get_logger("chuk_tool_processor.execution.wrappers.rate_limiting")
26
27
 
28
+ # Optional observability imports
29
+ try:
30
+ from chuk_tool_processor.observability.metrics import get_metrics
31
+ from chuk_tool_processor.observability.tracing import trace_rate_limit
32
+
33
+ _observability_available = True
34
+ except ImportError:
35
+ _observability_available = False
36
+
37
+ # No-op functions when observability not available
38
+ def get_metrics():
39
+ return None
40
+
41
+ def trace_rate_limit(*_args, **_kwargs):
42
+ from contextlib import nullcontext
43
+
44
+ return nullcontext()
45
+
46
+
27
47
  # --------------------------------------------------------------------------- #
28
48
  # Core limiter
29
49
  # --------------------------------------------------------------------------- #
30
50
  class RateLimiter:
31
51
  """
32
52
  Async-native rate limiter for controlling execution frequency.
33
-
53
+
34
54
  Implements a sliding window algorithm to enforce rate limits both globally
35
55
  and per-tool. All operations are thread-safe using asyncio locks.
36
56
  """
37
-
57
+
38
58
  def __init__(
39
59
  self,
40
60
  *,
41
- global_limit: Optional[int] = None,
61
+ global_limit: int | None = None,
42
62
  global_period: float = 60.0,
43
- tool_limits: Optional[Dict[str, Tuple[int, float]]] = None,
63
+ tool_limits: dict[str, tuple[int, float]] | None = None,
44
64
  ) -> None:
45
65
  """
46
66
  Initialize the rate limiter.
47
-
67
+
48
68
  Args:
49
69
  global_limit: Maximum global requests per period (None = no limit)
50
70
  global_period: Time period in seconds for the global limit
@@ -55,13 +75,13 @@ class RateLimiter:
55
75
  self.tool_limits = tool_limits or {}
56
76
 
57
77
  # Timestamp queues
58
- self._global_ts: List[float] = []
59
- self._tool_ts: Dict[str, List[float]] = {}
78
+ self._global_ts: list[float] = []
79
+ self._tool_ts: dict[str, list[float]] = {}
60
80
 
61
81
  # Locks for thread safety
62
82
  self._global_lock = asyncio.Lock()
63
- self._tool_locks: Dict[str, asyncio.Lock] = {}
64
-
83
+ self._tool_locks: dict[str, asyncio.Lock] = {}
84
+
65
85
  logger.debug(
66
86
  f"Initialized rate limiter: global={global_limit}/{global_period}s, "
67
87
  f"tool-specific={len(self.tool_limits)} tools"
@@ -77,7 +97,7 @@ class RateLimiter:
77
97
  async with self._global_lock:
78
98
  now = time.monotonic()
79
99
  cutoff = now - self.global_period
80
-
100
+
81
101
  # Prune expired timestamps
82
102
  self._global_ts = [t for t in self._global_ts if t > cutoff]
83
103
 
@@ -88,7 +108,7 @@ class RateLimiter:
88
108
 
89
109
  # Calculate wait time until a slot becomes available
90
110
  wait = (self._global_ts[0] + self.global_period) - now
91
-
111
+
92
112
  logger.debug(f"Global rate limit reached, waiting {wait:.2f}s")
93
113
  await asyncio.sleep(wait)
94
114
 
@@ -105,7 +125,7 @@ class RateLimiter:
105
125
  async with lock:
106
126
  now = time.monotonic()
107
127
  cutoff = now - period
108
-
128
+
109
129
  # Prune expired timestamps in-place
110
130
  buf[:] = [t for t in buf if t > cutoff]
111
131
 
@@ -116,7 +136,7 @@ class RateLimiter:
116
136
 
117
137
  # Calculate wait time until a slot becomes available
118
138
  wait = (buf[0] + period) - now
119
-
139
+
120
140
  logger.debug(f"Tool '{tool}' rate limit reached, waiting {wait:.2f}s")
121
141
  await asyncio.sleep(wait)
122
142
 
@@ -124,32 +144,32 @@ class RateLimiter:
124
144
  async def wait(self, tool: str) -> None:
125
145
  """
126
146
  Block until rate limits allow execution.
127
-
147
+
128
148
  This method blocks until both global and tool-specific rate limits
129
149
  allow one more execution of the specified tool.
130
-
150
+
131
151
  Args:
132
152
  tool: Name of the tool being executed
133
153
  """
134
154
  await self._acquire_global()
135
155
  await self._acquire_tool(tool)
136
-
137
- async def check_limits(self, tool: str) -> Tuple[bool, bool]:
156
+
157
+ async def check_limits(self, tool: str) -> tuple[bool, bool]:
138
158
  """
139
159
  Check if the tool would be rate limited without consuming a slot.
140
-
160
+
141
161
  This is a non-blocking method useful for checking limits without
142
162
  affecting the rate limiting state.
143
-
163
+
144
164
  Args:
145
165
  tool: Name of the tool to check
146
-
166
+
147
167
  Returns:
148
168
  Tuple of (global_limit_reached, tool_limit_reached)
149
169
  """
150
170
  global_limited = False
151
171
  tool_limited = False
152
-
172
+
153
173
  # Check global limit
154
174
  if self.global_limit is not None:
155
175
  async with self._global_lock:
@@ -157,7 +177,7 @@ class RateLimiter:
157
177
  cutoff = now - self.global_period
158
178
  active_ts = [t for t in self._global_ts if t > cutoff]
159
179
  global_limited = len(active_ts) >= self.global_limit
160
-
180
+
161
181
  # Check tool limit
162
182
  if tool in self.tool_limits:
163
183
  limit, period = self.tool_limits[tool]
@@ -167,7 +187,7 @@ class RateLimiter:
167
187
  buf = self._tool_ts.get(tool, [])
168
188
  active_ts = [t for t in buf if t > cutoff]
169
189
  tool_limited = len(active_ts) >= limit
170
-
190
+
171
191
  return global_limited, tool_limited
172
192
 
173
193
 
@@ -177,7 +197,7 @@ class RateLimiter:
177
197
  class RateLimitedToolExecutor:
178
198
  """
179
199
  Executor wrapper that applies rate limiting to tool executions.
180
-
200
+
181
201
  This wrapper delegates to another executor but ensures that all
182
202
  tool calls respect the configured rate limits.
183
203
  """
@@ -185,48 +205,60 @@ class RateLimitedToolExecutor:
185
205
  def __init__(self, executor: Any, limiter: RateLimiter) -> None:
186
206
  """
187
207
  Initialize the rate-limited executor.
188
-
208
+
189
209
  Args:
190
210
  executor: The underlying executor to wrap
191
211
  limiter: The RateLimiter that controls execution frequency
192
212
  """
193
213
  self.executor = executor
194
214
  self.limiter = limiter
195
- logger.debug(f"Initialized rate-limited executor")
215
+ logger.debug("Initialized rate-limited executor")
196
216
 
197
217
  async def execute(
198
218
  self,
199
- calls: List[ToolCall],
200
- timeout: Optional[float] = None,
219
+ calls: list[ToolCall],
220
+ timeout: float | None = None,
201
221
  use_cache: bool = True,
202
- ) -> List[ToolResult]:
222
+ ) -> list[ToolResult]:
203
223
  """
204
224
  Execute tool calls while respecting rate limits.
205
-
225
+
206
226
  This method blocks until rate limits allow execution, then delegates
207
227
  to the underlying executor.
208
-
228
+
209
229
  Args:
210
230
  calls: List of tool calls to execute
211
231
  timeout: Optional timeout for execution
212
232
  use_cache: Whether to use cached results (forwarded to underlying executor)
213
-
233
+
214
234
  Returns:
215
235
  List of tool results
216
236
  """
217
237
  if not calls:
218
238
  return []
219
-
239
+
220
240
  # Block for each call *before* dispatching to the wrapped executor
241
+ metrics = get_metrics()
242
+
221
243
  for c in calls:
222
- await self.limiter.wait(c.tool)
223
-
244
+ # Check limits first for metrics
245
+ global_limited, tool_limited = await self.limiter.check_limits(c.tool)
246
+ allowed = not (global_limited or tool_limited)
247
+
248
+ # Trace rate limit check
249
+ with trace_rate_limit(c.tool, allowed):
250
+ await self.limiter.wait(c.tool)
251
+
252
+ # Record metrics
253
+ if metrics:
254
+ metrics.record_rate_limit_check(c.tool, allowed)
255
+
224
256
  # Check if the executor has a use_cache parameter
225
257
  if hasattr(self.executor, "execute"):
226
258
  sig = inspect.signature(self.executor.execute)
227
259
  if "use_cache" in sig.parameters:
228
260
  return await self.executor.execute(calls, timeout=timeout, use_cache=use_cache)
229
-
261
+
230
262
  # Fall back to standard execute method
231
263
  return await self.executor.execute(calls, timeout=timeout)
232
264
 
@@ -237,7 +269,7 @@ class RateLimitedToolExecutor:
237
269
  def rate_limited(limit: int, period: float = 60.0):
238
270
  """
239
271
  Class decorator that marks a Tool with default rate-limit metadata.
240
-
272
+
241
273
  This allows higher-level code to detect and configure rate limiting
242
274
  for the tool class.
243
275
 
@@ -246,17 +278,18 @@ def rate_limited(limit: int, period: float = 60.0):
246
278
  class WeatherTool:
247
279
  async def execute(self, location: str) -> Dict[str, Any]:
248
280
  # Implementation
249
-
281
+
250
282
  Args:
251
283
  limit: Maximum number of calls allowed in the period
252
284
  period: Time period in seconds
253
-
285
+
254
286
  Returns:
255
287
  Decorated class with rate limit metadata
256
288
  """
289
+
257
290
  def decorator(cls):
258
291
  cls._rate_limit = limit
259
292
  cls._rate_period = period
260
293
  return cls
261
294
 
262
- return decorator
295
+ return decorator