chuk-tool-processor 0.1.0__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/__init__.py +1 -0
- chuk_tool_processor/core/__init__.py +1 -0
- chuk_tool_processor/core/exceptions.py +45 -0
- chuk_tool_processor/core/processor.py +268 -0
- chuk_tool_processor/execution/__init__.py +0 -0
- chuk_tool_processor/execution/strategies/__init__.py +0 -0
- chuk_tool_processor/execution/strategies/inprocess_strategy.py +206 -0
- chuk_tool_processor/execution/strategies/subprocess_strategy.py +103 -0
- chuk_tool_processor/execution/tool_executor.py +46 -0
- chuk_tool_processor/execution/wrappers/__init__.py +0 -0
- chuk_tool_processor/execution/wrappers/caching.py +234 -0
- chuk_tool_processor/execution/wrappers/rate_limiting.py +149 -0
- chuk_tool_processor/execution/wrappers/retry.py +176 -0
- chuk_tool_processor/models/__init__.py +1 -0
- chuk_tool_processor/models/execution_strategy.py +19 -0
- chuk_tool_processor/models/tool_call.py +7 -0
- chuk_tool_processor/models/tool_result.py +49 -0
- chuk_tool_processor/plugins/__init__.py +1 -0
- chuk_tool_processor/plugins/discovery.py +205 -0
- chuk_tool_processor/plugins/parsers/__init__.py +1 -0
- chuk_tool_processor/plugins/parsers/function_call_tool.py +105 -0
- chuk_tool_processor/plugins/parsers/json_tool.py +17 -0
- chuk_tool_processor/plugins/parsers/xml_tool.py +41 -0
- chuk_tool_processor/registry/__init__.py +20 -0
- chuk_tool_processor/registry/decorators.py +42 -0
- chuk_tool_processor/registry/interface.py +79 -0
- chuk_tool_processor/registry/metadata.py +36 -0
- chuk_tool_processor/registry/provider.py +44 -0
- chuk_tool_processor/registry/providers/__init__.py +41 -0
- chuk_tool_processor/registry/providers/memory.py +165 -0
- chuk_tool_processor/utils/__init__.py +0 -0
- chuk_tool_processor/utils/logging.py +260 -0
- chuk_tool_processor/utils/validation.py +192 -0
- chuk_tool_processor-0.1.0.dist-info/METADATA +293 -0
- chuk_tool_processor-0.1.0.dist-info/RECORD +37 -0
- chuk_tool_processor-0.1.0.dist-info/WHEEL +5 -0
- chuk_tool_processor-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# chuk_tool_processor/__init__.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# chuk_tool_processor/core/__init__.py
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# chuk_tool_processor/exceptions.py
|
|
2
|
+
from typing import Optional, Any, Dict
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ToolProcessorError(Exception):
|
|
6
|
+
"""Base exception for all tool processor errors."""
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ToolNotFoundError(ToolProcessorError):
|
|
11
|
+
"""Raised when a requested tool is not found in the registry."""
|
|
12
|
+
def __init__(self, tool_name: str):
|
|
13
|
+
self.tool_name = tool_name
|
|
14
|
+
super().__init__(f"Tool '{tool_name}' not found in registry")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ToolExecutionError(ToolProcessorError):
|
|
18
|
+
"""Raised when a tool execution fails."""
|
|
19
|
+
def __init__(self, tool_name: str, original_error: Optional[Exception] = None):
|
|
20
|
+
self.tool_name = tool_name
|
|
21
|
+
self.original_error = original_error
|
|
22
|
+
message = f"Tool '{tool_name}' execution failed"
|
|
23
|
+
if original_error:
|
|
24
|
+
message += f": {str(original_error)}"
|
|
25
|
+
super().__init__(message)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ToolTimeoutError(ToolExecutionError):
|
|
29
|
+
"""Raised when a tool execution times out."""
|
|
30
|
+
def __init__(self, tool_name: str, timeout: float):
|
|
31
|
+
self.timeout = timeout
|
|
32
|
+
super().__init__(tool_name, Exception(f"Execution timed out after {timeout}s"))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ToolValidationError(ToolProcessorError):
|
|
36
|
+
"""Raised when tool arguments or results fail validation."""
|
|
37
|
+
def __init__(self, tool_name: str, errors: Dict[str, Any]):
|
|
38
|
+
self.tool_name = tool_name
|
|
39
|
+
self.errors = errors
|
|
40
|
+
super().__init__(f"Validation failed for tool '{tool_name}': {errors}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ParserError(ToolProcessorError):
|
|
44
|
+
"""Raised when parsing tool calls from raw input fails."""
|
|
45
|
+
pass
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# chuk_tool_processor/core/processor.py
|
|
2
|
+
import asyncio
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any, Dict, List, Optional, Type, Union
|
|
5
|
+
|
|
6
|
+
# imports
|
|
7
|
+
from chuk_tool_processor.models.tool_call import ToolCall
|
|
8
|
+
from chuk_tool_processor.models.tool_result import ToolResult
|
|
9
|
+
from chuk_tool_processor.registry import ToolRegistryInterface, ToolRegistryProvider
|
|
10
|
+
from chuk_tool_processor.execution.tool_executor import ToolExecutor
|
|
11
|
+
from chuk_tool_processor.execution.strategies.inprocess_strategy import InProcessStrategy
|
|
12
|
+
from chuk_tool_processor.execution.wrappers.caching import CacheInterface, InMemoryCache, CachingToolExecutor
|
|
13
|
+
from chuk_tool_processor.execution.wrappers.rate_limiting import RateLimiter, RateLimitedToolExecutor
|
|
14
|
+
from chuk_tool_processor.execution.wrappers.retry import RetryConfig, RetryableToolExecutor
|
|
15
|
+
from chuk_tool_processor.plugins.discovery import plugin_registry, discover_default_plugins
|
|
16
|
+
from chuk_tool_processor.utils.logging import get_logger, log_context_span, request_logging, log_tool_call, metrics
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ToolProcessor:
|
|
20
|
+
"""
|
|
21
|
+
Main class for processing tool calls from LLM responses.
|
|
22
|
+
Combines parsing, execution, and result handling.
|
|
23
|
+
"""
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
registry: Optional[ToolRegistryInterface] = None,
|
|
27
|
+
default_timeout: float = 10.0,
|
|
28
|
+
max_concurrency: Optional[int] = None,
|
|
29
|
+
enable_caching: bool = True,
|
|
30
|
+
cache_ttl: int = 300,
|
|
31
|
+
enable_rate_limiting: bool = False,
|
|
32
|
+
global_rate_limit: Optional[int] = None,
|
|
33
|
+
tool_rate_limits: Optional[Dict[str, tuple]] = None,
|
|
34
|
+
enable_retries: bool = True,
|
|
35
|
+
max_retries: int = 3,
|
|
36
|
+
parser_plugins: Optional[List[str]] = None
|
|
37
|
+
):
|
|
38
|
+
"""
|
|
39
|
+
Initialize the tool processor.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
registry: Tool registry to use. If None, uses the global registry.
|
|
43
|
+
default_timeout: Default timeout for tool execution in seconds.
|
|
44
|
+
max_concurrency: Maximum number of concurrent tool executions.
|
|
45
|
+
enable_caching: Whether to enable result caching.
|
|
46
|
+
cache_ttl: Default cache TTL in seconds.
|
|
47
|
+
enable_rate_limiting: Whether to enable rate limiting.
|
|
48
|
+
global_rate_limit: Optional global rate limit (requests per minute).
|
|
49
|
+
tool_rate_limits: Dict mapping tool names to (limit, period) tuples.
|
|
50
|
+
enable_retries: Whether to enable automatic retries.
|
|
51
|
+
max_retries: Maximum number of retry attempts.
|
|
52
|
+
parser_plugins: List of parser plugin names to use.
|
|
53
|
+
If None, uses all available parsers.
|
|
54
|
+
"""
|
|
55
|
+
self.logger = get_logger("chuk_tool_processor.processor")
|
|
56
|
+
|
|
57
|
+
# Use provided registry or global registry
|
|
58
|
+
self.registry = registry or ToolRegistryProvider.get_registry()
|
|
59
|
+
|
|
60
|
+
# Create base executor with in-process strategy
|
|
61
|
+
self.strategy = InProcessStrategy(
|
|
62
|
+
registry=self.registry,
|
|
63
|
+
default_timeout=default_timeout,
|
|
64
|
+
max_concurrency=max_concurrency
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
self.executor = ToolExecutor(
|
|
68
|
+
registry=self.registry,
|
|
69
|
+
default_timeout=default_timeout,
|
|
70
|
+
strategy=self.strategy
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Apply optional wrappers
|
|
74
|
+
if enable_retries:
|
|
75
|
+
self.logger.info("Enabling retry logic")
|
|
76
|
+
self.executor = RetryableToolExecutor(
|
|
77
|
+
executor=self.executor,
|
|
78
|
+
default_config=RetryConfig(max_retries=max_retries)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
if enable_rate_limiting:
|
|
82
|
+
self.logger.info("Enabling rate limiting")
|
|
83
|
+
rate_limiter = RateLimiter(
|
|
84
|
+
global_limit=global_rate_limit,
|
|
85
|
+
tool_limits=tool_rate_limits
|
|
86
|
+
)
|
|
87
|
+
self.executor = RateLimitedToolExecutor(
|
|
88
|
+
executor=self.executor,
|
|
89
|
+
rate_limiter=rate_limiter
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if enable_caching:
|
|
93
|
+
self.logger.info("Enabling result caching")
|
|
94
|
+
cache = InMemoryCache(default_ttl=cache_ttl)
|
|
95
|
+
self.executor = CachingToolExecutor(
|
|
96
|
+
executor=self.executor,
|
|
97
|
+
cache=cache,
|
|
98
|
+
default_ttl=cache_ttl
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Discover plugins if not already done
|
|
102
|
+
if not plugin_registry.list_plugins().get("parser", []):
|
|
103
|
+
discover_default_plugins()
|
|
104
|
+
|
|
105
|
+
# Get parser plugins
|
|
106
|
+
if parser_plugins:
|
|
107
|
+
self.parsers = [
|
|
108
|
+
plugin_registry.get_plugin("parser", name)
|
|
109
|
+
for name in parser_plugins
|
|
110
|
+
if plugin_registry.get_plugin("parser", name)
|
|
111
|
+
]
|
|
112
|
+
else:
|
|
113
|
+
parser_names = plugin_registry.list_plugins().get("parser", [])
|
|
114
|
+
self.parsers = [
|
|
115
|
+
plugin_registry.get_plugin("parser", name)
|
|
116
|
+
for name in parser_names
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
self.logger.info(f"Initialized with {len(self.parsers)} parser plugins")
|
|
120
|
+
|
|
121
|
+
async def process_text(
|
|
122
|
+
self,
|
|
123
|
+
text: str,
|
|
124
|
+
timeout: Optional[float] = None,
|
|
125
|
+
use_cache: bool = True,
|
|
126
|
+
request_id: Optional[str] = None
|
|
127
|
+
) -> List[ToolResult]:
|
|
128
|
+
"""
|
|
129
|
+
Process text to extract and execute tool calls.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
text: Text to process.
|
|
133
|
+
timeout: Optional timeout for execution.
|
|
134
|
+
use_cache: Whether to use cached results.
|
|
135
|
+
request_id: Optional request ID for logging.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
List of tool results.
|
|
139
|
+
"""
|
|
140
|
+
# Create request context
|
|
141
|
+
with request_logging(request_id) as req_id:
|
|
142
|
+
self.logger.info(f"Processing text ({len(text)} chars)")
|
|
143
|
+
|
|
144
|
+
# Extract tool calls
|
|
145
|
+
calls = await self._extract_tool_calls(text)
|
|
146
|
+
|
|
147
|
+
if not calls:
|
|
148
|
+
self.logger.info("No tool calls found")
|
|
149
|
+
return []
|
|
150
|
+
|
|
151
|
+
self.logger.info(f"Found {len(calls)} tool calls")
|
|
152
|
+
|
|
153
|
+
# Execute tool calls
|
|
154
|
+
with log_context_span("tool_execution", {"num_calls": len(calls)}):
|
|
155
|
+
# Check if any tools are unknown
|
|
156
|
+
tool_names = set(call.tool for call in calls)
|
|
157
|
+
unknown_tools = [
|
|
158
|
+
name for name in tool_names
|
|
159
|
+
if not self.registry.get_tool(name)
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
if unknown_tools:
|
|
163
|
+
self.logger.warning(f"Unknown tools: {unknown_tools}")
|
|
164
|
+
|
|
165
|
+
# Execute tools
|
|
166
|
+
results = await self.executor.execute(calls, timeout=timeout)
|
|
167
|
+
|
|
168
|
+
# Log metrics for each tool call
|
|
169
|
+
for call, result in zip(calls, results):
|
|
170
|
+
log_tool_call(call, result)
|
|
171
|
+
|
|
172
|
+
# Record metrics
|
|
173
|
+
duration = (result.end_time - result.start_time).total_seconds()
|
|
174
|
+
metrics.log_tool_execution(
|
|
175
|
+
tool=call.tool,
|
|
176
|
+
success=result.error is None,
|
|
177
|
+
duration=duration,
|
|
178
|
+
error=result.error,
|
|
179
|
+
cached=getattr(result, "cached", False),
|
|
180
|
+
attempts=getattr(result, "attempts", 1)
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
return results
|
|
184
|
+
|
|
185
|
+
async def _extract_tool_calls(self, text: str) -> List[ToolCall]:
|
|
186
|
+
"""
|
|
187
|
+
Extract tool calls from text using all available parsers.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
text: Text to parse.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
List of tool calls.
|
|
194
|
+
"""
|
|
195
|
+
all_calls = []
|
|
196
|
+
|
|
197
|
+
# Try each parser
|
|
198
|
+
with log_context_span("parsing", {"text_length": len(text)}):
|
|
199
|
+
for parser in self.parsers:
|
|
200
|
+
parser_name = parser.__class__.__name__
|
|
201
|
+
|
|
202
|
+
with log_context_span(f"parser.{parser_name}", log_duration=True):
|
|
203
|
+
start_time = time.time()
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
# Try to parse
|
|
207
|
+
calls = parser.try_parse(text)
|
|
208
|
+
|
|
209
|
+
# Log success
|
|
210
|
+
duration = time.time() - start_time
|
|
211
|
+
metrics.log_parser_metric(
|
|
212
|
+
parser=parser_name,
|
|
213
|
+
success=True,
|
|
214
|
+
duration=duration,
|
|
215
|
+
num_calls=len(calls)
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# Add calls to result
|
|
219
|
+
all_calls.extend(calls)
|
|
220
|
+
|
|
221
|
+
except Exception as e:
|
|
222
|
+
# Log failure
|
|
223
|
+
duration = time.time() - start_time
|
|
224
|
+
metrics.log_parser_metric(
|
|
225
|
+
parser=parser_name,
|
|
226
|
+
success=False,
|
|
227
|
+
duration=duration,
|
|
228
|
+
num_calls=0
|
|
229
|
+
)
|
|
230
|
+
self.logger.error(f"Parser {parser_name} failed: {str(e)}")
|
|
231
|
+
|
|
232
|
+
# Remove duplicates
|
|
233
|
+
unique_calls = {}
|
|
234
|
+
for call in all_calls:
|
|
235
|
+
key = f"{call.tool}:{hash(frozenset(call.arguments.items()))}"
|
|
236
|
+
unique_calls[key] = call
|
|
237
|
+
|
|
238
|
+
return list(unique_calls.values())
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# Create a global processor with default settings
|
|
242
|
+
default_processor = ToolProcessor()
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
async def process_text(
|
|
246
|
+
text: str,
|
|
247
|
+
timeout: Optional[float] = None,
|
|
248
|
+
use_cache: bool = True,
|
|
249
|
+
request_id: Optional[str] = None
|
|
250
|
+
) -> List[ToolResult]:
|
|
251
|
+
"""
|
|
252
|
+
Process text with the default processor.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
text: Text to process.
|
|
256
|
+
timeout: Optional timeout for execution.
|
|
257
|
+
use_cache: Whether to use cached results.
|
|
258
|
+
request_id: Optional request ID for logging.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
List of tool results.
|
|
262
|
+
"""
|
|
263
|
+
return await default_processor.process_text(
|
|
264
|
+
text=text,
|
|
265
|
+
timeout=timeout,
|
|
266
|
+
use_cache=use_cache,
|
|
267
|
+
request_id=request_id
|
|
268
|
+
)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# chuk_tool_processor/execution/inprocess_strategy.py
|
|
2
|
+
import asyncio
|
|
3
|
+
import os
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import List, Optional, Dict, Any
|
|
6
|
+
|
|
7
|
+
# imports
|
|
8
|
+
from chuk_tool_processor.models.tool_call import ToolCall
|
|
9
|
+
from chuk_tool_processor.models.tool_result import ToolResult
|
|
10
|
+
from chuk_tool_processor.models.execution_strategy import ExecutionStrategy
|
|
11
|
+
from chuk_tool_processor.registry.interface import ToolRegistryInterface
|
|
12
|
+
from chuk_tool_processor.core.exceptions import ToolNotFoundError, ToolTimeoutError, ToolExecutionError
|
|
13
|
+
from chuk_tool_processor.utils.logging import get_logger
|
|
14
|
+
|
|
15
|
+
logger = get_logger("chuk_tool_processor.execution.inprocess_strategy")
|
|
16
|
+
|
|
17
|
+
class InProcessStrategy(ExecutionStrategy):
|
|
18
|
+
"""
|
|
19
|
+
In-process execution strategy with concurrent execution support.
|
|
20
|
+
"""
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
registry: ToolRegistryInterface,
|
|
24
|
+
default_timeout: Optional[float] = None,
|
|
25
|
+
max_concurrency: Optional[int] = None
|
|
26
|
+
):
|
|
27
|
+
"""
|
|
28
|
+
Initialize the strategy.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
registry: Tool registry to look up tools.
|
|
32
|
+
default_timeout: Default timeout for tool executions.
|
|
33
|
+
max_concurrency: Maximum number of concurrent tool executions (default: None = unlimited).
|
|
34
|
+
"""
|
|
35
|
+
self.registry = registry
|
|
36
|
+
self.default_timeout = default_timeout
|
|
37
|
+
self.max_concurrency = max_concurrency
|
|
38
|
+
self._semaphore = asyncio.Semaphore(max_concurrency) if max_concurrency else None
|
|
39
|
+
|
|
40
|
+
async def run(
|
|
41
|
+
self,
|
|
42
|
+
calls: List[ToolCall],
|
|
43
|
+
timeout: Optional[float] = None
|
|
44
|
+
) -> List[ToolResult]:
|
|
45
|
+
"""
|
|
46
|
+
Execute tool calls concurrently with timeout.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
calls: List of tool calls to execute.
|
|
50
|
+
timeout: Optional timeout that overrides the default.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
List of tool results in the same order as the calls.
|
|
54
|
+
"""
|
|
55
|
+
# Create tasks for each call
|
|
56
|
+
tasks = []
|
|
57
|
+
for call in calls:
|
|
58
|
+
task = self._execute_single_call(call, timeout if timeout is not None else self.default_timeout)
|
|
59
|
+
tasks.append(task)
|
|
60
|
+
|
|
61
|
+
# Run all tasks concurrently and gather results
|
|
62
|
+
results = await asyncio.gather(*tasks)
|
|
63
|
+
return results
|
|
64
|
+
|
|
65
|
+
async def _execute_single_call(
|
|
66
|
+
self,
|
|
67
|
+
call: ToolCall,
|
|
68
|
+
timeout: Optional[float]
|
|
69
|
+
) -> ToolResult:
|
|
70
|
+
"""
|
|
71
|
+
Execute a single tool call with timeout.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
call: Tool call to execute.
|
|
75
|
+
timeout: Optional timeout in seconds.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Tool result with execution metadata.
|
|
79
|
+
"""
|
|
80
|
+
# Get execution metadata
|
|
81
|
+
pid = os.getpid()
|
|
82
|
+
machine = os.uname().nodename
|
|
83
|
+
start_time = datetime.now(timezone.utc)
|
|
84
|
+
|
|
85
|
+
# Look up the tool
|
|
86
|
+
tool_impl = self.registry.get_tool(call.tool)
|
|
87
|
+
if not tool_impl:
|
|
88
|
+
end_time = datetime.now(timezone.utc)
|
|
89
|
+
return ToolResult(
|
|
90
|
+
tool=call.tool,
|
|
91
|
+
result=None,
|
|
92
|
+
error="Tool not found", # Keep this message exactly as "Tool not found" for test compatibility
|
|
93
|
+
start_time=start_time,
|
|
94
|
+
end_time=end_time,
|
|
95
|
+
machine=machine,
|
|
96
|
+
pid=pid
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# Execute with concurrency control if needed
|
|
100
|
+
try:
|
|
101
|
+
if self._semaphore:
|
|
102
|
+
async with self._semaphore:
|
|
103
|
+
return await self._run_with_timeout(tool_impl, call, timeout, start_time, machine, pid)
|
|
104
|
+
else:
|
|
105
|
+
return await self._run_with_timeout(tool_impl, call, timeout, start_time, machine, pid)
|
|
106
|
+
except Exception as e:
|
|
107
|
+
# Catch any uncaught exceptions
|
|
108
|
+
end_time = datetime.now(timezone.utc)
|
|
109
|
+
return ToolResult(
|
|
110
|
+
tool=call.tool,
|
|
111
|
+
result=None,
|
|
112
|
+
error=f"Unexpected error: {str(e)}",
|
|
113
|
+
start_time=start_time,
|
|
114
|
+
end_time=end_time,
|
|
115
|
+
machine=machine,
|
|
116
|
+
pid=pid
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
async def _run_with_timeout(
|
|
120
|
+
self,
|
|
121
|
+
tool_impl: Any,
|
|
122
|
+
call: ToolCall,
|
|
123
|
+
timeout: Optional[float],
|
|
124
|
+
start_time: datetime,
|
|
125
|
+
machine: str,
|
|
126
|
+
pid: int
|
|
127
|
+
) -> ToolResult:
|
|
128
|
+
"""
|
|
129
|
+
Execute a tool with timeout handling.
|
|
130
|
+
"""
|
|
131
|
+
try:
|
|
132
|
+
# Determine if we need to instantiate the tool
|
|
133
|
+
# If tool_impl is a class (not an instance), instantiate it
|
|
134
|
+
if isinstance(tool_impl, type):
|
|
135
|
+
tool_instance = tool_impl()
|
|
136
|
+
else:
|
|
137
|
+
tool_instance = tool_impl
|
|
138
|
+
|
|
139
|
+
# Get the tool metadata to check if it's async
|
|
140
|
+
metadata = self.registry.get_metadata(call.tool) if hasattr(self.registry, "get_metadata") else None
|
|
141
|
+
is_async = metadata.is_async if metadata else asyncio.iscoroutinefunction(tool_instance.execute)
|
|
142
|
+
|
|
143
|
+
# Call the tool implementation
|
|
144
|
+
if is_async:
|
|
145
|
+
# Direct async call
|
|
146
|
+
if timeout:
|
|
147
|
+
result_value = await asyncio.wait_for(
|
|
148
|
+
tool_instance.execute(**call.arguments),
|
|
149
|
+
timeout
|
|
150
|
+
)
|
|
151
|
+
else:
|
|
152
|
+
result_value = await tool_instance.execute(**call.arguments)
|
|
153
|
+
else:
|
|
154
|
+
# Run sync function in executor
|
|
155
|
+
loop = asyncio.get_running_loop()
|
|
156
|
+
if timeout:
|
|
157
|
+
result_value = await asyncio.wait_for(
|
|
158
|
+
loop.run_in_executor(
|
|
159
|
+
None,
|
|
160
|
+
lambda: tool_instance.execute(**call.arguments)
|
|
161
|
+
),
|
|
162
|
+
timeout
|
|
163
|
+
)
|
|
164
|
+
else:
|
|
165
|
+
result_value = await loop.run_in_executor(
|
|
166
|
+
None,
|
|
167
|
+
lambda: tool_instance.execute(**call.arguments)
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# Create successful result
|
|
171
|
+
end_time = datetime.now(timezone.utc)
|
|
172
|
+
return ToolResult(
|
|
173
|
+
tool=call.tool,
|
|
174
|
+
result=result_value,
|
|
175
|
+
error=None,
|
|
176
|
+
start_time=start_time,
|
|
177
|
+
end_time=end_time,
|
|
178
|
+
machine=machine,
|
|
179
|
+
pid=pid
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
except asyncio.TimeoutError:
|
|
183
|
+
# Handle timeout
|
|
184
|
+
end_time = datetime.now(timezone.utc)
|
|
185
|
+
return ToolResult(
|
|
186
|
+
tool=call.tool,
|
|
187
|
+
result=None,
|
|
188
|
+
error=f"Timeout after {timeout}s",
|
|
189
|
+
start_time=start_time,
|
|
190
|
+
end_time=end_time,
|
|
191
|
+
machine=machine,
|
|
192
|
+
pid=pid
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
except Exception as e:
|
|
196
|
+
# Handle execution error
|
|
197
|
+
end_time = datetime.now(timezone.utc)
|
|
198
|
+
return ToolResult(
|
|
199
|
+
tool=call.tool,
|
|
200
|
+
result=None,
|
|
201
|
+
error=str(e),
|
|
202
|
+
start_time=start_time,
|
|
203
|
+
end_time=end_time,
|
|
204
|
+
machine=machine,
|
|
205
|
+
pid=pid
|
|
206
|
+
)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# chuk_tool_processor/execution/subprocess_strategy.py
|
|
2
|
+
import asyncio
|
|
3
|
+
from chuk_tool_processor.execution.strategies.inprocess_strategy import InProcessStrategy
|
|
4
|
+
import os
|
|
5
|
+
import importlib
|
|
6
|
+
import inspect
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import List, Optional, Dict, Any
|
|
9
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
10
|
+
|
|
11
|
+
# imports
|
|
12
|
+
from chuk_tool_processor.models.execution_strategy import ExecutionStrategy
|
|
13
|
+
from chuk_tool_processor.models.tool_call import ToolCall
|
|
14
|
+
from chuk_tool_processor.models.tool_result import ToolResult
|
|
15
|
+
from chuk_tool_processor.utils.logging import get_logger
|
|
16
|
+
|
|
17
|
+
logger = get_logger("chuk_tool_processor.execution.subprocess_strategy")
|
|
18
|
+
|
|
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
|
+
|
|
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
|
+
Returns:
|
|
33
|
+
A dict containing result, error, start_time, end_time, pid, machine.
|
|
34
|
+
"""
|
|
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
|
+
|
|
42
|
+
start_time = datetime.now(timezone.utc)
|
|
43
|
+
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
|
+
|
|
47
|
+
try:
|
|
48
|
+
if not module_name or not class_name:
|
|
49
|
+
result_data["error"] = f"Missing module_name or class_name for tool {tool_name}"
|
|
50
|
+
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}"
|
|
57
|
+
return result_data
|
|
58
|
+
|
|
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()
|
|
69
|
+
else:
|
|
70
|
+
result_data["result"] = tool_instance.execute(**arguments)
|
|
71
|
+
except Exception as e:
|
|
72
|
+
result_data["error"] = str(e)
|
|
73
|
+
finally:
|
|
74
|
+
result_data["end_time"] = datetime.now(timezone.utc)
|
|
75
|
+
return result_data
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class SubprocessStrategy(ExecutionStrategy):
|
|
79
|
+
"""
|
|
80
|
+
Executes tool calls in-process via InProcessStrategy for compatibility with local tool definitions and tests.
|
|
81
|
+
"""
|
|
82
|
+
def __init__(self, registry, max_workers: int = 4, default_timeout: Optional[float] = None):
|
|
83
|
+
"""
|
|
84
|
+
Initialize with in-process strategy delegation.
|
|
85
|
+
"""
|
|
86
|
+
self.registry = registry
|
|
87
|
+
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
|
+
)
|
|
94
|
+
|
|
95
|
+
async def run(
|
|
96
|
+
self,
|
|
97
|
+
calls: List[ToolCall],
|
|
98
|
+
timeout: Optional[float] = None
|
|
99
|
+
) -> List[ToolResult]:
|
|
100
|
+
"""
|
|
101
|
+
Execute tool calls using in-process strategy.
|
|
102
|
+
"""
|
|
103
|
+
return await self._strategy.run(calls, timeout=timeout)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# chuk_tool_processor/execution/tool_executor.py
|
|
2
|
+
from typing import List, Optional
|
|
3
|
+
|
|
4
|
+
# Lazy import of in-process strategy to allow monkeypatching
|
|
5
|
+
import chuk_tool_processor.execution.strategies.inprocess_strategy as inprocess_mod
|
|
6
|
+
from chuk_tool_processor.models.execution_strategy import ExecutionStrategy
|
|
7
|
+
from chuk_tool_processor.models.tool_call import ToolCall
|
|
8
|
+
from chuk_tool_processor.models.tool_result import ToolResult
|
|
9
|
+
from chuk_tool_processor.registry.interface import ToolRegistryInterface
|
|
10
|
+
|
|
11
|
+
class ToolExecutor:
|
|
12
|
+
"""
|
|
13
|
+
Wraps an ExecutionStrategy (in‐process or subprocess) and provides
|
|
14
|
+
a default_timeout shortcut for convenience.
|
|
15
|
+
"""
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
registry: ToolRegistryInterface,
|
|
19
|
+
default_timeout: float = 1.0,
|
|
20
|
+
strategy: Optional[ExecutionStrategy] = None,
|
|
21
|
+
# allow passing through to SubprocessStrategy if needed:
|
|
22
|
+
strategy_kwargs: dict = {}
|
|
23
|
+
):
|
|
24
|
+
# If user supplied a strategy, use it; otherwise default to in-process
|
|
25
|
+
if strategy is not None:
|
|
26
|
+
self.strategy = strategy
|
|
27
|
+
else:
|
|
28
|
+
# Use module-level InProcessStrategy, so monkeypatching works
|
|
29
|
+
# Pass positional args to match patched FakeInProcess signature
|
|
30
|
+
self.strategy = inprocess_mod.InProcessStrategy(
|
|
31
|
+
registry,
|
|
32
|
+
default_timeout,
|
|
33
|
+
**strategy_kwargs
|
|
34
|
+
)
|
|
35
|
+
self.registry = registry
|
|
36
|
+
|
|
37
|
+
async def execute(
|
|
38
|
+
self,
|
|
39
|
+
calls: List[ToolCall],
|
|
40
|
+
timeout: Optional[float] = None
|
|
41
|
+
) -> List[ToolResult]:
|
|
42
|
+
"""
|
|
43
|
+
Execute the list of calls with the underlying strategy.
|
|
44
|
+
`timeout` here overrides the strategy's default_timeout.
|
|
45
|
+
"""
|
|
46
|
+
return await self.strategy.run(calls, timeout=timeout)
|
|
File without changes
|