tactus 0.34.1__py3-none-any.whl → 0.35.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.
- tactus/__init__.py +1 -1
- tactus/adapters/broker_log.py +17 -14
- tactus/adapters/channels/__init__.py +17 -15
- tactus/adapters/channels/base.py +16 -7
- tactus/adapters/channels/broker.py +43 -13
- tactus/adapters/channels/cli.py +19 -15
- tactus/adapters/channels/host.py +15 -6
- tactus/adapters/channels/ipc.py +82 -31
- tactus/adapters/channels/sse.py +41 -23
- tactus/adapters/cli_hitl.py +19 -19
- tactus/adapters/cli_log.py +4 -4
- tactus/adapters/control_loop.py +138 -99
- tactus/adapters/cost_collector_log.py +9 -9
- tactus/adapters/file_storage.py +56 -52
- tactus/adapters/http_callback_log.py +23 -13
- tactus/adapters/ide_log.py +17 -9
- tactus/adapters/lua_tools.py +4 -5
- tactus/adapters/mcp.py +16 -19
- tactus/adapters/mcp_manager.py +46 -30
- tactus/adapters/memory.py +9 -9
- tactus/adapters/plugins.py +42 -42
- tactus/broker/client.py +75 -78
- tactus/broker/protocol.py +57 -57
- tactus/broker/server.py +252 -197
- tactus/cli/app.py +3 -1
- tactus/cli/control.py +2 -2
- tactus/core/config_manager.py +181 -135
- tactus/core/dependencies/registry.py +66 -48
- tactus/core/dsl_stubs.py +222 -163
- tactus/core/exceptions.py +10 -1
- tactus/core/execution_context.py +152 -112
- tactus/core/lua_sandbox.py +72 -64
- tactus/core/message_history_manager.py +138 -43
- tactus/core/mocking.py +41 -27
- tactus/core/output_validator.py +49 -44
- tactus/core/registry.py +94 -80
- tactus/core/runtime.py +211 -176
- tactus/core/template_resolver.py +16 -16
- tactus/core/yaml_parser.py +55 -45
- tactus/docs/extractor.py +7 -6
- tactus/ide/server.py +119 -78
- tactus/primitives/control.py +10 -6
- tactus/primitives/file.py +48 -46
- tactus/primitives/handles.py +47 -35
- tactus/primitives/host.py +29 -27
- tactus/primitives/human.py +154 -137
- tactus/primitives/json.py +22 -23
- tactus/primitives/log.py +26 -26
- tactus/primitives/message_history.py +285 -31
- tactus/primitives/model.py +15 -9
- tactus/primitives/procedure.py +86 -64
- tactus/primitives/procedure_callable.py +58 -51
- tactus/primitives/retry.py +31 -29
- tactus/primitives/session.py +42 -29
- tactus/primitives/state.py +54 -43
- tactus/primitives/step.py +9 -13
- tactus/primitives/system.py +34 -21
- tactus/primitives/tool.py +44 -31
- tactus/primitives/tool_handle.py +76 -54
- tactus/primitives/toolset.py +25 -22
- tactus/sandbox/config.py +4 -4
- tactus/sandbox/container_runner.py +161 -107
- tactus/sandbox/docker_manager.py +20 -20
- tactus/sandbox/entrypoint.py +16 -14
- tactus/sandbox/protocol.py +15 -15
- tactus/stdlib/classify/llm.py +1 -3
- tactus/stdlib/core/validation.py +0 -3
- tactus/testing/pydantic_eval_runner.py +1 -1
- tactus/utils/asyncio_helpers.py +27 -0
- tactus/utils/cost_calculator.py +7 -7
- tactus/utils/model_pricing.py +11 -12
- tactus/utils/safe_file_library.py +156 -132
- tactus/utils/safe_libraries.py +27 -27
- tactus/validation/error_listener.py +18 -5
- tactus/validation/semantic_visitor.py +392 -333
- tactus/validation/validator.py +89 -49
- {tactus-0.34.1.dist-info → tactus-0.35.0.dist-info}/METADATA +12 -3
- {tactus-0.34.1.dist-info → tactus-0.35.0.dist-info}/RECORD +81 -80
- {tactus-0.34.1.dist-info → tactus-0.35.0.dist-info}/WHEEL +0 -0
- {tactus-0.34.1.dist-info → tactus-0.35.0.dist-info}/entry_points.txt +0 -0
- {tactus-0.34.1.dist-info → tactus-0.35.0.dist-info}/licenses/LICENSE +0 -0
tactus/primitives/tool.py
CHANGED
|
@@ -9,7 +9,7 @@ Provides:
|
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
import logging
|
|
12
|
-
from typing import Any, Optional,
|
|
12
|
+
from typing import Any, Optional, TYPE_CHECKING
|
|
13
13
|
|
|
14
14
|
if TYPE_CHECKING:
|
|
15
15
|
from tactus.primitives.tool_handle import ToolHandle
|
|
@@ -20,13 +20,13 @@ logger = logging.getLogger(__name__)
|
|
|
20
20
|
class ToolCall:
|
|
21
21
|
"""Represents a single tool call with arguments and result."""
|
|
22
22
|
|
|
23
|
-
def __init__(self, name: str, args:
|
|
23
|
+
def __init__(self, name: str, args: dict[str, Any], result: Any):
|
|
24
24
|
self.name = name
|
|
25
25
|
self.args = args
|
|
26
26
|
self.result = result
|
|
27
27
|
self.timestamp = None # Could add timestamp tracking
|
|
28
28
|
|
|
29
|
-
def to_dict(self) ->
|
|
29
|
+
def to_dict(self) -> dict[str, Any]:
|
|
30
30
|
"""Convert to dictionary for Lua access."""
|
|
31
31
|
return {"name": self.name, "args": self.args, "result": self.result}
|
|
32
32
|
|
|
@@ -47,19 +47,22 @@ class ToolPrimitive:
|
|
|
47
47
|
"""
|
|
48
48
|
|
|
49
49
|
def __init__(
|
|
50
|
-
self,
|
|
50
|
+
self,
|
|
51
|
+
log_handler=None,
|
|
52
|
+
agent_name: Optional[str] = None,
|
|
53
|
+
procedure_id: Optional[str] = None,
|
|
51
54
|
):
|
|
52
55
|
"""Initialize tool tracking."""
|
|
53
|
-
self._tool_calls:
|
|
54
|
-
self._last_calls:
|
|
56
|
+
self._tool_calls: list[ToolCall] = []
|
|
57
|
+
self._last_calls: dict[str, ToolCall] = {} # name -> last call
|
|
55
58
|
self.log_handler = log_handler
|
|
56
59
|
self.agent_name = agent_name
|
|
57
60
|
self.procedure_id = procedure_id
|
|
58
61
|
self._runtime = None # Will be set by runtime for Tool.get() support
|
|
59
|
-
self._tool_registry:
|
|
62
|
+
self._tool_registry: dict[str, "ToolHandle"] = {} # For Tool("name") lookup
|
|
60
63
|
logger.debug("ToolPrimitive initialized")
|
|
61
64
|
|
|
62
|
-
def set_tool_registry(self, registry:
|
|
65
|
+
def set_tool_registry(self, registry: dict[str, "ToolHandle"]) -> None:
|
|
63
66
|
"""
|
|
64
67
|
Set the tool registry for Tool("name") lookup.
|
|
65
68
|
|
|
@@ -69,7 +72,7 @@ class ToolPrimitive:
|
|
|
69
72
|
registry: Dict mapping tool names to ToolHandle instances
|
|
70
73
|
"""
|
|
71
74
|
self._tool_registry = registry
|
|
72
|
-
logger.debug(
|
|
75
|
+
logger.debug("ToolPrimitive tool registry set with %s tools", len(registry))
|
|
73
76
|
|
|
74
77
|
def __call__(self, tool_name: str) -> "ToolHandle":
|
|
75
78
|
"""
|
|
@@ -132,7 +135,7 @@ class ToolPrimitive:
|
|
|
132
135
|
"""
|
|
133
136
|
from tactus.primitives.tool_handle import ToolHandle
|
|
134
137
|
|
|
135
|
-
logger.debug(
|
|
138
|
+
logger.debug("Tool.get('%s') called", tool_name)
|
|
136
139
|
|
|
137
140
|
# Look up toolset from runtime registry
|
|
138
141
|
toolset = self._get_toolset(tool_name)
|
|
@@ -143,10 +146,10 @@ class ToolPrimitive:
|
|
|
143
146
|
)
|
|
144
147
|
|
|
145
148
|
# Extract the callable function from the toolset
|
|
146
|
-
|
|
149
|
+
tool_function = self._extract_tool_function(toolset, tool_name)
|
|
147
150
|
|
|
148
|
-
logger.debug(
|
|
149
|
-
return ToolHandle(tool_name,
|
|
151
|
+
logger.debug("Tool.get('%s') returning ToolHandle", tool_name)
|
|
152
|
+
return ToolHandle(tool_name, tool_function, self)
|
|
150
153
|
|
|
151
154
|
def _get_toolset(self, name: str) -> Optional[Any]:
|
|
152
155
|
"""
|
|
@@ -226,7 +229,9 @@ class ToolPrimitive:
|
|
|
226
229
|
|
|
227
230
|
# Fallback: assume the toolset itself contains tool functions
|
|
228
231
|
logger.warning(
|
|
229
|
-
|
|
232
|
+
"Could not extract tool function for '%s' from toolset type %s",
|
|
233
|
+
tool_name,
|
|
234
|
+
type(toolset),
|
|
230
235
|
)
|
|
231
236
|
|
|
232
237
|
# Return a wrapper that attempts to call through the toolset
|
|
@@ -252,9 +257,9 @@ class ToolPrimitive:
|
|
|
252
257
|
Log.info("Done tool was called")
|
|
253
258
|
end
|
|
254
259
|
"""
|
|
255
|
-
|
|
256
|
-
logger.debug(
|
|
257
|
-
return
|
|
260
|
+
was_called = tool_name in self._last_calls
|
|
261
|
+
logger.debug("Tool.called('%s') = %s", tool_name, was_called)
|
|
262
|
+
return was_called
|
|
258
263
|
|
|
259
264
|
def last_result(self, tool_name: str) -> Any:
|
|
260
265
|
"""
|
|
@@ -273,14 +278,14 @@ class ToolPrimitive:
|
|
|
273
278
|
end
|
|
274
279
|
"""
|
|
275
280
|
if tool_name not in self._last_calls:
|
|
276
|
-
logger.debug(
|
|
281
|
+
logger.debug("Tool.last_result('%s') = None (never called)", tool_name)
|
|
277
282
|
return None
|
|
278
283
|
|
|
279
284
|
result = self._last_calls[tool_name].result
|
|
280
|
-
logger.debug(
|
|
285
|
+
logger.debug("Tool.last_result('%s') = %s", tool_name, result)
|
|
281
286
|
return result
|
|
282
287
|
|
|
283
|
-
def last_call(self, tool_name: str) -> Optional[
|
|
288
|
+
def last_call(self, tool_name: str) -> Optional[dict[str, Any]]:
|
|
284
289
|
"""
|
|
285
290
|
Get full information about the last call to a tool.
|
|
286
291
|
|
|
@@ -298,15 +303,19 @@ class ToolPrimitive:
|
|
|
298
303
|
end
|
|
299
304
|
"""
|
|
300
305
|
if tool_name not in self._last_calls:
|
|
301
|
-
logger.debug(
|
|
306
|
+
logger.debug("Tool.last_call('%s') = None (never called)", tool_name)
|
|
302
307
|
return None
|
|
303
308
|
|
|
304
309
|
call_dict = self._last_calls[tool_name].to_dict()
|
|
305
|
-
logger.debug(
|
|
310
|
+
logger.debug("Tool.last_call('%s') = %s", tool_name, call_dict)
|
|
306
311
|
return call_dict
|
|
307
312
|
|
|
308
313
|
def record_call(
|
|
309
|
-
self,
|
|
314
|
+
self,
|
|
315
|
+
tool_name: str,
|
|
316
|
+
args: dict[str, Any],
|
|
317
|
+
result: Any,
|
|
318
|
+
agent_name: Optional[str] = None,
|
|
310
319
|
) -> None:
|
|
311
320
|
"""
|
|
312
321
|
Record a tool call (called by runtime after tool execution).
|
|
@@ -319,11 +328,15 @@ class ToolPrimitive:
|
|
|
319
328
|
|
|
320
329
|
Note: This is called internally by the runtime, not from Lua
|
|
321
330
|
"""
|
|
322
|
-
|
|
323
|
-
self._tool_calls.append(
|
|
324
|
-
self._last_calls[tool_name] =
|
|
325
|
-
|
|
326
|
-
logger.debug(
|
|
331
|
+
tool_call = ToolCall(tool_name, args, result)
|
|
332
|
+
self._tool_calls.append(tool_call)
|
|
333
|
+
self._last_calls[tool_name] = tool_call
|
|
334
|
+
|
|
335
|
+
logger.debug(
|
|
336
|
+
"Tool call recorded: %s -> %s total calls",
|
|
337
|
+
tool_name,
|
|
338
|
+
len(self._tool_calls),
|
|
339
|
+
)
|
|
327
340
|
|
|
328
341
|
# Emit ToolCallEvent if we have a log handler
|
|
329
342
|
if self.log_handler:
|
|
@@ -338,10 +351,10 @@ class ToolPrimitive:
|
|
|
338
351
|
procedure_id=self.procedure_id,
|
|
339
352
|
)
|
|
340
353
|
self.log_handler.log(event)
|
|
341
|
-
except Exception as
|
|
342
|
-
logger.warning(
|
|
354
|
+
except Exception as error:
|
|
355
|
+
logger.warning("Failed to log tool call event: %s", error)
|
|
343
356
|
|
|
344
|
-
def get_all_calls(self) ->
|
|
357
|
+
def get_all_calls(self) -> list[ToolCall]:
|
|
345
358
|
"""
|
|
346
359
|
Get all tool calls (for debugging/logging).
|
|
347
360
|
|
tactus/primitives/tool_handle.py
CHANGED
|
@@ -7,7 +7,9 @@ that can be invoked directly without going through an agent.
|
|
|
7
7
|
|
|
8
8
|
import asyncio
|
|
9
9
|
import logging
|
|
10
|
-
from typing import Any, Callable,
|
|
10
|
+
from typing import Any, Callable, Optional, TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from tactus.utils.asyncio_helpers import clear_closed_event_loop
|
|
11
13
|
|
|
12
14
|
if TYPE_CHECKING:
|
|
13
15
|
from tactus.primitives.tool import ToolPrimitive
|
|
@@ -30,7 +32,7 @@ class ToolHandle:
|
|
|
30
32
|
def __init__(
|
|
31
33
|
self,
|
|
32
34
|
name: str,
|
|
33
|
-
|
|
35
|
+
implementation_function: Callable,
|
|
34
36
|
tool_primitive: Optional["ToolPrimitive"] = None,
|
|
35
37
|
is_async: bool = False,
|
|
36
38
|
record_calls: bool = True,
|
|
@@ -40,19 +42,34 @@ class ToolHandle:
|
|
|
40
42
|
|
|
41
43
|
Args:
|
|
42
44
|
name: Tool name for tracking/logging
|
|
43
|
-
|
|
45
|
+
implementation_function: The actual function to execute
|
|
44
46
|
tool_primitive: Optional ToolPrimitive for call recording
|
|
45
|
-
is_async: Whether
|
|
47
|
+
is_async: Whether the implementation function is async (for MCP tools)
|
|
46
48
|
"""
|
|
47
49
|
self.name = name
|
|
48
|
-
self.
|
|
50
|
+
self.implementation_function = implementation_function
|
|
49
51
|
self.tool_primitive = tool_primitive
|
|
50
52
|
self.is_async = is_async
|
|
51
53
|
self.record_calls = record_calls
|
|
52
54
|
|
|
53
|
-
logger.debug(
|
|
55
|
+
logger.debug("ToolHandle created for '%s' (async=%s)", name, is_async)
|
|
56
|
+
|
|
57
|
+
def _has_tool_primitive(self) -> bool:
|
|
58
|
+
return self.tool_primitive is not None
|
|
59
|
+
|
|
60
|
+
def _normalize_tool_arguments(self, tool_arguments: Any) -> Any:
|
|
61
|
+
"""
|
|
62
|
+
Convert a Lua table or mapping-like input to a plain Python dict when possible.
|
|
63
|
+
"""
|
|
64
|
+
if tool_arguments is None:
|
|
65
|
+
return {}
|
|
66
|
+
|
|
67
|
+
if hasattr(tool_arguments, "items"):
|
|
68
|
+
return self._lua_table_to_dict(tool_arguments)
|
|
69
|
+
|
|
70
|
+
return tool_arguments
|
|
54
71
|
|
|
55
|
-
def call(self, args:
|
|
72
|
+
def call(self, args: dict[str, Any]) -> Any:
|
|
56
73
|
"""
|
|
57
74
|
Execute the tool with given arguments.
|
|
58
75
|
|
|
@@ -65,31 +82,35 @@ class ToolHandle:
|
|
|
65
82
|
Example (Lua):
|
|
66
83
|
local result = my_tool:call({arg1 = "value"})
|
|
67
84
|
"""
|
|
68
|
-
logger.debug(
|
|
85
|
+
logger.debug("ToolHandle.call('%s') with args: %s", self.name, args)
|
|
69
86
|
|
|
70
87
|
try:
|
|
71
88
|
# Convert Lua table to Python dict if needed
|
|
72
|
-
|
|
73
|
-
args = self._lua_table_to_dict(args)
|
|
89
|
+
normalized_arguments = self._normalize_tool_arguments(args)
|
|
74
90
|
|
|
75
91
|
# Execute the implementation
|
|
76
|
-
if self.is_async or asyncio.iscoroutinefunction(self.
|
|
77
|
-
result = self._run_async(
|
|
92
|
+
if self.is_async or asyncio.iscoroutinefunction(self.implementation_function):
|
|
93
|
+
result = self._run_async(normalized_arguments)
|
|
78
94
|
else:
|
|
79
|
-
result = self.
|
|
95
|
+
result = self.implementation_function(normalized_arguments)
|
|
80
96
|
|
|
81
97
|
# Record the call for tracking
|
|
82
98
|
if self.tool_primitive and self.record_calls:
|
|
83
|
-
self.tool_primitive.record_call(self.name,
|
|
99
|
+
self.tool_primitive.record_call(self.name, normalized_arguments, result)
|
|
84
100
|
|
|
85
|
-
logger.debug(
|
|
101
|
+
logger.debug("ToolHandle.call('%s') returned: %s", self.name, result)
|
|
86
102
|
return result
|
|
87
103
|
|
|
88
|
-
except Exception as
|
|
89
|
-
logger.error(
|
|
104
|
+
except Exception as error:
|
|
105
|
+
logger.error(
|
|
106
|
+
"ToolHandle.call('%s') failed: %s",
|
|
107
|
+
self.name,
|
|
108
|
+
error,
|
|
109
|
+
exc_info=True,
|
|
110
|
+
)
|
|
90
111
|
raise
|
|
91
112
|
|
|
92
|
-
def __call__(self, args:
|
|
113
|
+
def __call__(self, args: dict[str, Any]) -> Any:
|
|
93
114
|
"""
|
|
94
115
|
Make handle callable for shorthand syntax.
|
|
95
116
|
|
|
@@ -110,15 +131,15 @@ class ToolHandle:
|
|
|
110
131
|
Log.info("Task completed!")
|
|
111
132
|
end
|
|
112
133
|
"""
|
|
113
|
-
if not self.
|
|
114
|
-
logger.warning(
|
|
134
|
+
if not self._has_tool_primitive():
|
|
135
|
+
logger.warning("ToolHandle.called('%s'): No tool_primitive attached", self.name)
|
|
115
136
|
return False
|
|
116
137
|
|
|
117
138
|
result = self.tool_primitive.called(self.name)
|
|
118
|
-
logger.debug(
|
|
139
|
+
logger.debug("ToolHandle.called('%s') = %s", self.name, result)
|
|
119
140
|
return result
|
|
120
141
|
|
|
121
|
-
def last_call(self) -> Optional[
|
|
142
|
+
def last_call(self) -> Optional[dict[str, Any]]:
|
|
122
143
|
"""
|
|
123
144
|
Get the last call record for this tool.
|
|
124
145
|
|
|
@@ -131,12 +152,12 @@ class ToolHandle:
|
|
|
131
152
|
Log.info("Last multiply: " .. call.args.a .. " * " .. call.args.b)
|
|
132
153
|
end
|
|
133
154
|
"""
|
|
134
|
-
if not self.
|
|
135
|
-
logger.warning(
|
|
155
|
+
if not self._has_tool_primitive():
|
|
156
|
+
logger.warning("ToolHandle.last_call('%s'): No tool_primitive attached", self.name)
|
|
136
157
|
return None
|
|
137
158
|
|
|
138
159
|
result = self.tool_primitive.last_call(self.name)
|
|
139
|
-
logger.debug(
|
|
160
|
+
logger.debug("ToolHandle.last_call('%s') = %s", self.name, result)
|
|
140
161
|
return result
|
|
141
162
|
|
|
142
163
|
def last_result(self) -> Any:
|
|
@@ -150,12 +171,12 @@ class ToolHandle:
|
|
|
150
171
|
local answer = done.last_result()
|
|
151
172
|
return { result = answer }
|
|
152
173
|
"""
|
|
153
|
-
if not self.
|
|
154
|
-
logger.warning(
|
|
174
|
+
if not self._has_tool_primitive():
|
|
175
|
+
logger.warning("ToolHandle.last_result('%s'): No tool_primitive attached", self.name)
|
|
155
176
|
return None
|
|
156
177
|
|
|
157
178
|
result = self.tool_primitive.last_result(self.name)
|
|
158
|
-
logger.debug(
|
|
179
|
+
logger.debug("ToolHandle.last_result('%s') = %s", self.name, result)
|
|
159
180
|
return result
|
|
160
181
|
|
|
161
182
|
def call_count(self) -> int:
|
|
@@ -169,13 +190,13 @@ class ToolHandle:
|
|
|
169
190
|
local count = multiply.call_count()
|
|
170
191
|
Log.info("Multiply was called " .. count .. " times")
|
|
171
192
|
"""
|
|
172
|
-
if not self.
|
|
173
|
-
logger.warning(
|
|
193
|
+
if not self._has_tool_primitive():
|
|
194
|
+
logger.warning("ToolHandle.call_count('%s'): No tool_primitive attached", self.name)
|
|
174
195
|
return 0
|
|
175
196
|
|
|
176
197
|
# Count all calls with this tool name
|
|
177
198
|
count = sum(1 for call in self.tool_primitive._tool_calls if call.name == self.name)
|
|
178
|
-
logger.debug(
|
|
199
|
+
logger.debug("ToolHandle.call_count('%s') = %s", self.name, count)
|
|
179
200
|
return count
|
|
180
201
|
|
|
181
202
|
def reset(self) -> None:
|
|
@@ -202,17 +223,17 @@ class ToolHandle:
|
|
|
202
223
|
Log.info("Agent 2 completed")
|
|
203
224
|
end
|
|
204
225
|
"""
|
|
205
|
-
if not self.
|
|
206
|
-
logger.warning(
|
|
226
|
+
if not self._has_tool_primitive():
|
|
227
|
+
logger.warning("ToolHandle.reset('%s'): No tool_primitive attached", self.name)
|
|
207
228
|
return
|
|
208
229
|
|
|
209
230
|
# Remove all calls for this tool
|
|
210
231
|
self.tool_primitive._tool_calls = [
|
|
211
232
|
call for call in self.tool_primitive._tool_calls if call.name != self.name
|
|
212
233
|
]
|
|
213
|
-
logger.debug(
|
|
234
|
+
logger.debug("ToolHandle.reset('%s'): Cleared all call records", self.name)
|
|
214
235
|
|
|
215
|
-
def _run_async(self, args:
|
|
236
|
+
def _run_async(self, args: dict[str, Any]) -> Any:
|
|
216
237
|
"""
|
|
217
238
|
Run async function from sync context.
|
|
218
239
|
|
|
@@ -220,46 +241,47 @@ class ToolHandle:
|
|
|
220
241
|
"""
|
|
221
242
|
try:
|
|
222
243
|
# Try to get a running event loop
|
|
223
|
-
|
|
244
|
+
running_loop = asyncio.get_running_loop()
|
|
224
245
|
|
|
225
246
|
# We're in an async context - use nest_asyncio if available
|
|
226
247
|
try:
|
|
227
248
|
import nest_asyncio
|
|
228
249
|
|
|
229
|
-
nest_asyncio.apply(
|
|
230
|
-
return asyncio.run(self.
|
|
250
|
+
nest_asyncio.apply(running_loop)
|
|
251
|
+
return asyncio.run(self.implementation_function(args))
|
|
231
252
|
except ImportError:
|
|
232
253
|
# nest_asyncio not available, fall back to threading
|
|
233
254
|
import threading
|
|
234
255
|
|
|
235
|
-
|
|
256
|
+
async_result = {"value": None, "exception": None}
|
|
236
257
|
|
|
237
258
|
def run_in_thread():
|
|
238
259
|
try:
|
|
239
|
-
|
|
240
|
-
asyncio.set_event_loop(
|
|
260
|
+
thread_event_loop = asyncio.new_event_loop()
|
|
261
|
+
asyncio.set_event_loop(thread_event_loop)
|
|
241
262
|
try:
|
|
242
|
-
|
|
243
|
-
self.
|
|
263
|
+
async_result["value"] = thread_event_loop.run_until_complete(
|
|
264
|
+
self.implementation_function(args)
|
|
244
265
|
)
|
|
245
266
|
finally:
|
|
246
|
-
|
|
247
|
-
except Exception as
|
|
248
|
-
|
|
267
|
+
thread_event_loop.close()
|
|
268
|
+
except Exception as error:
|
|
269
|
+
async_result["exception"] = error
|
|
249
270
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
271
|
+
worker_thread = threading.Thread(target=run_in_thread)
|
|
272
|
+
worker_thread.start()
|
|
273
|
+
worker_thread.join()
|
|
253
274
|
|
|
254
|
-
if
|
|
255
|
-
raise
|
|
256
|
-
return
|
|
275
|
+
if async_result["exception"]:
|
|
276
|
+
raise async_result["exception"]
|
|
277
|
+
return async_result["value"]
|
|
257
278
|
|
|
258
279
|
except RuntimeError:
|
|
259
280
|
# No event loop running - safe to use asyncio.run()
|
|
260
|
-
|
|
281
|
+
clear_closed_event_loop()
|
|
282
|
+
return asyncio.run(self.implementation_function(args))
|
|
261
283
|
|
|
262
|
-
def _lua_table_to_dict(self, lua_table) ->
|
|
284
|
+
def _lua_table_to_dict(self, lua_table: Any) -> Any:
|
|
263
285
|
"""Convert a Lua table to Python dict recursively."""
|
|
264
286
|
if lua_table is None:
|
|
265
287
|
return {}
|
tactus/primitives/toolset.py
CHANGED
|
@@ -5,7 +5,7 @@ Provides first-class support for Pydantic AI's composable toolset architecture.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import logging
|
|
8
|
-
from typing import Any,
|
|
8
|
+
from typing import Any, Callable
|
|
9
9
|
from pydantic_ai.toolsets import AbstractToolset, CombinedToolset, FilteredToolset
|
|
10
10
|
|
|
11
11
|
logger = logging.getLogger(__name__)
|
|
@@ -36,7 +36,7 @@ class ToolsetPrimitive:
|
|
|
36
36
|
self.definitions = {} # name -> toolset config (from DSL)
|
|
37
37
|
logger.debug("ToolsetPrimitive initialized")
|
|
38
38
|
|
|
39
|
-
def define(self, name: str, config:
|
|
39
|
+
def define(self, name: str, config: dict[str, Any]) -> None:
|
|
40
40
|
"""
|
|
41
41
|
Register a toolset definition from the DSL.
|
|
42
42
|
|
|
@@ -61,7 +61,11 @@ class ToolsetPrimitive:
|
|
|
61
61
|
}
|
|
62
62
|
"""
|
|
63
63
|
self.definitions[name] = config
|
|
64
|
-
logger.info(
|
|
64
|
+
logger.info(
|
|
65
|
+
"Defined toolset '%s' of type '%s'",
|
|
66
|
+
name,
|
|
67
|
+
config.get("type"),
|
|
68
|
+
)
|
|
65
69
|
|
|
66
70
|
def get(self, name: str) -> AbstractToolset:
|
|
67
71
|
"""
|
|
@@ -80,9 +84,9 @@ class ToolsetPrimitive:
|
|
|
80
84
|
ValueError: If toolset not found
|
|
81
85
|
"""
|
|
82
86
|
# Try to resolve from runtime first (config-defined toolsets)
|
|
83
|
-
|
|
84
|
-
if
|
|
85
|
-
return
|
|
87
|
+
toolset_from_runtime = self.runtime.resolve_toolset(name)
|
|
88
|
+
if toolset_from_runtime:
|
|
89
|
+
return toolset_from_runtime
|
|
86
90
|
|
|
87
91
|
# Try DSL definitions
|
|
88
92
|
if name in self.definitions:
|
|
@@ -101,7 +105,7 @@ class ToolsetPrimitive:
|
|
|
101
105
|
CombinedToolset containing all input toolsets
|
|
102
106
|
"""
|
|
103
107
|
toolset_list = list(toolsets)
|
|
104
|
-
logger.debug(
|
|
108
|
+
logger.debug("Combining %s toolsets", len(toolset_list))
|
|
105
109
|
return CombinedToolset(toolset_list)
|
|
106
110
|
|
|
107
111
|
def filter(self, toolset: AbstractToolset, predicate: Callable[[str], bool]) -> FilteredToolset:
|
|
@@ -123,14 +127,14 @@ class ToolsetPrimitive:
|
|
|
123
127
|
|
|
124
128
|
# Wrap Lua function for Pydantic AI's filter API
|
|
125
129
|
# Pydantic AI's filtered() expects: lambda ctx, tool: bool
|
|
126
|
-
def pydantic_filter(
|
|
130
|
+
def pydantic_filter(_context, tool):
|
|
127
131
|
# Call Lua predicate with just the tool name
|
|
128
132
|
return predicate(tool.name)
|
|
129
133
|
|
|
130
134
|
logger.debug("Creating filtered toolset")
|
|
131
135
|
return toolset.filtered(pydantic_filter)
|
|
132
136
|
|
|
133
|
-
def _create_toolset_from_definition(self, name: str, config:
|
|
137
|
+
def _create_toolset_from_definition(self, name: str, config: dict[str, Any]) -> AbstractToolset:
|
|
134
138
|
"""
|
|
135
139
|
Create a toolset from a DSL definition.
|
|
136
140
|
|
|
@@ -148,16 +152,15 @@ class ToolsetPrimitive:
|
|
|
148
152
|
|
|
149
153
|
if toolset_type == "plugin":
|
|
150
154
|
return self._create_plugin_toolset(name, config)
|
|
151
|
-
|
|
155
|
+
if toolset_type == "mcp":
|
|
152
156
|
return self._create_mcp_toolset_reference(name, config)
|
|
153
|
-
|
|
157
|
+
if toolset_type == "combined":
|
|
154
158
|
return self._create_combined_toolset(name, config)
|
|
155
|
-
|
|
159
|
+
if toolset_type == "filtered":
|
|
156
160
|
return self._create_filtered_toolset(name, config)
|
|
157
|
-
|
|
158
|
-
raise ValueError(f"Unknown toolset type: {toolset_type}")
|
|
161
|
+
raise ValueError(f"Unknown toolset type: {toolset_type}")
|
|
159
162
|
|
|
160
|
-
def _create_plugin_toolset(self, name: str, config:
|
|
163
|
+
def _create_plugin_toolset(self, name: str, config: dict[str, Any]) -> AbstractToolset:
|
|
161
164
|
"""Create a plugin toolset from paths."""
|
|
162
165
|
from tactus.adapters.plugins import PluginLoader
|
|
163
166
|
|
|
@@ -169,7 +172,7 @@ class ToolsetPrimitive:
|
|
|
169
172
|
toolset = loader.create_toolset(paths, name=name)
|
|
170
173
|
return toolset
|
|
171
174
|
|
|
172
|
-
def _create_mcp_toolset_reference(self, name: str, config:
|
|
175
|
+
def _create_mcp_toolset_reference(self, name: str, config: dict[str, Any]) -> AbstractToolset:
|
|
173
176
|
"""Get reference to an MCP toolset."""
|
|
174
177
|
server_name = config.get("server")
|
|
175
178
|
if not server_name:
|
|
@@ -183,12 +186,12 @@ class ToolsetPrimitive:
|
|
|
183
186
|
# Get the toolset by server name
|
|
184
187
|
toolset = self.runtime.mcp_manager.get_toolset_by_name(server_name)
|
|
185
188
|
if toolset:
|
|
186
|
-
logger.info(
|
|
189
|
+
logger.info("Found MCP toolset for server '%s'", server_name)
|
|
187
190
|
return toolset
|
|
188
191
|
|
|
189
192
|
raise ValueError(f"MCP server toolset '{server_name}' not found")
|
|
190
193
|
|
|
191
|
-
def _create_combined_toolset(self, name: str, config:
|
|
194
|
+
def _create_combined_toolset(self, name: str, config: dict[str, Any]) -> CombinedToolset:
|
|
192
195
|
"""Create a combined toolset from sources."""
|
|
193
196
|
sources = config.get("sources", [])
|
|
194
197
|
if not sources:
|
|
@@ -197,12 +200,12 @@ class ToolsetPrimitive:
|
|
|
197
200
|
# Resolve each source toolset
|
|
198
201
|
resolved_toolsets = []
|
|
199
202
|
for source_name in sources:
|
|
200
|
-
|
|
201
|
-
resolved_toolsets.append(
|
|
203
|
+
resolved_toolset = self.get(source_name)
|
|
204
|
+
resolved_toolsets.append(resolved_toolset)
|
|
202
205
|
|
|
203
206
|
return CombinedToolset(resolved_toolsets)
|
|
204
207
|
|
|
205
|
-
def _create_filtered_toolset(self, name: str, config:
|
|
208
|
+
def _create_filtered_toolset(self, name: str, config: dict[str, Any]) -> FilteredToolset:
|
|
206
209
|
"""Create a filtered toolset."""
|
|
207
210
|
source = config.get("source")
|
|
208
211
|
filter_pattern = config.get("filter")
|
|
@@ -220,7 +223,7 @@ class ToolsetPrimitive:
|
|
|
220
223
|
|
|
221
224
|
pattern = re.compile(filter_pattern)
|
|
222
225
|
|
|
223
|
-
def filter_func(
|
|
226
|
+
def filter_func(_context, tool):
|
|
224
227
|
return pattern.match(tool.name) is not None
|
|
225
228
|
|
|
226
229
|
return source_toolset.filtered(filter_func)
|
tactus/sandbox/config.py
CHANGED
|
@@ -5,7 +5,7 @@ Defines the SandboxConfig Pydantic model for controlling container execution.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from pathlib import Path
|
|
8
|
-
from typing import
|
|
8
|
+
from typing import Optional
|
|
9
9
|
|
|
10
10
|
from pydantic import BaseModel, Field, model_validator
|
|
11
11
|
|
|
@@ -47,7 +47,7 @@ class SandboxConfig(BaseModel):
|
|
|
47
47
|
)
|
|
48
48
|
|
|
49
49
|
# Additional environment variables to pass to container
|
|
50
|
-
env:
|
|
50
|
+
env: dict[str, str] = Field(
|
|
51
51
|
default_factory=dict,
|
|
52
52
|
description="Additional environment variables to pass to the container",
|
|
53
53
|
)
|
|
@@ -59,7 +59,7 @@ class SandboxConfig(BaseModel):
|
|
|
59
59
|
)
|
|
60
60
|
|
|
61
61
|
# Additional volume mounts
|
|
62
|
-
volumes:
|
|
62
|
+
volumes: list[str] = Field(
|
|
63
63
|
default_factory=list,
|
|
64
64
|
description="Additional volume mounts in 'host:container:mode' format",
|
|
65
65
|
)
|
|
@@ -157,7 +157,7 @@ class SandboxConfig(BaseModel):
|
|
|
157
157
|
model_config = {"arbitrary_types_allowed": True}
|
|
158
158
|
|
|
159
159
|
@model_validator(mode="after")
|
|
160
|
-
def add_default_volumes(self):
|
|
160
|
+
def add_default_volumes(self) -> "SandboxConfig":
|
|
161
161
|
"""Add default volume mounts based on config flags."""
|
|
162
162
|
if self.mount_current_dir:
|
|
163
163
|
# Insert at beginning so user volumes can override
|