chuk-tool-processor 0.6.12__py3-none-any.whl → 0.6.13__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 +1 -1
- chuk_tool_processor/core/exceptions.py +10 -4
- chuk_tool_processor/core/processor.py +97 -97
- chuk_tool_processor/execution/strategies/inprocess_strategy.py +142 -150
- chuk_tool_processor/execution/strategies/subprocess_strategy.py +200 -205
- chuk_tool_processor/execution/tool_executor.py +82 -84
- chuk_tool_processor/execution/wrappers/caching.py +102 -103
- chuk_tool_processor/execution/wrappers/rate_limiting.py +45 -42
- chuk_tool_processor/execution/wrappers/retry.py +23 -25
- 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 +24 -38
- chuk_tool_processor/logging/metrics.py +11 -13
- chuk_tool_processor/mcp/__init__.py +8 -12
- chuk_tool_processor/mcp/mcp_tool.py +124 -112
- chuk_tool_processor/mcp/register_mcp_tools.py +17 -17
- chuk_tool_processor/mcp/setup_mcp_http_streamable.py +11 -13
- chuk_tool_processor/mcp/setup_mcp_sse.py +11 -13
- chuk_tool_processor/mcp/setup_mcp_stdio.py +7 -9
- chuk_tool_processor/mcp/stream_manager.py +168 -204
- chuk_tool_processor/mcp/transport/__init__.py +4 -4
- chuk_tool_processor/mcp/transport/base_transport.py +43 -58
- chuk_tool_processor/mcp/transport/http_streamable_transport.py +145 -163
- chuk_tool_processor/mcp/transport/sse_transport.py +217 -255
- chuk_tool_processor/mcp/transport/stdio_transport.py +171 -189
- chuk_tool_processor/models/__init__.py +1 -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 +19 -34
- chuk_tool_processor/models/tool_export_mixin.py +22 -8
- chuk_tool_processor/models/tool_result.py +40 -77
- chuk_tool_processor/models/validated_tool.py +14 -16
- chuk_tool_processor/plugins/__init__.py +1 -1
- chuk_tool_processor/plugins/discovery.py +10 -10
- 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.6.12.dist-info → chuk_tool_processor-0.6.13.dist-info}/METADATA +1 -1
- chuk_tool_processor-0.6.13.dist-info/RECORD +60 -0
- chuk_tool_processor-0.6.12.dist-info/RECORD +0 -60
- {chuk_tool_processor-0.6.12.dist-info → chuk_tool_processor-0.6.13.dist-info}/WHEEL +0 -0
- {chuk_tool_processor-0.6.12.dist-info → chuk_tool_processor-0.6.13.dist-info}/top_level.txt +0 -0
|
@@ -7,31 +7,33 @@ This strategy executes tools in separate Python processes using a process pool,
|
|
|
7
7
|
providing isolation and potentially better parallelism on multi-core systems.
|
|
8
8
|
|
|
9
9
|
Enhanced tool name resolution that properly handles:
|
|
10
|
-
- Simple names: "get_current_time"
|
|
10
|
+
- Simple names: "get_current_time"
|
|
11
11
|
- Namespaced names: "diagnostic_test.get_current_time"
|
|
12
12
|
- Cross-namespace fallback searching
|
|
13
13
|
|
|
14
14
|
Properly handles tool serialization and ensures tool_name is preserved.
|
|
15
15
|
"""
|
|
16
|
+
|
|
16
17
|
from __future__ import annotations
|
|
17
18
|
|
|
18
19
|
import asyncio
|
|
19
20
|
import concurrent.futures
|
|
21
|
+
import contextlib
|
|
20
22
|
import functools
|
|
21
23
|
import inspect
|
|
22
24
|
import os
|
|
23
25
|
import pickle
|
|
26
|
+
import platform
|
|
24
27
|
import signal
|
|
25
|
-
import
|
|
26
|
-
import
|
|
27
|
-
from
|
|
28
|
-
from typing import Any, AsyncIterator, Dict, List, Optional, Tuple, Set
|
|
28
|
+
from collections.abc import AsyncIterator
|
|
29
|
+
from datetime import UTC, datetime
|
|
30
|
+
from typing import Any
|
|
29
31
|
|
|
32
|
+
from chuk_tool_processor.logging import get_logger, log_context_span
|
|
30
33
|
from chuk_tool_processor.models.execution_strategy import ExecutionStrategy
|
|
31
34
|
from chuk_tool_processor.models.tool_call import ToolCall
|
|
32
35
|
from chuk_tool_processor.models.tool_result import ToolResult
|
|
33
36
|
from chuk_tool_processor.registry.interface import ToolRegistryInterface
|
|
34
|
-
from chuk_tool_processor.logging import get_logger, log_context_span
|
|
35
37
|
|
|
36
38
|
logger = get_logger("chuk_tool_processor.execution.subprocess_strategy")
|
|
37
39
|
|
|
@@ -44,7 +46,7 @@ def _init_worker():
|
|
|
44
46
|
"""Initialize worker process with signal handlers."""
|
|
45
47
|
# Ignore keyboard interrupt in workers
|
|
46
48
|
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
47
|
-
|
|
49
|
+
|
|
48
50
|
|
|
49
51
|
def _pool_test_func():
|
|
50
52
|
"""Simple function to test if the process pool is working."""
|
|
@@ -52,38 +54,34 @@ def _pool_test_func():
|
|
|
52
54
|
|
|
53
55
|
|
|
54
56
|
def _serialized_tool_worker(
|
|
55
|
-
tool_name: str,
|
|
56
|
-
|
|
57
|
-
arguments: Dict[str, Any],
|
|
58
|
-
timeout: Optional[float],
|
|
59
|
-
serialized_tool_data: bytes
|
|
60
|
-
) -> Dict[str, Any]:
|
|
57
|
+
tool_name: str, namespace: str, arguments: dict[str, Any], timeout: float | None, serialized_tool_data: bytes
|
|
58
|
+
) -> dict[str, Any]:
|
|
61
59
|
"""
|
|
62
60
|
Worker function that uses serialized tools and ensures tool_name is available.
|
|
63
|
-
|
|
61
|
+
|
|
64
62
|
This worker deserializes the complete tool and executes it, with multiple
|
|
65
63
|
fallbacks to ensure tool_name is properly set.
|
|
66
|
-
|
|
64
|
+
|
|
67
65
|
Args:
|
|
68
66
|
tool_name: Name of the tool
|
|
69
67
|
namespace: Namespace of the tool
|
|
70
68
|
arguments: Arguments to pass to the tool
|
|
71
69
|
timeout: Optional timeout in seconds
|
|
72
70
|
serialized_tool_data: Pickled tool instance
|
|
73
|
-
|
|
71
|
+
|
|
74
72
|
Returns:
|
|
75
73
|
Serialized result data
|
|
76
74
|
"""
|
|
77
75
|
import asyncio
|
|
78
|
-
import pickle
|
|
79
|
-
import os
|
|
80
76
|
import inspect
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
77
|
+
import os
|
|
78
|
+
import pickle
|
|
79
|
+
from datetime import datetime
|
|
80
|
+
|
|
81
|
+
start_time = datetime.now(UTC)
|
|
84
82
|
pid = os.getpid()
|
|
85
|
-
hostname =
|
|
86
|
-
|
|
83
|
+
hostname = platform.node()
|
|
84
|
+
|
|
87
85
|
result_data = {
|
|
88
86
|
"tool": tool_name,
|
|
89
87
|
"namespace": namespace,
|
|
@@ -94,17 +92,17 @@ def _serialized_tool_worker(
|
|
|
94
92
|
"result": None,
|
|
95
93
|
"error": None,
|
|
96
94
|
}
|
|
97
|
-
|
|
95
|
+
|
|
98
96
|
try:
|
|
99
97
|
# Deserialize the complete tool
|
|
100
98
|
tool = pickle.loads(serialized_tool_data)
|
|
101
|
-
|
|
99
|
+
|
|
102
100
|
# Multiple fallbacks to ensure tool_name is available
|
|
103
|
-
|
|
101
|
+
|
|
104
102
|
# Fallback 1: If tool doesn't have tool_name, set it directly
|
|
105
|
-
if not hasattr(tool,
|
|
103
|
+
if not hasattr(tool, "tool_name") or not tool.tool_name:
|
|
106
104
|
tool.tool_name = tool_name
|
|
107
|
-
|
|
105
|
+
|
|
108
106
|
# Fallback 2: If it's a class instead of instance, instantiate it
|
|
109
107
|
if inspect.isclass(tool):
|
|
110
108
|
try:
|
|
@@ -112,46 +110,44 @@ def _serialized_tool_worker(
|
|
|
112
110
|
tool.tool_name = tool_name
|
|
113
111
|
except Exception as e:
|
|
114
112
|
result_data["error"] = f"Failed to instantiate tool class: {str(e)}"
|
|
115
|
-
result_data["end_time"] = datetime.now(
|
|
113
|
+
result_data["end_time"] = datetime.now(UTC).isoformat()
|
|
116
114
|
return result_data
|
|
117
|
-
|
|
115
|
+
|
|
118
116
|
# Fallback 3: Ensure tool_name exists using setattr
|
|
119
|
-
if not getattr(tool,
|
|
120
|
-
|
|
121
|
-
|
|
117
|
+
if not getattr(tool, "tool_name", None):
|
|
118
|
+
tool.tool_name = tool_name
|
|
119
|
+
|
|
122
120
|
# Fallback 4: Verify execute method exists
|
|
123
|
-
if not hasattr(tool,
|
|
124
|
-
result_data["error"] =
|
|
125
|
-
result_data["end_time"] = datetime.now(
|
|
121
|
+
if not hasattr(tool, "execute"):
|
|
122
|
+
result_data["error"] = "Tool missing execute method"
|
|
123
|
+
result_data["end_time"] = datetime.now(UTC).isoformat()
|
|
126
124
|
return result_data
|
|
127
|
-
|
|
125
|
+
|
|
128
126
|
# Create event loop for execution
|
|
129
127
|
loop = asyncio.new_event_loop()
|
|
130
128
|
asyncio.set_event_loop(loop)
|
|
131
|
-
|
|
129
|
+
|
|
132
130
|
try:
|
|
133
131
|
# Execute the tool with timeout
|
|
134
132
|
if timeout is not None and timeout > 0:
|
|
135
|
-
result_value = loop.run_until_complete(
|
|
136
|
-
asyncio.wait_for(tool.execute(**arguments), timeout)
|
|
137
|
-
)
|
|
133
|
+
result_value = loop.run_until_complete(asyncio.wait_for(tool.execute(**arguments), timeout))
|
|
138
134
|
else:
|
|
139
135
|
result_value = loop.run_until_complete(tool.execute(**arguments))
|
|
140
|
-
|
|
136
|
+
|
|
141
137
|
result_data["result"] = result_value
|
|
142
|
-
|
|
143
|
-
except
|
|
138
|
+
|
|
139
|
+
except TimeoutError:
|
|
144
140
|
result_data["error"] = f"Tool execution timed out after {timeout}s"
|
|
145
141
|
except Exception as e:
|
|
146
142
|
result_data["error"] = f"Tool execution failed: {str(e)}"
|
|
147
|
-
|
|
143
|
+
|
|
148
144
|
finally:
|
|
149
145
|
loop.close()
|
|
150
|
-
|
|
146
|
+
|
|
151
147
|
except Exception as e:
|
|
152
148
|
result_data["error"] = f"Worker error: {str(e)}"
|
|
153
|
-
|
|
154
|
-
result_data["end_time"] = datetime.now(
|
|
149
|
+
|
|
150
|
+
result_data["end_time"] = datetime.now(UTC).isoformat()
|
|
155
151
|
return result_data
|
|
156
152
|
|
|
157
153
|
|
|
@@ -161,11 +157,11 @@ def _serialized_tool_worker(
|
|
|
161
157
|
class SubprocessStrategy(ExecutionStrategy):
|
|
162
158
|
"""
|
|
163
159
|
Execute tools in separate processes for isolation and parallelism.
|
|
164
|
-
|
|
160
|
+
|
|
165
161
|
This strategy creates a pool of worker processes and distributes tool calls
|
|
166
162
|
among them. Each tool executes in its own process, providing isolation and
|
|
167
163
|
parallelism.
|
|
168
|
-
|
|
164
|
+
|
|
169
165
|
Enhanced tool name resolution and proper tool serialization.
|
|
170
166
|
"""
|
|
171
167
|
|
|
@@ -174,12 +170,12 @@ class SubprocessStrategy(ExecutionStrategy):
|
|
|
174
170
|
registry: ToolRegistryInterface,
|
|
175
171
|
*,
|
|
176
172
|
max_workers: int = 4,
|
|
177
|
-
default_timeout:
|
|
173
|
+
default_timeout: float | None = None,
|
|
178
174
|
worker_init_timeout: float = 5.0,
|
|
179
175
|
) -> None:
|
|
180
176
|
"""
|
|
181
177
|
Initialize the subprocess execution strategy.
|
|
182
|
-
|
|
178
|
+
|
|
183
179
|
Args:
|
|
184
180
|
registry: Tool registry for tool lookups
|
|
185
181
|
max_workers: Maximum number of worker processes
|
|
@@ -190,26 +186,25 @@ class SubprocessStrategy(ExecutionStrategy):
|
|
|
190
186
|
self.max_workers = max_workers
|
|
191
187
|
self.default_timeout = default_timeout or 30.0 # Always have a default
|
|
192
188
|
self.worker_init_timeout = worker_init_timeout
|
|
193
|
-
|
|
189
|
+
|
|
194
190
|
# Process pool (initialized lazily)
|
|
195
|
-
self._process_pool:
|
|
191
|
+
self._process_pool: concurrent.futures.ProcessPoolExecutor | None = None
|
|
196
192
|
self._pool_lock = asyncio.Lock()
|
|
197
|
-
|
|
193
|
+
|
|
198
194
|
# Task tracking for cleanup
|
|
199
|
-
self._active_tasks:
|
|
195
|
+
self._active_tasks: set[asyncio.Task] = set()
|
|
200
196
|
self._shutdown_event = asyncio.Event()
|
|
201
197
|
self._shutting_down = False
|
|
202
|
-
|
|
203
|
-
logger.debug(
|
|
204
|
-
|
|
205
|
-
|
|
198
|
+
|
|
199
|
+
logger.debug(
|
|
200
|
+
"SubprocessStrategy initialized with timeout: %ss, max_workers: %d", self.default_timeout, max_workers
|
|
201
|
+
)
|
|
202
|
+
|
|
206
203
|
# Register shutdown handler if in main thread
|
|
207
204
|
try:
|
|
208
205
|
loop = asyncio.get_running_loop()
|
|
209
206
|
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
210
|
-
loop.add_signal_handler(
|
|
211
|
-
sig, lambda s=sig: asyncio.create_task(self._signal_handler(s))
|
|
212
|
-
)
|
|
207
|
+
loop.add_signal_handler(sig, lambda s=sig: asyncio.create_task(self._signal_handler(s)))
|
|
213
208
|
except (RuntimeError, NotImplementedError):
|
|
214
209
|
# Not in the main thread or not on Unix
|
|
215
210
|
pass
|
|
@@ -218,24 +213,23 @@ class SubprocessStrategy(ExecutionStrategy):
|
|
|
218
213
|
"""Initialize the process pool if not already initialized."""
|
|
219
214
|
if self._process_pool is not None:
|
|
220
215
|
return
|
|
221
|
-
|
|
216
|
+
|
|
222
217
|
async with self._pool_lock:
|
|
223
218
|
if self._process_pool is not None:
|
|
224
219
|
return
|
|
225
|
-
|
|
220
|
+
|
|
226
221
|
# Create process pool
|
|
227
222
|
self._process_pool = concurrent.futures.ProcessPoolExecutor(
|
|
228
223
|
max_workers=self.max_workers,
|
|
229
224
|
initializer=_init_worker,
|
|
230
225
|
)
|
|
231
|
-
|
|
226
|
+
|
|
232
227
|
# Test the pool with a simple task
|
|
233
228
|
loop = asyncio.get_running_loop()
|
|
234
229
|
try:
|
|
235
230
|
# Use a module-level function instead of a lambda
|
|
236
231
|
await asyncio.wait_for(
|
|
237
|
-
loop.run_in_executor(self._process_pool, _pool_test_func),
|
|
238
|
-
timeout=self.worker_init_timeout
|
|
232
|
+
loop.run_in_executor(self._process_pool, _pool_test_func), timeout=self.worker_init_timeout
|
|
239
233
|
)
|
|
240
234
|
logger.info("Process pool initialized with %d workers", self.max_workers)
|
|
241
235
|
except Exception as e:
|
|
@@ -244,16 +238,16 @@ class SubprocessStrategy(ExecutionStrategy):
|
|
|
244
238
|
self._process_pool = None
|
|
245
239
|
logger.error("Failed to initialize process pool: %s", e)
|
|
246
240
|
raise RuntimeError(f"Failed to initialize process pool: {e}") from e
|
|
247
|
-
|
|
241
|
+
|
|
248
242
|
# ------------------------------------------------------------------ #
|
|
249
243
|
# 🔌 legacy façade for older wrappers #
|
|
250
244
|
# ------------------------------------------------------------------ #
|
|
251
245
|
async def execute(
|
|
252
246
|
self,
|
|
253
|
-
calls:
|
|
247
|
+
calls: list[ToolCall],
|
|
254
248
|
*,
|
|
255
|
-
timeout:
|
|
256
|
-
) ->
|
|
249
|
+
timeout: float | None = None,
|
|
250
|
+
) -> list[ToolResult]:
|
|
257
251
|
"""
|
|
258
252
|
Back-compat shim.
|
|
259
253
|
|
|
@@ -262,25 +256,25 @@ class SubprocessStrategy(ExecutionStrategy):
|
|
|
262
256
|
The real implementation lives in :meth:`run`, so we just forward.
|
|
263
257
|
"""
|
|
264
258
|
return await self.run(calls, timeout)
|
|
265
|
-
|
|
259
|
+
|
|
266
260
|
async def run(
|
|
267
261
|
self,
|
|
268
|
-
calls:
|
|
269
|
-
timeout:
|
|
270
|
-
) ->
|
|
262
|
+
calls: list[ToolCall],
|
|
263
|
+
timeout: float | None = None,
|
|
264
|
+
) -> list[ToolResult]:
|
|
271
265
|
"""
|
|
272
266
|
Execute tool calls in separate processes.
|
|
273
|
-
|
|
267
|
+
|
|
274
268
|
Args:
|
|
275
269
|
calls: List of tool calls to execute
|
|
276
270
|
timeout: Optional timeout for each execution (overrides default)
|
|
277
|
-
|
|
271
|
+
|
|
278
272
|
Returns:
|
|
279
273
|
List of tool results in the same order as calls
|
|
280
274
|
"""
|
|
281
275
|
if not calls:
|
|
282
276
|
return []
|
|
283
|
-
|
|
277
|
+
|
|
284
278
|
if self._shutting_down:
|
|
285
279
|
# Return early with error results if shutting down
|
|
286
280
|
return [
|
|
@@ -288,50 +282,53 @@ class SubprocessStrategy(ExecutionStrategy):
|
|
|
288
282
|
tool=call.tool,
|
|
289
283
|
result=None,
|
|
290
284
|
error="System is shutting down",
|
|
291
|
-
start_time=datetime.now(
|
|
292
|
-
end_time=datetime.now(
|
|
293
|
-
machine=
|
|
285
|
+
start_time=datetime.now(UTC),
|
|
286
|
+
end_time=datetime.now(UTC),
|
|
287
|
+
machine=platform.node(),
|
|
294
288
|
pid=os.getpid(),
|
|
295
289
|
)
|
|
296
290
|
for call in calls
|
|
297
291
|
]
|
|
298
|
-
|
|
292
|
+
|
|
299
293
|
# Use default_timeout if no timeout specified
|
|
300
294
|
effective_timeout = timeout if timeout is not None else self.default_timeout
|
|
301
295
|
logger.debug("Executing %d calls in subprocesses with %ss timeout each", len(calls), effective_timeout)
|
|
302
|
-
|
|
296
|
+
|
|
303
297
|
# Create tasks for each call
|
|
304
298
|
tasks = []
|
|
305
299
|
for call in calls:
|
|
306
|
-
task = asyncio.create_task(
|
|
307
|
-
|
|
308
|
-
|
|
300
|
+
task = asyncio.create_task(
|
|
301
|
+
self._execute_single_call(
|
|
302
|
+
call,
|
|
303
|
+
effective_timeout, # Always pass concrete timeout
|
|
304
|
+
)
|
|
305
|
+
)
|
|
309
306
|
self._active_tasks.add(task)
|
|
310
307
|
task.add_done_callback(self._active_tasks.discard)
|
|
311
308
|
tasks.append(task)
|
|
312
|
-
|
|
309
|
+
|
|
313
310
|
# Execute all tasks concurrently
|
|
314
311
|
async with log_context_span("subprocess_execution", {"num_calls": len(calls)}):
|
|
315
312
|
return await asyncio.gather(*tasks)
|
|
316
313
|
|
|
317
314
|
async def stream_run(
|
|
318
315
|
self,
|
|
319
|
-
calls:
|
|
320
|
-
timeout:
|
|
316
|
+
calls: list[ToolCall],
|
|
317
|
+
timeout: float | None = None,
|
|
321
318
|
) -> AsyncIterator[ToolResult]:
|
|
322
319
|
"""
|
|
323
320
|
Execute tool calls and yield results as they become available.
|
|
324
|
-
|
|
321
|
+
|
|
325
322
|
Args:
|
|
326
323
|
calls: List of tool calls to execute
|
|
327
324
|
timeout: Optional timeout for each execution
|
|
328
|
-
|
|
325
|
+
|
|
329
326
|
Yields:
|
|
330
327
|
Tool results as they complete (not necessarily in order)
|
|
331
328
|
"""
|
|
332
329
|
if not calls:
|
|
333
330
|
return
|
|
334
|
-
|
|
331
|
+
|
|
335
332
|
if self._shutting_down:
|
|
336
333
|
# Yield error results if shutting down
|
|
337
334
|
for call in calls:
|
|
@@ -339,40 +336,42 @@ class SubprocessStrategy(ExecutionStrategy):
|
|
|
339
336
|
tool=call.tool,
|
|
340
337
|
result=None,
|
|
341
338
|
error="System is shutting down",
|
|
342
|
-
start_time=datetime.now(
|
|
343
|
-
end_time=datetime.now(
|
|
344
|
-
machine=
|
|
339
|
+
start_time=datetime.now(UTC),
|
|
340
|
+
end_time=datetime.now(UTC),
|
|
341
|
+
machine=platform.node(),
|
|
345
342
|
pid=os.getpid(),
|
|
346
343
|
)
|
|
347
344
|
return
|
|
348
|
-
|
|
345
|
+
|
|
349
346
|
# Use default_timeout if no timeout specified
|
|
350
347
|
effective_timeout = timeout if timeout is not None else self.default_timeout
|
|
351
|
-
|
|
348
|
+
|
|
352
349
|
# Create a queue for results
|
|
353
350
|
queue = asyncio.Queue()
|
|
354
|
-
|
|
351
|
+
|
|
355
352
|
# Start all executions and have them put results in the queue
|
|
356
353
|
pending = set()
|
|
357
354
|
for call in calls:
|
|
358
|
-
task = asyncio.create_task(
|
|
359
|
-
|
|
360
|
-
|
|
355
|
+
task = asyncio.create_task(
|
|
356
|
+
self._execute_to_queue(
|
|
357
|
+
call,
|
|
358
|
+
queue,
|
|
359
|
+
effective_timeout, # Always pass concrete timeout
|
|
360
|
+
)
|
|
361
|
+
)
|
|
361
362
|
self._active_tasks.add(task)
|
|
362
363
|
task.add_done_callback(self._active_tasks.discard)
|
|
363
364
|
pending.add(task)
|
|
364
|
-
|
|
365
|
+
|
|
365
366
|
# Yield results as they become available
|
|
366
367
|
while pending:
|
|
367
368
|
# Get next result from queue
|
|
368
369
|
result = await queue.get()
|
|
369
370
|
yield result
|
|
370
|
-
|
|
371
|
+
|
|
371
372
|
# Check for completed tasks
|
|
372
|
-
done, pending = await asyncio.wait(
|
|
373
|
-
|
|
374
|
-
)
|
|
375
|
-
|
|
373
|
+
done, pending = await asyncio.wait(pending, timeout=0, return_when=asyncio.FIRST_COMPLETED)
|
|
374
|
+
|
|
376
375
|
# Handle any exceptions
|
|
377
376
|
for task in done:
|
|
378
377
|
try:
|
|
@@ -397,22 +396,22 @@ class SubprocessStrategy(ExecutionStrategy):
|
|
|
397
396
|
) -> ToolResult:
|
|
398
397
|
"""
|
|
399
398
|
Execute a single tool call with enhanced tool resolution and serialization.
|
|
400
|
-
|
|
399
|
+
|
|
401
400
|
Args:
|
|
402
401
|
call: Tool call to execute
|
|
403
402
|
timeout: Timeout in seconds (required)
|
|
404
|
-
|
|
403
|
+
|
|
405
404
|
Returns:
|
|
406
405
|
Tool execution result
|
|
407
406
|
"""
|
|
408
|
-
start_time = datetime.now(
|
|
409
|
-
|
|
407
|
+
start_time = datetime.now(UTC)
|
|
408
|
+
|
|
410
409
|
logger.debug("Executing %s in subprocess with %ss timeout", call.tool, timeout)
|
|
411
|
-
|
|
410
|
+
|
|
412
411
|
try:
|
|
413
412
|
# Ensure pool is initialized
|
|
414
413
|
await self._ensure_pool()
|
|
415
|
-
|
|
414
|
+
|
|
416
415
|
# Use enhanced tool resolution instead of direct lookup
|
|
417
416
|
tool_impl, resolved_namespace = await self._resolve_tool_info(call.tool, call.namespace)
|
|
418
417
|
if tool_impl is None:
|
|
@@ -421,29 +420,24 @@ class SubprocessStrategy(ExecutionStrategy):
|
|
|
421
420
|
result=None,
|
|
422
421
|
error=f"Tool '{call.tool}' not found in any namespace",
|
|
423
422
|
start_time=start_time,
|
|
424
|
-
end_time=datetime.now(
|
|
425
|
-
machine=
|
|
423
|
+
end_time=datetime.now(UTC),
|
|
424
|
+
machine=platform.node(),
|
|
426
425
|
pid=os.getpid(),
|
|
427
426
|
)
|
|
428
|
-
|
|
427
|
+
|
|
429
428
|
logger.debug(f"Resolved subprocess tool '{call.tool}' to namespace '{resolved_namespace}'")
|
|
430
|
-
|
|
429
|
+
|
|
431
430
|
# Ensure tool is properly prepared before serialization
|
|
432
|
-
if inspect.isclass(tool_impl)
|
|
433
|
-
|
|
434
|
-
else:
|
|
435
|
-
tool = tool_impl
|
|
436
|
-
|
|
431
|
+
tool = tool_impl() if inspect.isclass(tool_impl) else tool_impl
|
|
432
|
+
|
|
437
433
|
# Ensure tool_name attribute exists
|
|
438
|
-
if not hasattr(tool,
|
|
434
|
+
if not hasattr(tool, "tool_name") or not tool.tool_name:
|
|
439
435
|
tool.tool_name = call.tool
|
|
440
|
-
|
|
441
|
-
tool.tool_name = call.tool
|
|
442
|
-
|
|
436
|
+
|
|
443
437
|
# Also set _tool_name class attribute for consistency
|
|
444
|
-
if not hasattr(tool.__class__,
|
|
438
|
+
if not hasattr(tool.__class__, "_tool_name"):
|
|
445
439
|
tool.__class__._tool_name = call.tool
|
|
446
|
-
|
|
440
|
+
|
|
447
441
|
# Serialize the properly prepared tool
|
|
448
442
|
try:
|
|
449
443
|
serialized_tool_data = pickle.dumps(tool)
|
|
@@ -455,15 +449,15 @@ class SubprocessStrategy(ExecutionStrategy):
|
|
|
455
449
|
result=None,
|
|
456
450
|
error=f"Tool serialization failed: {str(e)}",
|
|
457
451
|
start_time=start_time,
|
|
458
|
-
end_time=datetime.now(
|
|
459
|
-
machine=
|
|
452
|
+
end_time=datetime.now(UTC),
|
|
453
|
+
machine=platform.node(),
|
|
460
454
|
pid=os.getpid(),
|
|
461
455
|
)
|
|
462
|
-
|
|
456
|
+
|
|
463
457
|
# Execute in subprocess using the FIXED worker
|
|
464
458
|
loop = asyncio.get_running_loop()
|
|
465
459
|
safety_timeout = timeout + 5.0
|
|
466
|
-
|
|
460
|
+
|
|
467
461
|
try:
|
|
468
462
|
result_data = await asyncio.wait_for(
|
|
469
463
|
loop.run_in_executor(
|
|
@@ -474,29 +468,29 @@ class SubprocessStrategy(ExecutionStrategy):
|
|
|
474
468
|
resolved_namespace, # Use resolved namespace
|
|
475
469
|
call.arguments,
|
|
476
470
|
timeout,
|
|
477
|
-
serialized_tool_data # Pass serialized tool data
|
|
478
|
-
)
|
|
471
|
+
serialized_tool_data, # Pass serialized tool data
|
|
472
|
+
),
|
|
479
473
|
),
|
|
480
|
-
timeout=safety_timeout
|
|
474
|
+
timeout=safety_timeout,
|
|
481
475
|
)
|
|
482
|
-
|
|
476
|
+
|
|
483
477
|
# Parse timestamps
|
|
484
478
|
if isinstance(result_data["start_time"], str):
|
|
485
479
|
result_data["start_time"] = datetime.fromisoformat(result_data["start_time"])
|
|
486
|
-
|
|
480
|
+
|
|
487
481
|
if isinstance(result_data["end_time"], str):
|
|
488
482
|
result_data["end_time"] = datetime.fromisoformat(result_data["end_time"])
|
|
489
|
-
|
|
490
|
-
end_time = datetime.now(
|
|
483
|
+
|
|
484
|
+
end_time = datetime.now(UTC)
|
|
491
485
|
actual_duration = (end_time - start_time).total_seconds()
|
|
492
|
-
|
|
486
|
+
|
|
493
487
|
if result_data.get("error"):
|
|
494
|
-
logger.debug(
|
|
495
|
-
|
|
488
|
+
logger.debug(
|
|
489
|
+
"%s subprocess failed after %.3fs: %s", call.tool, actual_duration, result_data["error"]
|
|
490
|
+
)
|
|
496
491
|
else:
|
|
497
|
-
logger.debug("%s subprocess completed in %.3fs (limit: %ss)",
|
|
498
|
-
|
|
499
|
-
|
|
492
|
+
logger.debug("%s subprocess completed in %.3fs (limit: %ss)", call.tool, actual_duration, timeout)
|
|
493
|
+
|
|
500
494
|
# Create ToolResult from worker data
|
|
501
495
|
return ToolResult(
|
|
502
496
|
tool=result_data.get("tool", call.tool),
|
|
@@ -504,42 +498,46 @@ class SubprocessStrategy(ExecutionStrategy):
|
|
|
504
498
|
error=result_data.get("error"),
|
|
505
499
|
start_time=result_data.get("start_time", start_time),
|
|
506
500
|
end_time=result_data.get("end_time", end_time),
|
|
507
|
-
machine=result_data.get("machine",
|
|
501
|
+
machine=result_data.get("machine", platform.node()),
|
|
508
502
|
pid=result_data.get("pid", os.getpid()),
|
|
509
503
|
)
|
|
510
|
-
|
|
511
|
-
except
|
|
512
|
-
end_time = datetime.now(
|
|
504
|
+
|
|
505
|
+
except TimeoutError:
|
|
506
|
+
end_time = datetime.now(UTC)
|
|
513
507
|
actual_duration = (end_time - start_time).total_seconds()
|
|
514
|
-
logger.debug(
|
|
515
|
-
|
|
516
|
-
|
|
508
|
+
logger.debug(
|
|
509
|
+
"%s subprocess timed out after %.3fs (safety limit: %ss)",
|
|
510
|
+
call.tool,
|
|
511
|
+
actual_duration,
|
|
512
|
+
safety_timeout,
|
|
513
|
+
)
|
|
514
|
+
|
|
517
515
|
return ToolResult(
|
|
518
516
|
tool=call.tool,
|
|
519
517
|
result=None,
|
|
520
518
|
error=f"Worker process timed out after {safety_timeout}s",
|
|
521
519
|
start_time=start_time,
|
|
522
520
|
end_time=end_time,
|
|
523
|
-
machine=
|
|
521
|
+
machine=platform.node(),
|
|
524
522
|
pid=os.getpid(),
|
|
525
523
|
)
|
|
526
|
-
|
|
524
|
+
|
|
527
525
|
except concurrent.futures.process.BrokenProcessPool:
|
|
528
526
|
logger.error("Process pool broke during execution - recreating")
|
|
529
527
|
if self._process_pool:
|
|
530
528
|
self._process_pool.shutdown(wait=False)
|
|
531
529
|
self._process_pool = None
|
|
532
|
-
|
|
530
|
+
|
|
533
531
|
return ToolResult(
|
|
534
532
|
tool=call.tool,
|
|
535
533
|
result=None,
|
|
536
534
|
error="Worker process crashed",
|
|
537
535
|
start_time=start_time,
|
|
538
|
-
end_time=datetime.now(
|
|
539
|
-
machine=
|
|
536
|
+
end_time=datetime.now(UTC),
|
|
537
|
+
machine=platform.node(),
|
|
540
538
|
pid=os.getpid(),
|
|
541
539
|
)
|
|
542
|
-
|
|
540
|
+
|
|
543
541
|
except asyncio.CancelledError:
|
|
544
542
|
logger.debug("%s subprocess was cancelled", call.tool)
|
|
545
543
|
return ToolResult(
|
|
@@ -547,54 +545,55 @@ class SubprocessStrategy(ExecutionStrategy):
|
|
|
547
545
|
result=None,
|
|
548
546
|
error="Execution was cancelled",
|
|
549
547
|
start_time=start_time,
|
|
550
|
-
end_time=datetime.now(
|
|
551
|
-
machine=
|
|
548
|
+
end_time=datetime.now(UTC),
|
|
549
|
+
machine=platform.node(),
|
|
552
550
|
pid=os.getpid(),
|
|
553
551
|
)
|
|
554
|
-
|
|
552
|
+
|
|
555
553
|
except Exception as e:
|
|
556
554
|
logger.exception("Error executing %s in subprocess: %s", call.tool, e)
|
|
557
|
-
end_time = datetime.now(
|
|
555
|
+
end_time = datetime.now(UTC)
|
|
558
556
|
actual_duration = (end_time - start_time).total_seconds()
|
|
559
|
-
logger.debug("%s subprocess setup failed after %.3fs: %s",
|
|
560
|
-
|
|
561
|
-
|
|
557
|
+
logger.debug("%s subprocess setup failed after %.3fs: %s", call.tool, actual_duration, e)
|
|
558
|
+
|
|
562
559
|
return ToolResult(
|
|
563
560
|
tool=call.tool,
|
|
564
561
|
result=None,
|
|
565
562
|
error=f"Error: {str(e)}",
|
|
566
563
|
start_time=start_time,
|
|
567
564
|
end_time=end_time,
|
|
568
|
-
machine=
|
|
565
|
+
machine=platform.node(),
|
|
569
566
|
pid=os.getpid(),
|
|
570
567
|
)
|
|
571
568
|
|
|
572
|
-
async def _resolve_tool_info(
|
|
569
|
+
async def _resolve_tool_info(
|
|
570
|
+
self, tool_name: str, preferred_namespace: str = "default"
|
|
571
|
+
) -> tuple[Any | None, str | None]:
|
|
573
572
|
"""
|
|
574
573
|
Enhanced tool name resolution with comprehensive fallback logic.
|
|
575
|
-
|
|
574
|
+
|
|
576
575
|
This method handles:
|
|
577
576
|
1. Simple names: "get_current_time" -> search in specified namespace first, then all namespaces
|
|
578
577
|
2. Namespaced names: "diagnostic_test.get_current_time" -> extract namespace and tool name
|
|
579
578
|
3. Fallback searching across all namespaces when not found in default
|
|
580
|
-
|
|
579
|
+
|
|
581
580
|
Args:
|
|
582
581
|
tool_name: Name of the tool to resolve
|
|
583
582
|
preferred_namespace: Preferred namespace to search first
|
|
584
|
-
|
|
583
|
+
|
|
585
584
|
Returns:
|
|
586
585
|
Tuple of (tool_object, resolved_namespace) or (None, None) if not found
|
|
587
586
|
"""
|
|
588
587
|
logger.debug(f"Resolving tool: '{tool_name}' (preferred namespace: '{preferred_namespace}')")
|
|
589
|
-
|
|
588
|
+
|
|
590
589
|
# Strategy 1: Handle namespaced tool names (namespace.tool_name format)
|
|
591
|
-
if
|
|
592
|
-
parts = tool_name.split(
|
|
590
|
+
if "." in tool_name:
|
|
591
|
+
parts = tool_name.split(".", 1) # Split on first dot only
|
|
593
592
|
namespace = parts[0]
|
|
594
593
|
actual_tool_name = parts[1]
|
|
595
|
-
|
|
594
|
+
|
|
596
595
|
logger.debug(f"Namespaced lookup: namespace='{namespace}', tool='{actual_tool_name}'")
|
|
597
|
-
|
|
596
|
+
|
|
598
597
|
tool = await self.registry.get_tool(actual_tool_name, namespace)
|
|
599
598
|
if tool is not None:
|
|
600
599
|
logger.debug(f"Found tool '{actual_tool_name}' in namespace '{namespace}'")
|
|
@@ -602,7 +601,7 @@ class SubprocessStrategy(ExecutionStrategy):
|
|
|
602
601
|
else:
|
|
603
602
|
logger.debug(f"Tool '{actual_tool_name}' not found in namespace '{namespace}'")
|
|
604
603
|
return None, None
|
|
605
|
-
|
|
604
|
+
|
|
606
605
|
# Strategy 2: Simple tool name - try preferred namespace first
|
|
607
606
|
if preferred_namespace:
|
|
608
607
|
logger.debug(f"Simple tool lookup: trying preferred namespace '{preferred_namespace}' for '{tool_name}'")
|
|
@@ -610,7 +609,7 @@ class SubprocessStrategy(ExecutionStrategy):
|
|
|
610
609
|
if tool is not None:
|
|
611
610
|
logger.debug(f"Found tool '{tool_name}' in preferred namespace '{preferred_namespace}'")
|
|
612
611
|
return tool, preferred_namespace
|
|
613
|
-
|
|
612
|
+
|
|
614
613
|
# Strategy 3: Try default namespace if different from preferred
|
|
615
614
|
if preferred_namespace != "default":
|
|
616
615
|
logger.debug(f"Simple tool lookup: trying default namespace for '{tool_name}'")
|
|
@@ -618,30 +617,30 @@ class SubprocessStrategy(ExecutionStrategy):
|
|
|
618
617
|
if tool is not None:
|
|
619
618
|
logger.debug(f"Found tool '{tool_name}' in default namespace")
|
|
620
619
|
return tool, "default"
|
|
621
|
-
|
|
620
|
+
|
|
622
621
|
# Strategy 4: Search all namespaces as fallback
|
|
623
622
|
logger.debug(f"Tool '{tool_name}' not in preferred/default namespace, searching all namespaces...")
|
|
624
|
-
|
|
623
|
+
|
|
625
624
|
try:
|
|
626
625
|
# Get all available namespaces
|
|
627
626
|
namespaces = await self.registry.list_namespaces()
|
|
628
627
|
logger.debug(f"Available namespaces: {namespaces}")
|
|
629
|
-
|
|
628
|
+
|
|
630
629
|
# Search each namespace
|
|
631
630
|
for namespace in namespaces:
|
|
632
631
|
if namespace in [preferred_namespace, "default"]:
|
|
633
632
|
continue # Already tried these
|
|
634
|
-
|
|
633
|
+
|
|
635
634
|
logger.debug(f"Searching namespace '{namespace}' for tool '{tool_name}'")
|
|
636
635
|
tool = await self.registry.get_tool(tool_name, namespace)
|
|
637
636
|
if tool is not None:
|
|
638
637
|
logger.debug(f"Found tool '{tool_name}' in namespace '{namespace}'")
|
|
639
638
|
return tool, namespace
|
|
640
|
-
|
|
639
|
+
|
|
641
640
|
# Strategy 5: Final fallback - list all tools and do fuzzy matching
|
|
642
641
|
logger.debug(f"Tool '{tool_name}' not found in any namespace, trying fuzzy matching...")
|
|
643
642
|
all_tools = await self.registry.list_tools()
|
|
644
|
-
|
|
643
|
+
|
|
645
644
|
# Look for exact matches in tool name (ignoring namespace)
|
|
646
645
|
for namespace, registered_name in all_tools:
|
|
647
646
|
if registered_name == tool_name:
|
|
@@ -649,13 +648,13 @@ class SubprocessStrategy(ExecutionStrategy):
|
|
|
649
648
|
tool = await self.registry.get_tool(registered_name, namespace)
|
|
650
649
|
if tool is not None:
|
|
651
650
|
return tool, namespace
|
|
652
|
-
|
|
651
|
+
|
|
653
652
|
# Log all available tools for debugging
|
|
654
653
|
logger.debug(f"Available tools: {all_tools}")
|
|
655
|
-
|
|
654
|
+
|
|
656
655
|
except Exception as e:
|
|
657
656
|
logger.error(f"Error during namespace search: {e}")
|
|
658
|
-
|
|
657
|
+
|
|
659
658
|
logger.warning(f"Tool '{tool_name}' not found in any namespace")
|
|
660
659
|
return None, None
|
|
661
660
|
|
|
@@ -663,26 +662,26 @@ class SubprocessStrategy(ExecutionStrategy):
|
|
|
663
662
|
def supports_streaming(self) -> bool:
|
|
664
663
|
"""Check if this strategy supports streaming execution."""
|
|
665
664
|
return True
|
|
666
|
-
|
|
665
|
+
|
|
667
666
|
async def _signal_handler(self, sig: int) -> None:
|
|
668
667
|
"""Handle termination signals."""
|
|
669
668
|
signame = signal.Signals(sig).name
|
|
670
669
|
logger.info("Received %s, shutting down process pool", signame)
|
|
671
670
|
await self.shutdown()
|
|
672
|
-
|
|
671
|
+
|
|
673
672
|
async def shutdown(self) -> None:
|
|
674
673
|
"""Enhanced shutdown with graceful task handling and proper null checks."""
|
|
675
674
|
if self._shutting_down:
|
|
676
675
|
return
|
|
677
|
-
|
|
676
|
+
|
|
678
677
|
self._shutting_down = True
|
|
679
678
|
self._shutdown_event.set()
|
|
680
|
-
|
|
679
|
+
|
|
681
680
|
# Handle active tasks gracefully
|
|
682
681
|
active_tasks = list(self._active_tasks)
|
|
683
682
|
if active_tasks:
|
|
684
683
|
logger.debug(f"Completing {len(active_tasks)} active operations")
|
|
685
|
-
|
|
684
|
+
|
|
686
685
|
# Cancel tasks with brief intervals for clean handling
|
|
687
686
|
for task in active_tasks:
|
|
688
687
|
try:
|
|
@@ -691,22 +690,18 @@ class SubprocessStrategy(ExecutionStrategy):
|
|
|
691
690
|
except Exception:
|
|
692
691
|
pass
|
|
693
692
|
# Small delay to prevent overwhelming the event loop
|
|
694
|
-
|
|
693
|
+
with contextlib.suppress(Exception):
|
|
695
694
|
await asyncio.sleep(0.001)
|
|
696
|
-
|
|
697
|
-
pass
|
|
698
|
-
|
|
695
|
+
|
|
699
696
|
# Allow reasonable time for completion
|
|
700
697
|
try:
|
|
701
|
-
completion_task = asyncio.create_task(
|
|
702
|
-
asyncio.gather(*active_tasks, return_exceptions=True)
|
|
703
|
-
)
|
|
698
|
+
completion_task = asyncio.create_task(asyncio.gather(*active_tasks, return_exceptions=True))
|
|
704
699
|
await asyncio.wait_for(completion_task, timeout=2.0)
|
|
705
|
-
except
|
|
700
|
+
except TimeoutError:
|
|
706
701
|
logger.debug("Active operations completed within timeout constraints")
|
|
707
702
|
except Exception:
|
|
708
703
|
logger.debug("Active operations completed successfully")
|
|
709
|
-
|
|
704
|
+
|
|
710
705
|
# Handle process pool shutdown with proper null checks
|
|
711
706
|
if self._process_pool is not None:
|
|
712
707
|
logger.debug("Finalizing process pool")
|
|
@@ -714,18 +709,18 @@ class SubprocessStrategy(ExecutionStrategy):
|
|
|
714
709
|
# Store reference and null check before async operation
|
|
715
710
|
pool_to_shutdown = self._process_pool
|
|
716
711
|
self._process_pool = None # Clear immediately to prevent race conditions
|
|
717
|
-
|
|
712
|
+
|
|
718
713
|
# Create shutdown task with the stored reference
|
|
719
714
|
shutdown_task = asyncio.create_task(
|
|
720
715
|
asyncio.get_event_loop().run_in_executor(
|
|
721
716
|
None, lambda: pool_to_shutdown.shutdown(wait=False) if pool_to_shutdown else None
|
|
722
717
|
)
|
|
723
718
|
)
|
|
724
|
-
|
|
719
|
+
|
|
725
720
|
try:
|
|
726
721
|
await asyncio.wait_for(shutdown_task, timeout=1.0)
|
|
727
722
|
logger.debug("Process pool shutdown completed")
|
|
728
|
-
except
|
|
723
|
+
except TimeoutError:
|
|
729
724
|
logger.debug("Process pool shutdown timed out, forcing cleanup")
|
|
730
725
|
if not shutdown_task.done():
|
|
731
726
|
shutdown_task.cancel()
|
|
@@ -734,4 +729,4 @@ class SubprocessStrategy(ExecutionStrategy):
|
|
|
734
729
|
except Exception as e:
|
|
735
730
|
logger.debug(f"Process pool finalization completed: {e}")
|
|
736
731
|
else:
|
|
737
|
-
logger.debug("Process pool already cleaned up")
|
|
732
|
+
logger.debug("Process pool already cleaned up")
|