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,103 +1,563 @@
1
- # chuk_tool_processor/execution/subprocess_strategy.py
1
+ # chuk_tool_processor/execution/strategies/subprocess_strategy.py
2
+ """
3
+ Subprocess execution strategy - truly runs tools in separate OS processes.
4
+
5
+ This strategy executes tools in separate Python processes using a process pool,
6
+ providing isolation and potentially better parallelism on multi-core systems.
7
+ """
8
+ from __future__ import annotations
9
+
2
10
  import asyncio
3
- from chuk_tool_processor.execution.strategies.inprocess_strategy import InProcessStrategy
4
- import os
5
- import importlib
11
+ import concurrent.futures
12
+ import functools
6
13
  import inspect
14
+ import os
15
+ import pickle
16
+ import signal
17
+ import sys
18
+ import traceback
7
19
  from datetime import datetime, timezone
8
- from typing import List, Optional, Dict, Any
9
- from concurrent.futures import ProcessPoolExecutor
20
+ from typing import Any, AsyncIterator, Dict, List, Optional, Tuple, Set
10
21
 
11
- # imports
12
22
  from chuk_tool_processor.models.execution_strategy import ExecutionStrategy
13
23
  from chuk_tool_processor.models.tool_call import ToolCall
14
24
  from chuk_tool_processor.models.tool_result import ToolResult
15
- from chuk_tool_processor.logging import get_logger
25
+ from chuk_tool_processor.registry.interface import ToolRegistryInterface
26
+ from chuk_tool_processor.logging import get_logger, log_context_span
16
27
 
17
28
  logger = get_logger("chuk_tool_processor.execution.subprocess_strategy")
18
29
 
19
- # Define a top-level function for subprocess execution
20
- def _execute_tool_in_process(tool_data: Dict[str, Any]) -> Dict[str, Any]:
21
- """
22
- Execute a tool in a separate process.
23
30
 
24
- Args:
25
- tool_data: Dictionary with:
26
- - tool_name: Name of the tool
27
- - module_name: Module containing the tool class
28
- - class_name: Name of the tool class
29
- - arguments: Arguments for the tool
30
- - is_async: Whether the tool's execute is async
31
+ # --------------------------------------------------------------------------- #
32
+ # Module-level helper functions for worker processes - these must be at the module
33
+ # level so they can be pickled
34
+ # --------------------------------------------------------------------------- #
35
+ def _init_worker():
36
+ """Initialize worker process with signal handlers."""
37
+ # Ignore keyboard interrupt in workers
38
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
39
+
40
+
41
+ def _pool_test_func():
42
+ """Simple function to test if the process pool is working."""
43
+ return "ok"
44
+
31
45
 
46
+ def _process_worker(
47
+ tool_name: str,
48
+ namespace: str,
49
+ module_name: str,
50
+ class_name: str,
51
+ arguments: Dict[str, Any],
52
+ timeout: Optional[float]
53
+ ) -> Dict[str, Any]:
54
+ """
55
+ Worker function that runs in a separate process.
56
+
57
+ Args:
58
+ tool_name: Name of the tool
59
+ namespace: Namespace of the tool
60
+ module_name: Module containing the tool class
61
+ class_name: Name of the tool class
62
+ arguments: Arguments to pass to the tool
63
+ timeout: Optional timeout in seconds
64
+
32
65
  Returns:
