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.
- chuk_tool_processor/core/processor.py +345 -132
- chuk_tool_processor/execution/strategies/inprocess_strategy.py +512 -68
- chuk_tool_processor/execution/strategies/subprocess_strategy.py +523 -63
- chuk_tool_processor/execution/tool_executor.py +282 -24
- chuk_tool_processor/execution/wrappers/caching.py +465 -123
- chuk_tool_processor/execution/wrappers/rate_limiting.py +199 -86
- chuk_tool_processor/execution/wrappers/retry.py +133 -23
- chuk_tool_processor/logging/__init__.py +83 -10
- chuk_tool_processor/logging/context.py +218 -22
- chuk_tool_processor/logging/formatter.py +56 -13
- chuk_tool_processor/logging/helpers.py +91 -16
- chuk_tool_processor/logging/metrics.py +75 -6
- chuk_tool_processor/mcp/mcp_tool.py +80 -35
- chuk_tool_processor/mcp/register_mcp_tools.py +74 -56
- chuk_tool_processor/mcp/setup_mcp_sse.py +41 -36
- chuk_tool_processor/mcp/setup_mcp_stdio.py +39 -37
- chuk_tool_processor/models/execution_strategy.py +52 -3
- chuk_tool_processor/models/streaming_tool.py +110 -0
- chuk_tool_processor/models/tool_call.py +56 -4
- chuk_tool_processor/models/tool_result.py +115 -9
- chuk_tool_processor/models/validated_tool.py +15 -13
- chuk_tool_processor/plugins/discovery.py +115 -70
- chuk_tool_processor/plugins/parsers/base.py +13 -5
- chuk_tool_processor/plugins/parsers/{function_call_tool_plugin.py → function_call_tool.py} +39 -20
- chuk_tool_processor/plugins/parsers/json_tool.py +50 -0
- chuk_tool_processor/plugins/parsers/openai_tool.py +88 -0
- chuk_tool_processor/plugins/parsers/xml_tool.py +74 -20
- chuk_tool_processor/registry/__init__.py +46 -7
- chuk_tool_processor/registry/auto_register.py +92 -28
- chuk_tool_processor/registry/decorators.py +134 -11
- chuk_tool_processor/registry/interface.py +48 -14
- chuk_tool_processor/registry/metadata.py +52 -6
- chuk_tool_processor/registry/provider.py +75 -36
- chuk_tool_processor/registry/providers/__init__.py +49 -10
- chuk_tool_processor/registry/providers/memory.py +59 -48
- chuk_tool_processor/registry/tool_export.py +208 -39
- chuk_tool_processor/utils/validation.py +18 -13
- chuk_tool_processor-0.1.7.dist-info/METADATA +401 -0
- chuk_tool_processor-0.1.7.dist-info/RECORD +58 -0
- {chuk_tool_processor-0.1.6.dist-info → chuk_tool_processor-0.1.7.dist-info}/WHEEL +1 -1
- chuk_tool_processor/plugins/parsers/json_tool_plugin.py +0 -38
- chuk_tool_processor/plugins/parsers/openai_tool_plugin.py +0 -76
- chuk_tool_processor-0.1.6.dist-info/METADATA +0 -462
- chuk_tool_processor-0.1.6.dist-info/RECORD +0 -57
- {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
|
-
|
|
4
|
-
import
|
|
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,
|
|
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.
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
66
|
+
Serialized result data
|
|
34
67
|
"""
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
result_data["error"] = f"
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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["
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
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
|
-
|
|
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
|