33
- A dict containing result, error, start_time, end_time, pid, machine.
66
+ Serialized result data
34
67
  """
35
- # Extract data
36
- tool_name = tool_data.get("tool_name", "unknown")
37
- module_name = tool_data.get("module_name")
38
- class_name = tool_data.get("class_name")
39
- arguments = tool_data.get("arguments", {})
40
- is_async = tool_data.get("is_async", False)
41
-
68
+ import asyncio
69
+ import importlib
70
+ import inspect
71
+ import os
72
+ import sys
73
+ import time
74
+ from datetime import datetime, timezone
75
+
42
76
  start_time = datetime.now(timezone.utc)
43
77
  pid = os.getpid()
44
- machine = os.uname().nodename
45
- result_data = {"result": None, "error": None, "start_time": start_time, "end_time": None, "pid": pid, "machine": machine}
46
-
78
+ hostname = os.uname().nodename
79
+
80
+ # Data for the result
81
+ result_data = {
82
+ "tool": tool_name,
83
+ "namespace": namespace,
84
+ "start_time": start_time.isoformat(),
85
+ "end_time": None,
86
+ "machine": hostname,
87
+ "pid": pid,
88
+ "result": None,
89
+ "error": None,
90
+ }
91
+
47
92
  try:
93
+ # Import the module
48
94
  if not module_name or not class_name:
49
- result_data["error"] = f"Missing module_name or class_name for tool {tool_name}"
95
+ raise ValueError("Missing module or class name")
96
+
97
+ # Import the module
98
+ try:
99
+ module = importlib.import_module(module_name)
100
+ except ImportError as e:
101
+ result_data["error"] = f"Failed to import module {module_name}: {str(e)}"
102
+ result_data["end_time"] = datetime.now(timezone.utc).isoformat()
50
103
  return result_data
51
-
52
- # Load the tool class
53
- module = importlib.import_module(module_name)
54
- tool_class = getattr(module, class_name, None)
55
- if tool_class is None:
56
- result_data["error"] = f"Class {class_name} not found in module {module_name}"
104
+
105
+ # Get the class or function
106
+ try:
107
+ tool_class = getattr(module, class_name)
108
+ except AttributeError as e:
109
+ result_data["error"] = f"Failed to find {class_name} in {module_name}: {str(e)}"
110
+ result_data["end_time"] = datetime.now(timezone.utc).isoformat()
57
111
  return result_data
112
+
113
+ # Instantiate the tool
114
+ tool_instance = tool_class() if inspect.isclass(tool_class) else tool_class
58
115
 
59
- tool_instance = tool_class()
60
- # Determine execution path
61
- if is_async:
62
- import asyncio as _asyncio
63
- loop = _asyncio.new_event_loop()
64
- _asyncio.set_event_loop(loop)
65
- try:
66
- result_data["result"] = loop.run_until_complete(tool_instance.execute(**arguments))
67
- finally:
68
- loop.close()
116
+ # Find the execute method
117
+ if hasattr(tool_instance, "_aexecute") and inspect.iscoroutinefunction(
118
+ getattr(tool_instance.__class__, "_aexecute", None)
119
+ ):
120
+ execute_fn = tool_instance._aexecute
121
+ elif hasattr(tool_instance, "execute") and inspect.iscoroutinefunction(
122
+ getattr(tool_instance.__class__, "execute", None)
123
+ ):
124
+ execute_fn = tool_instance.execute
69
125
  else:
70
- result_data["result"] = tool_instance.execute(**arguments)
126
+ result_data["error"] = "Tool must have an async execute or _aexecute method"
127
+ result_data["end_time"] = datetime.now(timezone.utc).isoformat()
128
+ return result_data
129
+
130
+ # Create a new event loop for this process
131
+ loop = asyncio.new_event_loop()
132
+ asyncio.set_event_loop(loop)
133
+
134
+ try:
135
+ # Execute the tool with timeout
136
+ if timeout:
137
+ result_value = loop.run_until_complete(
138
+ asyncio.wait_for(execute_fn(**arguments), timeout)
139
+ )
140
+ else:
141
+ result_value = loop.run_until_complete(execute_fn(**arguments))
142
+
143
+ # Store the result
144
+ result_data["result"] = result_value
145
+
146
+ except asyncio.TimeoutError:
147
+ result_data["error"] = f"Execution timed out after {timeout}s"
148
+ except Exception as e:
149
+ result_data["error"] = f"Error during execution: {str(e)}"
150
+
151
+ finally:
152
+ # Clean up the loop
153
+ loop.close()
154
+
71
155
  except Exception as e:
72
- result_data["error"] = str(e)
73
- finally:
74
- result_data["end_time"] = datetime.now(timezone.utc)
156
+ # Catch any other exceptions
157
+ result_data["error"] = f"Unexpected error: {str(e)}"
158
+
159
+ # Set end time
160
+ result_data["end_time"] = datetime.now(timezone.utc).isoformat()
75
161
  return result_data
76
162
 
77
163
 
164
+ # --------------------------------------------------------------------------- #
165
+ # The subprocess strategy
166
+ # --------------------------------------------------------------------------- #
78
167
  class SubprocessStrategy(ExecutionStrategy):
79
168
  """
80
- Executes tool calls in-process via InProcessStrategy for compatibility with local tool definitions and tests.
169
+ Execute tools in separate processes for isolation and parallelism.
170
+
171
+ This strategy creates a pool of worker processes and distributes tool calls
172
+ among them. Each tool executes in its own process, providing isolation and
173
+ parallelism.
81
174
  """
82
- def __init__(self, registry, max_workers: int = 4, default_timeout: Optional[float] = None):
175
+
176
+ def __init__(
177
+ self,
178
+ registry: ToolRegistryInterface,
179
+ *,
180
+ max_workers: int = 4,
181
+ default_timeout: Optional[float] = None,
182
+ worker_init_timeout: float = 5.0,
183
+ ) -> None:
83
184
  """
84
- Initialize with in-process strategy delegation.
185
+ Initialize the subprocess execution strategy.
186
+
187
+ Args:
188
+ registry: Tool registry for tool lookups
189
+ max_workers: Maximum number of worker processes
190
+ default_timeout: Default timeout for tool execution
191
+ worker_init_timeout: Timeout for worker process initialization
85
192
  """
86
193
  self.registry = registry
194
+ self.max_workers = max_workers
87
195
  self.default_timeout = default_timeout
88
- # Use InProcessStrategy to execute calls directly
89
- self._strategy = InProcessStrategy(
90
- registry=registry,
91
- default_timeout=default_timeout,
92
- max_concurrency=max_workers
93
- )
196
+ self.worker_init_timeout = worker_init_timeout
197
+
198
+ # Process pool (initialized lazily)
199
+ self._process_pool: Optional[concurrent.futures.ProcessPoolExecutor] = None
200
+ self._pool_lock = asyncio.Lock()
201
+
202
+ # Task tracking for cleanup
203
+ self._active_tasks: Set[asyncio.Task] = set()
204
+ self._shutdown_event = asyncio.Event()
205
+ self._shutting_down = False
206
+
207
+ # Register shutdown handler if in main thread
208
+ try:
209
+ loop = asyncio.get_running_loop()
210
+ for sig in (signal.SIGTERM, signal.SIGINT):
211
+ loop.add_signal_handler(
212
+ sig, lambda s=sig: asyncio.create_task(self._signal_handler(s))
213
+ )
214
+ except (RuntimeError, NotImplementedError):
215
+ # Not in the main thread or not on Unix
216
+ pass
94
217
 
218
+ async def _ensure_pool(self) -> None:
219
+ """Initialize the process pool if not already initialized."""
220
+ if self._process_pool is not None:
221
+ return
222
+
223
+ async with self._pool_lock:
224
+ if self._process_pool is not None:
225
+ return
226
+
227
+ # Create process pool
228
+ self._process_pool = concurrent.futures.ProcessPoolExecutor(
229
+ max_workers=self.max_workers,
230
+ initializer=_init_worker,
231
+ )
232
+
233
+ # Test the pool with a simple task
234
+ loop = asyncio.get_running_loop()
235
+ try:
236
+ # Use a module-level function instead of a lambda
237
+ await asyncio.wait_for(
238
+ loop.run_in_executor(self._process_pool, _pool_test_func),
239
+ timeout=self.worker_init_timeout
240
+ )
241
+ logger.info(f"Process pool initialized with {self.max_workers} workers")
242
+ except Exception as e:
243
+ # Clean up on initialization error
244
+ self._process_pool.shutdown(wait=False)
245
+ self._process_pool = None
246
+ logger.error(f"Failed to initialize process pool: {e}")
247
+ raise RuntimeError(f"Failed to initialize process pool: {e}") from e
248
+
249
+ # ------------------------------------------------------------------ #
250
+ # 🔌 legacy façade for older wrappers #
251
+ # ------------------------------------------------------------------ #
252
+ async def execute(
253
+ self,
254
+ calls: List[ToolCall],
255
+ *,
256
+ timeout: Optional[float] = None,
257
+ ) -> List[ToolResult]:
258
+ """
259
+ Back-compat shim.
260
+
261
+ Old wrappers (`retry`, `rate_limit`, `cache`, …) still expect an
262
+ ``execute()`` coroutine on an execution-strategy object.
263
+ The real implementation lives in :meth:`run`, so we just forward.
264
+ """
265
+ return await self.run(calls, timeout)
266
+
95
267
  async def run(
96
268
  self,
97
269
  calls: List[ToolCall],
98
- timeout: Optional[float] = None
270
+ timeout: Optional[float] = None,
99
271
  ) -> List[ToolResult]:
100
272
  """
101
- Execute tool calls using in-process strategy.
273
+ Execute tool calls in separate processes.
274
+
275
+ Args:
276
+ calls: List of tool calls to execute
277
+ timeout: Optional timeout for each execution (overrides default)
278
+
279
+ Returns:
280
+ List of tool results in the same order as calls
281
+ """
282
+ if not calls:
283
+ return []
284
+
285
+ if self._shutting_down:
286
+ # Return early with error results if shutting down
287
+ return [
288
+ ToolResult(
289
+ tool=call.tool,
290
+ result=None,
291
+ error="System is shutting down",
292
+ start_time=datetime.now(timezone.utc),
293
+ end_time=datetime.now(timezone.utc),
294
+ machine=os.uname().nodename,
295
+ pid=os.getpid(),
296
+ )
297
+ for call in calls
298
+ ]
299
+
300
+ # Create tasks for each call
301
+ tasks = []
302
+ for call in calls:
303
+ task = asyncio.create_task(self._execute_single_call(
304
+ call, timeout or self.default_timeout
305
+ ))
306
+ self._active_tasks.add(task)
307
+ task.add_done_callback(self._active_tasks.discard)
308
+ tasks.append(task)
309
+
310
+ # Execute all tasks concurrently
311
+ async with log_context_span("subprocess_execution", {"num_calls": len(calls)}):
312
+ return await asyncio.gather(*tasks)
313
+
314
+ async def stream_run(
315
+ self,
316
+ calls: List[ToolCall],
317
+ timeout: Optional[float] = None,
318
+ ) -> AsyncIterator[ToolResult]:
319
+ """
320
+ Execute tool calls and yield results as they become available.
321
+
322
+ Args:
323
+ calls: List of tool calls to execute
324
+ timeout: Optional timeout for each execution
325
+
326
+ Yields:
327
+ Tool results as they complete (not necessarily in order)
328
+ """
329
+ if not calls:
330
+ return
331
+
332
+ if self._shutting_down:
333
+ # Yield error results if shutting down
334
+ for call in calls:
335
+ yield ToolResult(
336
+ tool=call.tool,
337
+ result=None,
338
+ error="System is shutting down",
339
+ start_time=datetime.now(timezone.utc),
340
+ end_time=datetime.now(timezone.utc),
341
+ machine=os.uname().nodename,
342
+ pid=os.getpid(),
343
+ )
344
+ return
345
+
346
+ # Create a queue for results
347
+ queue = asyncio.Queue()
348
+
349
+ # Start all executions and have them put results in the queue
350
+ pending = set()
351
+ for call in calls:
352
+ task = asyncio.create_task(self._execute_to_queue(
353
+ call, queue, timeout or self.default_timeout
354
+ ))
355
+ self._active_tasks.add(task)
356
+ task.add_done_callback(self._active_tasks.discard)
357
+ pending.add(task)
358
+
359
+ # Yield results as they become available
360
+ while pending:
361
+ # Get next result from queue
362
+ result = await queue.get()
363
+ yield result
364
+
365
+ # Check for completed tasks
366
+ done, pending = await asyncio.wait(
367
+ pending, timeout=0, return_when=asyncio.FIRST_COMPLETED
368
+ )
369
+
370
+ # Handle any exceptions
371
+ for task in done:
372
+ try:
373
+ await task
374
+ except Exception as e:
375
+ logger.exception(f"Error in task: {e}")
376
+
377
+ async def _execute_to_queue(
378
+ self,
379
+ call: ToolCall,
380
+ queue: asyncio.Queue,
381
+ timeout: Optional[float],
382
+ ) -> None:
383
+ """Execute a single call and put the result in the queue."""
384
+ result = await self._execute_single_call(call, timeout)
385
+ await queue.put(result)
386
+
387
+ async def _execute_single_call(
388
+ self,
389
+ call: ToolCall,
390
+ timeout: Optional[float],
391
+ ) -> ToolResult:
392
+ """
393
+ Execute a single tool call in a separate process.
394
+
395
+ Args:
396
+ call: Tool call to execute
397
+ timeout: Optional timeout in seconds
398
+
399
+ Returns:
400
+ Tool execution result
401
+ """
402
+ start_time = datetime.now(timezone.utc)
403
+
404
+ try:
405
+ # Ensure pool is initialized
406
+ await self._ensure_pool()
407
+
408
+ # Get tool from registry
409
+ tool_impl = await self.registry.get_tool(call.tool, call.namespace)
410
+ if tool_impl is None:
411
+ return ToolResult(
412
+ tool=call.tool,
413
+ result=None,
414
+ error=f"Tool '{call.tool}' not found",
415
+ start_time=start_time,
416
+ end_time=datetime.now(timezone.utc),
417
+ machine=os.uname().nodename,
418
+ pid=os.getpid(),
419
+ )
420
+
421
+ # Get module and class names for import in worker process
422
+ if inspect.isclass(tool_impl):
423
+ module_name = tool_impl.__module__
424
+ class_name = tool_impl.__name__
425
+ else:
426
+ module_name = tool_impl.__class__.__module__
427
+ class_name = tool_impl.__class__.__name__
428
+
429
+ # Execute in subprocess
430
+ loop = asyncio.get_running_loop()
431
+
432
+ # We need to add safety timeout here to handle process crashes
433
+ safety_timeout = (timeout or self.default_timeout or 60.0) + 5.0
434
+
435
+ try:
436
+ result_data = await asyncio.wait_for(
437
+ loop.run_in_executor(
438
+ self._process_pool,
439
+ functools.partial(
440
+ _process_worker,
441
+ call.tool,
442
+ call.namespace,
443
+ module_name,
444
+ class_name,
445
+ call.arguments,
446
+ timeout
447
+ )
448
+ ),
449
+ timeout=safety_timeout
450
+ )
451
+
452
+ # Parse timestamps
453
+ if isinstance(result_data["start_time"], str):
454
+ start_time_str = result_data["start_time"]
455
+ result_data["start_time"] = datetime.fromisoformat(start_time_str)
456
+
457
+ if isinstance(result_data["end_time"], str):
458
+ end_time_str = result_data["end_time"]
459
+ result_data["end_time"] = datetime.fromisoformat(end_time_str)
460
+
461
+ # Create ToolResult from worker data
462
+ return ToolResult(
463
+ tool=result_data.get("tool", call.tool),
464
+ result=result_data.get("result"),
465
+ error=result_data.get("error"),
466
+ start_time=result_data.get("start_time", start_time),
467
+ end_time=result_data.get("end_time", datetime.now(timezone.utc)),
468
+ machine=result_data.get("machine", os.uname().nodename),
469
+ pid=result_data.get("pid", os.getpid()),
470
+ )
471
+
472
+ except asyncio.TimeoutError:
473
+ # This happens if the worker process itself hangs
474
+ return ToolResult(
475
+ tool=call.tool,
476
+ result=None,
477
+ error=f"Worker process timed out after {safety_timeout}s",
478
+ start_time=start_time,
479
+ end_time=datetime.now(timezone.utc),
480
+ machine=os.uname().nodename,
481
+ pid=os.getpid(),
482
+ )
483
+
484
+ except concurrent.futures.process.BrokenProcessPool:
485
+ # Process pool broke - need to recreate it
486
+ logger.error("Process pool broke during execution - recreating")
487
+ if self._process_pool:
488
+ self._process_pool.shutdown(wait=False)
489
+ self._process_pool = None
490
+
491
+ return ToolResult(
492
+ tool=call.tool,
493
+ result=None,
494
+ error="Worker process crashed",
495
+ start_time=start_time,
496
+ end_time=datetime.now(timezone.utc),
497
+ machine=os.uname().nodename,
498
+ pid=os.getpid(),
499
+ )
500
+
501
+ except asyncio.CancelledError:
502
+ # Handle cancellation
503
+ return ToolResult(
504
+ tool=call.tool,
505
+ result=None,
506
+ error="Execution was cancelled",
507
+ start_time=start_time,
508
+ end_time=datetime.now(timezone.utc),
509
+ machine=os.uname().nodename,
510
+ pid=os.getpid(),
511
+ )
512
+
513
+ except Exception as e:
514
+ # Handle any other errors
515
+ logger.exception(f"Error executing {call.tool} in subprocess: {e}")
516
+ return ToolResult(
517
+ tool=call.tool,
518
+ result=None,
519
+ error=f"Error: {str(e)}",
520
+ start_time=start_time,
521
+ end_time=datetime.now(timezone.utc),
522
+ machine=os.uname().nodename,
523
+ pid=os.getpid(),
524
+ )
525
+
526
+ @property
527
+ def supports_streaming(self) -> bool:
528
+ """Check if this strategy supports streaming execution."""
529
+ return True
530
+
531
+ async def _signal_handler(self, sig: int) -> None:
532
+ """Handle termination signals."""
533
+ signame = signal.Signals(sig).name
534
+ logger.info(f"Received {signame}, shutting down process pool")
535
+ await self.shutdown()
536
+
537
+ async def shutdown(self) -> None:
538
+ """
539
+ Gracefully shut down the process pool.
540
+
541
+ This cancels all active tasks and shuts down the process pool.
102
542
  """
103
- return await self._strategy.run(calls, timeout=timeout)
543
+ if self._shutting_down:
544
+ return
545
+
546
+ self._shutting_down = True
547
+ self._shutdown_event.set()
548
+
549
+ # Cancel all active tasks
550
+ active_tasks = list(self._active_tasks)
551
+ if active_tasks:
552
+ logger.info(f"Cancelling {len(active_tasks)} active tool executions")
553
+ for task in active_tasks:
554
+ task.cancel()
555
+
556
+ # Wait for all tasks to complete (with cancellation)
557
+ await asyncio.gather(*active_tasks, return_exceptions=True)
558
+
559
+ # Shut down the process pool
560
+ if self._process_pool:
561
+ logger.info("Shutting down process pool")
562
+ self._process_pool.shutdown(wait=True)
563
+ self._process_pool = None