tactus 0.31.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 +49 -0
- tactus/adapters/__init__.py +9 -0
- tactus/adapters/broker_log.py +76 -0
- tactus/adapters/cli_hitl.py +189 -0
- tactus/adapters/cli_log.py +223 -0
- tactus/adapters/cost_collector_log.py +56 -0
- tactus/adapters/file_storage.py +367 -0
- tactus/adapters/http_callback_log.py +109 -0
- tactus/adapters/ide_log.py +71 -0
- tactus/adapters/lua_tools.py +336 -0
- tactus/adapters/mcp.py +289 -0
- tactus/adapters/mcp_manager.py +196 -0
- tactus/adapters/memory.py +53 -0
- tactus/adapters/plugins.py +419 -0
- tactus/backends/http_backend.py +58 -0
- tactus/backends/model_backend.py +35 -0
- tactus/backends/pytorch_backend.py +110 -0
- tactus/broker/__init__.py +12 -0
- tactus/broker/client.py +247 -0
- tactus/broker/protocol.py +183 -0
- tactus/broker/server.py +1123 -0
- tactus/broker/stdio.py +12 -0
- tactus/cli/__init__.py +7 -0
- tactus/cli/app.py +2245 -0
- tactus/cli/commands/__init__.py +0 -0
- tactus/core/__init__.py +32 -0
- tactus/core/config_manager.py +790 -0
- tactus/core/dependencies/__init__.py +14 -0
- tactus/core/dependencies/registry.py +180 -0
- tactus/core/dsl_stubs.py +2117 -0
- tactus/core/exceptions.py +66 -0
- tactus/core/execution_context.py +480 -0
- tactus/core/lua_sandbox.py +508 -0
- tactus/core/message_history_manager.py +236 -0
- tactus/core/mocking.py +286 -0
- tactus/core/output_validator.py +291 -0
- tactus/core/registry.py +499 -0
- tactus/core/runtime.py +2907 -0
- tactus/core/template_resolver.py +142 -0
- tactus/core/yaml_parser.py +301 -0
- tactus/docker/Dockerfile +61 -0
- tactus/docker/entrypoint.sh +69 -0
- tactus/dspy/__init__.py +39 -0
- tactus/dspy/agent.py +1144 -0
- tactus/dspy/broker_lm.py +181 -0
- tactus/dspy/config.py +212 -0
- tactus/dspy/history.py +196 -0
- tactus/dspy/module.py +405 -0
- tactus/dspy/prediction.py +318 -0
- tactus/dspy/signature.py +185 -0
- tactus/formatting/__init__.py +7 -0
- tactus/formatting/formatter.py +437 -0
- tactus/ide/__init__.py +9 -0
- tactus/ide/coding_assistant.py +343 -0
- tactus/ide/server.py +2223 -0
- tactus/primitives/__init__.py +49 -0
- tactus/primitives/control.py +168 -0
- tactus/primitives/file.py +229 -0
- tactus/primitives/handles.py +378 -0
- tactus/primitives/host.py +94 -0
- tactus/primitives/human.py +342 -0
- tactus/primitives/json.py +189 -0
- tactus/primitives/log.py +187 -0
- tactus/primitives/message_history.py +157 -0
- tactus/primitives/model.py +163 -0
- tactus/primitives/procedure.py +564 -0
- tactus/primitives/procedure_callable.py +318 -0
- tactus/primitives/retry.py +155 -0
- tactus/primitives/session.py +152 -0
- tactus/primitives/state.py +182 -0
- tactus/primitives/step.py +209 -0
- tactus/primitives/system.py +93 -0
- tactus/primitives/tool.py +375 -0
- tactus/primitives/tool_handle.py +279 -0
- tactus/primitives/toolset.py +229 -0
- tactus/protocols/__init__.py +38 -0
- tactus/protocols/chat_recorder.py +81 -0
- tactus/protocols/config.py +97 -0
- tactus/protocols/cost.py +31 -0
- tactus/protocols/hitl.py +71 -0
- tactus/protocols/log_handler.py +27 -0
- tactus/protocols/models.py +355 -0
- tactus/protocols/result.py +33 -0
- tactus/protocols/storage.py +90 -0
- tactus/providers/__init__.py +13 -0
- tactus/providers/base.py +92 -0
- tactus/providers/bedrock.py +117 -0
- tactus/providers/google.py +105 -0
- tactus/providers/openai.py +98 -0
- tactus/sandbox/__init__.py +63 -0
- tactus/sandbox/config.py +171 -0
- tactus/sandbox/container_runner.py +1099 -0
- tactus/sandbox/docker_manager.py +433 -0
- tactus/sandbox/entrypoint.py +227 -0
- tactus/sandbox/protocol.py +213 -0
- tactus/stdlib/__init__.py +10 -0
- tactus/stdlib/io/__init__.py +13 -0
- tactus/stdlib/io/csv.py +88 -0
- tactus/stdlib/io/excel.py +136 -0
- tactus/stdlib/io/file.py +90 -0
- tactus/stdlib/io/fs.py +154 -0
- tactus/stdlib/io/hdf5.py +121 -0
- tactus/stdlib/io/json.py +109 -0
- tactus/stdlib/io/parquet.py +83 -0
- tactus/stdlib/io/tsv.py +88 -0
- tactus/stdlib/loader.py +274 -0
- tactus/stdlib/tac/tactus/tools/done.tac +33 -0
- tactus/stdlib/tac/tactus/tools/log.tac +50 -0
- tactus/testing/README.md +273 -0
- tactus/testing/__init__.py +61 -0
- tactus/testing/behave_integration.py +380 -0
- tactus/testing/context.py +486 -0
- tactus/testing/eval_models.py +114 -0
- tactus/testing/evaluation_runner.py +222 -0
- tactus/testing/evaluators.py +634 -0
- tactus/testing/events.py +94 -0
- tactus/testing/gherkin_parser.py +134 -0
- tactus/testing/mock_agent.py +315 -0
- tactus/testing/mock_dependencies.py +234 -0
- tactus/testing/mock_hitl.py +171 -0
- tactus/testing/mock_registry.py +168 -0
- tactus/testing/mock_tools.py +133 -0
- tactus/testing/models.py +115 -0
- tactus/testing/pydantic_eval_runner.py +508 -0
- tactus/testing/steps/__init__.py +13 -0
- tactus/testing/steps/builtin.py +902 -0
- tactus/testing/steps/custom.py +69 -0
- tactus/testing/steps/registry.py +68 -0
- tactus/testing/test_runner.py +489 -0
- tactus/tracing/__init__.py +5 -0
- tactus/tracing/trace_manager.py +417 -0
- tactus/utils/__init__.py +1 -0
- tactus/utils/cost_calculator.py +72 -0
- tactus/utils/model_pricing.py +132 -0
- tactus/utils/safe_file_library.py +502 -0
- tactus/utils/safe_libraries.py +234 -0
- tactus/validation/LuaLexerBase.py +66 -0
- tactus/validation/LuaParserBase.py +23 -0
- tactus/validation/README.md +224 -0
- tactus/validation/__init__.py +7 -0
- tactus/validation/error_listener.py +21 -0
- tactus/validation/generated/LuaLexer.interp +231 -0
- tactus/validation/generated/LuaLexer.py +5548 -0
- tactus/validation/generated/LuaLexer.tokens +124 -0
- tactus/validation/generated/LuaLexerBase.py +66 -0
- tactus/validation/generated/LuaParser.interp +173 -0
- tactus/validation/generated/LuaParser.py +6439 -0
- tactus/validation/generated/LuaParser.tokens +124 -0
- tactus/validation/generated/LuaParserBase.py +23 -0
- tactus/validation/generated/LuaParserVisitor.py +118 -0
- tactus/validation/generated/__init__.py +7 -0
- tactus/validation/grammar/LuaLexer.g4 +123 -0
- tactus/validation/grammar/LuaParser.g4 +178 -0
- tactus/validation/semantic_visitor.py +817 -0
- tactus/validation/validator.py +157 -0
- tactus-0.31.0.dist-info/METADATA +1809 -0
- tactus-0.31.0.dist-info/RECORD +160 -0
- tactus-0.31.0.dist-info/WHEEL +4 -0
- tactus-0.31.0.dist-info/entry_points.txt +2 -0
- tactus-0.31.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tool Primitive - Tool call tracking, result access, and direct invocation.
|
|
3
|
+
|
|
4
|
+
Provides:
|
|
5
|
+
- Tool.called(name) - Check if tool was called
|
|
6
|
+
- Tool.last_result(name) - Get last result from named tool
|
|
7
|
+
- Tool.last_call(name) - Get full call info
|
|
8
|
+
- Tool.get(name) - Get a callable handle to an external tool (MCP, plugin)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Any, Optional, Dict, List, TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from tactus.primitives.tool_handle import ToolHandle
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ToolCall:
|
|
21
|
+
"""Represents a single tool call with arguments and result."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, name: str, args: Dict[str, Any], result: Any):
|
|
24
|
+
self.name = name
|
|
25
|
+
self.args = args
|
|
26
|
+
self.result = result
|
|
27
|
+
self.timestamp = None # Could add timestamp tracking
|
|
28
|
+
|
|
29
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
30
|
+
"""Convert to dictionary for Lua access."""
|
|
31
|
+
return {"name": self.name, "args": self.args, "result": self.result}
|
|
32
|
+
|
|
33
|
+
def __repr__(self) -> str:
|
|
34
|
+
return f"ToolCall({self.name}, args={self.args})"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ToolPrimitive:
|
|
38
|
+
"""
|
|
39
|
+
Tracks tool calls and provides access to results.
|
|
40
|
+
|
|
41
|
+
Maintains a history of tool calls and their results, allowing
|
|
42
|
+
Lua code to check what tools were used and access their outputs.
|
|
43
|
+
|
|
44
|
+
Also supports tool lookup via __call__:
|
|
45
|
+
Tool("done")({args}) -- Look up and call tool
|
|
46
|
+
Tool.called("done") -- Check if tool was called (existing)
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self, log_handler=None, agent_name: Optional[str] = None, procedure_id: Optional[str] = None
|
|
51
|
+
):
|
|
52
|
+
"""Initialize tool tracking."""
|
|
53
|
+
self._tool_calls: List[ToolCall] = []
|
|
54
|
+
self._last_calls: Dict[str, ToolCall] = {} # name -> last call
|
|
55
|
+
self.log_handler = log_handler
|
|
56
|
+
self.agent_name = agent_name
|
|
57
|
+
self.procedure_id = procedure_id
|
|
58
|
+
self._runtime = None # Will be set by runtime for Tool.get() support
|
|
59
|
+
self._tool_registry: Dict[str, "ToolHandle"] = {} # For Tool("name") lookup
|
|
60
|
+
logger.debug("ToolPrimitive initialized")
|
|
61
|
+
|
|
62
|
+
def set_tool_registry(self, registry: Dict[str, "ToolHandle"]) -> None:
|
|
63
|
+
"""
|
|
64
|
+
Set the tool registry for Tool("name") lookup.
|
|
65
|
+
|
|
66
|
+
Called by dsl_stubs.create_dsl_stubs() after tools are registered.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
registry: Dict mapping tool names to ToolHandle instances
|
|
70
|
+
"""
|
|
71
|
+
self._tool_registry = registry
|
|
72
|
+
logger.debug(f"ToolPrimitive tool registry set with {len(registry)} tools")
|
|
73
|
+
|
|
74
|
+
def __call__(self, tool_name: str) -> "ToolHandle":
|
|
75
|
+
"""
|
|
76
|
+
Look up a tool by name for direct invocation.
|
|
77
|
+
|
|
78
|
+
This makes Tool dual-purpose:
|
|
79
|
+
- Tool("done")({args}) -- lookup and call
|
|
80
|
+
- Tool.called("done") -- tracking (existing method)
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
tool_name: Name of the tool to look up
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
ToolHandle that can be called directly
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
ValueError: If tool not found in registry
|
|
90
|
+
|
|
91
|
+
Example (Lua):
|
|
92
|
+
Tool("done")({reason = "finished"})
|
|
93
|
+
"""
|
|
94
|
+
if tool_name not in self._tool_registry:
|
|
95
|
+
available = list(self._tool_registry.keys())
|
|
96
|
+
raise ValueError(f"Tool '{tool_name}' not defined. Available tools: {available}")
|
|
97
|
+
return self._tool_registry[tool_name]
|
|
98
|
+
|
|
99
|
+
def set_runtime(self, runtime) -> None:
|
|
100
|
+
"""
|
|
101
|
+
Set runtime reference for toolset lookup.
|
|
102
|
+
|
|
103
|
+
Called by the runtime after initialization to enable Tool.get().
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
runtime: TactusRuntime instance with toolset_registry
|
|
107
|
+
"""
|
|
108
|
+
self._runtime = runtime
|
|
109
|
+
logger.debug("ToolPrimitive connected to runtime for toolset lookup")
|
|
110
|
+
|
|
111
|
+
def get(self, tool_name: str) -> "ToolHandle":
|
|
112
|
+
"""
|
|
113
|
+
Get a callable handle to an external tool (MCP, plugin).
|
|
114
|
+
|
|
115
|
+
For Lua-defined tools, use the return value of tool() instead.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
tool_name: Name of the external tool to retrieve
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
ToolHandle that can be called directly
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
ValueError: If tool is not found in registry
|
|
125
|
+
|
|
126
|
+
Example (Lua):
|
|
127
|
+
local search = Tool.get("web_search") -- MCP tool
|
|
128
|
+
local result = search({query = "weather"})
|
|
129
|
+
|
|
130
|
+
local analyze = Tool.get("analyze_sentiment") -- Plugin tool
|
|
131
|
+
local sentiment = analyze({text = "I love this!"})
|
|
132
|
+
"""
|
|
133
|
+
from tactus.primitives.tool_handle import ToolHandle
|
|
134
|
+
|
|
135
|
+
logger.debug(f"Tool.get('{tool_name}') called")
|
|
136
|
+
|
|
137
|
+
# Look up toolset from runtime registry
|
|
138
|
+
toolset = self._get_toolset(tool_name)
|
|
139
|
+
if toolset is None:
|
|
140
|
+
raise ValueError(
|
|
141
|
+
f"Tool '{tool_name}' not found. "
|
|
142
|
+
f"Make sure it's registered as an MCP tool, plugin, or in a toolset."
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Extract the callable function from the toolset
|
|
146
|
+
tool_fn = self._extract_tool_function(toolset, tool_name)
|
|
147
|
+
|
|
148
|
+
logger.debug(f"Tool.get('{tool_name}') returning ToolHandle")
|
|
149
|
+
return ToolHandle(tool_name, tool_fn, self)
|
|
150
|
+
|
|
151
|
+
def _get_toolset(self, name: str) -> Optional[Any]:
|
|
152
|
+
"""
|
|
153
|
+
Look up toolset from runtime registry.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
name: Toolset name
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
Toolset instance or None if not found
|
|
160
|
+
"""
|
|
161
|
+
if self._runtime is None:
|
|
162
|
+
logger.warning("Tool.get() called but runtime not connected")
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
if not hasattr(self._runtime, "toolset_registry"):
|
|
166
|
+
logger.warning("Runtime does not have toolset_registry")
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
return self._runtime.toolset_registry.get(name)
|
|
170
|
+
|
|
171
|
+
def _extract_tool_function(self, toolset: Any, tool_name: str) -> Any:
|
|
172
|
+
"""
|
|
173
|
+
Extract callable function from a pydantic-ai toolset.
|
|
174
|
+
|
|
175
|
+
Handles different toolset types (FunctionToolset, MCP, etc.)
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
toolset: Pydantic-ai toolset instance
|
|
179
|
+
tool_name: Name of the tool
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Callable function that executes the tool
|
|
183
|
+
"""
|
|
184
|
+
# Try to get the tool function from common toolset patterns
|
|
185
|
+
|
|
186
|
+
# Pattern 1: FunctionToolset with tools list
|
|
187
|
+
if hasattr(toolset, "tools"):
|
|
188
|
+
tools = toolset.tools
|
|
189
|
+
if isinstance(tools, list):
|
|
190
|
+
for tool in tools:
|
|
191
|
+
if hasattr(tool, "name") and tool.name == tool_name:
|
|
192
|
+
# Return the tool's function
|
|
193
|
+
if hasattr(tool, "function"):
|
|
194
|
+
return tool.function
|
|
195
|
+
elif callable(tool):
|
|
196
|
+
return tool
|
|
197
|
+
# If tools is a dict
|
|
198
|
+
elif isinstance(tools, dict) and tool_name in tools:
|
|
199
|
+
tool = tools[tool_name]
|
|
200
|
+
if hasattr(tool, "function"):
|
|
201
|
+
return tool.function
|
|
202
|
+
elif callable(tool):
|
|
203
|
+
return tool
|
|
204
|
+
|
|
205
|
+
# Pattern 2: Toolset with a single tool (like individual Lua tools)
|
|
206
|
+
if hasattr(toolset, "function"):
|
|
207
|
+
return toolset.function
|
|
208
|
+
|
|
209
|
+
# Pattern 3: Toolset that is itself callable
|
|
210
|
+
if callable(toolset):
|
|
211
|
+
return toolset
|
|
212
|
+
|
|
213
|
+
# Pattern 4: MCP toolset - these wrap MCP tools
|
|
214
|
+
if hasattr(toolset, "_tools") or hasattr(toolset, "get_tool"):
|
|
215
|
+
# Try to get the specific tool
|
|
216
|
+
if hasattr(toolset, "get_tool"):
|
|
217
|
+
tool = toolset.get_tool(tool_name)
|
|
218
|
+
if tool:
|
|
219
|
+
return tool
|
|
220
|
+
|
|
221
|
+
# Return a wrapper that calls through the toolset
|
|
222
|
+
def mcp_wrapper(args):
|
|
223
|
+
return toolset.call_tool(tool_name, args)
|
|
224
|
+
|
|
225
|
+
return mcp_wrapper
|
|
226
|
+
|
|
227
|
+
# Fallback: assume the toolset itself contains tool functions
|
|
228
|
+
logger.warning(
|
|
229
|
+
f"Could not extract tool function for '{tool_name}' from toolset type {type(toolset)}"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Return a wrapper that attempts to call through the toolset
|
|
233
|
+
def fallback_wrapper(args):
|
|
234
|
+
if hasattr(toolset, "call"):
|
|
235
|
+
return toolset.call(tool_name, args)
|
|
236
|
+
raise RuntimeError(f"Cannot call tool '{tool_name}' - toolset type not supported")
|
|
237
|
+
|
|
238
|
+
return fallback_wrapper
|
|
239
|
+
|
|
240
|
+
def called(self, tool_name: str) -> bool:
|
|
241
|
+
"""
|
|
242
|
+
Check if a tool was called at least once.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
tool_name: Name of the tool to check
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
True if the tool was called
|
|
249
|
+
|
|
250
|
+
Example (Lua):
|
|
251
|
+
if Tool.called("done") then
|
|
252
|
+
Log.info("Done tool was called")
|
|
253
|
+
end
|
|
254
|
+
"""
|
|
255
|
+
called = tool_name in self._last_calls
|
|
256
|
+
logger.debug(f"Tool.called('{tool_name}') = {called}")
|
|
257
|
+
return called
|
|
258
|
+
|
|
259
|
+
def last_result(self, tool_name: str) -> Any:
|
|
260
|
+
"""
|
|
261
|
+
Get the last result from a named tool.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
tool_name: Name of the tool
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
Last result from the tool, or None if never called
|
|
268
|
+
|
|
269
|
+
Example (Lua):
|
|
270
|
+
local result = Tool.last_result("search")
|
|
271
|
+
if result then
|
|
272
|
+
Log.info("Search found: " .. result)
|
|
273
|
+
end
|
|
274
|
+
"""
|
|
275
|
+
if tool_name not in self._last_calls:
|
|
276
|
+
logger.debug(f"Tool.last_result('{tool_name}') = None (never called)")
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
result = self._last_calls[tool_name].result
|
|
280
|
+
logger.debug(f"Tool.last_result('{tool_name}') = {result}")
|
|
281
|
+
return result
|
|
282
|
+
|
|
283
|
+
def last_call(self, tool_name: str) -> Optional[Dict[str, Any]]:
|
|
284
|
+
"""
|
|
285
|
+
Get full information about the last call to a tool.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
tool_name: Name of the tool
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
Dictionary with 'name', 'args', 'result' or None if never called
|
|
292
|
+
|
|
293
|
+
Example (Lua):
|
|
294
|
+
local call = Tool.last_call("search")
|
|
295
|
+
if call then
|
|
296
|
+
Log.info("Search was called with: " .. call.args.query)
|
|
297
|
+
Log.info("Result: " .. call.result)
|
|
298
|
+
end
|
|
299
|
+
"""
|
|
300
|
+
if tool_name not in self._last_calls:
|
|
301
|
+
logger.debug(f"Tool.last_call('{tool_name}') = None (never called)")
|
|
302
|
+
return None
|
|
303
|
+
|
|
304
|
+
call_dict = self._last_calls[tool_name].to_dict()
|
|
305
|
+
logger.debug(f"Tool.last_call('{tool_name}') = {call_dict}")
|
|
306
|
+
return call_dict
|
|
307
|
+
|
|
308
|
+
def record_call(
|
|
309
|
+
self, tool_name: str, args: Dict[str, Any], result: Any, agent_name: Optional[str] = None
|
|
310
|
+
) -> None:
|
|
311
|
+
"""
|
|
312
|
+
Record a tool call (called by runtime after tool execution).
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
tool_name: Name of the tool
|
|
316
|
+
args: Arguments passed to the tool
|
|
317
|
+
result: Result returned by the tool
|
|
318
|
+
agent_name: Optional name of agent that called the tool
|
|
319
|
+
|
|
320
|
+
Note: This is called internally by the runtime, not from Lua
|
|
321
|
+
"""
|
|
322
|
+
call = ToolCall(tool_name, args, result)
|
|
323
|
+
self._tool_calls.append(call)
|
|
324
|
+
self._last_calls[tool_name] = call
|
|
325
|
+
|
|
326
|
+
logger.debug(f"Tool call recorded: {tool_name} -> {len(self._tool_calls)} total calls")
|
|
327
|
+
|
|
328
|
+
# Emit ToolCallEvent if we have a log handler
|
|
329
|
+
if self.log_handler:
|
|
330
|
+
try:
|
|
331
|
+
from tactus.protocols.models import ToolCallEvent
|
|
332
|
+
|
|
333
|
+
event = ToolCallEvent(
|
|
334
|
+
agent_name=agent_name or self.agent_name or "unknown",
|
|
335
|
+
tool_name=tool_name,
|
|
336
|
+
tool_args=args,
|
|
337
|
+
tool_result=result,
|
|
338
|
+
procedure_id=self.procedure_id,
|
|
339
|
+
)
|
|
340
|
+
self.log_handler.log(event)
|
|
341
|
+
except Exception as e:
|
|
342
|
+
logger.warning(f"Failed to log tool call event: {e}")
|
|
343
|
+
|
|
344
|
+
def get_all_calls(self) -> List[ToolCall]:
|
|
345
|
+
"""
|
|
346
|
+
Get all tool calls (for debugging/logging).
|
|
347
|
+
|
|
348
|
+
Returns:
|
|
349
|
+
List of all ToolCall objects
|
|
350
|
+
"""
|
|
351
|
+
return self._tool_calls.copy()
|
|
352
|
+
|
|
353
|
+
def get_call_count(self, tool_name: Optional[str] = None) -> int:
|
|
354
|
+
"""
|
|
355
|
+
Get the number of times a tool was called.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
tool_name: Name of tool (or None for total count)
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
Number of calls
|
|
362
|
+
"""
|
|
363
|
+
if tool_name is None:
|
|
364
|
+
return len(self._tool_calls)
|
|
365
|
+
|
|
366
|
+
return sum(1 for call in self._tool_calls if call.name == tool_name)
|
|
367
|
+
|
|
368
|
+
def reset(self) -> None:
|
|
369
|
+
"""Reset tool tracking (mainly for testing)."""
|
|
370
|
+
self._tool_calls.clear()
|
|
371
|
+
self._last_calls.clear()
|
|
372
|
+
logger.debug("Tool tracking reset")
|
|
373
|
+
|
|
374
|
+
def __repr__(self) -> str:
|
|
375
|
+
return f"ToolPrimitive({len(self._tool_calls)} calls)"
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tool Handle - Callable wrapper for direct tool invocation.
|
|
3
|
+
|
|
4
|
+
Provides OOP-style tool access where tool() returns a callable handle
|
|
5
|
+
that can be invoked directly without going through an agent.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Any, Callable, Dict, Optional, TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from tactus.primitives.tool import ToolPrimitive
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ToolHandle:
|
|
19
|
+
"""
|
|
20
|
+
Callable wrapper around a tool for direct invocation.
|
|
21
|
+
|
|
22
|
+
Returned by tool() for Lua-defined tools and Tool.get() for external tools.
|
|
23
|
+
Can be called directly from Lua: result = handle({args})
|
|
24
|
+
|
|
25
|
+
Example (Lua):
|
|
26
|
+
local calculate_tip = tool({...}, function(args) ... end)
|
|
27
|
+
local result = calculate_tip({bill_amount = 50, tip_percentage = 20})
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
name: str,
|
|
33
|
+
impl_fn: Callable,
|
|
34
|
+
tool_primitive: Optional["ToolPrimitive"] = None,
|
|
35
|
+
is_async: bool = False,
|
|
36
|
+
record_calls: bool = True,
|
|
37
|
+
):
|
|
38
|
+
"""
|
|
39
|
+
Initialize a tool handle.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
name: Tool name for tracking/logging
|
|
43
|
+
impl_fn: The actual function to execute
|
|
44
|
+
tool_primitive: Optional ToolPrimitive for call recording
|
|
45
|
+
is_async: Whether impl_fn is async (for MCP tools)
|
|
46
|
+
"""
|
|
47
|
+
self.name = name
|
|
48
|
+
self.impl_fn = impl_fn
|
|
49
|
+
self.tool_primitive = tool_primitive
|
|
50
|
+
self.is_async = is_async
|
|
51
|
+
self.record_calls = record_calls
|
|
52
|
+
|
|
53
|
+
logger.debug(f"ToolHandle created for '{name}' (async={is_async})")
|
|
54
|
+
|
|
55
|
+
def call(self, args: Dict[str, Any]) -> Any:
|
|
56
|
+
"""
|
|
57
|
+
Execute the tool with given arguments.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
args: Dictionary of arguments to pass to the tool
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Tool result
|
|
64
|
+
|
|
65
|
+
Example (Lua):
|
|
66
|
+
local result = my_tool:call({arg1 = "value"})
|
|
67
|
+
"""
|
|
68
|
+
logger.debug(f"ToolHandle.call('{self.name}') with args: {args}")
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
# Convert Lua table to Python dict if needed
|
|
72
|
+
if hasattr(args, "items"):
|
|
73
|
+
args = self._lua_table_to_dict(args)
|
|
74
|
+
|
|
75
|
+
# Execute the implementation
|
|
76
|
+
if self.is_async or asyncio.iscoroutinefunction(self.impl_fn):
|
|
77
|
+
result = self._run_async(args)
|
|
78
|
+
else:
|
|
79
|
+
result = self.impl_fn(args)
|
|
80
|
+
|
|
81
|
+
# Record the call for tracking
|
|
82
|
+
if self.tool_primitive and self.record_calls:
|
|
83
|
+
self.tool_primitive.record_call(self.name, args, result)
|
|
84
|
+
|
|
85
|
+
logger.debug(f"ToolHandle.call('{self.name}') returned: {result}")
|
|
86
|
+
return result
|
|
87
|
+
|
|
88
|
+
except Exception as e:
|
|
89
|
+
logger.error(f"ToolHandle.call('{self.name}') failed: {e}", exc_info=True)
|
|
90
|
+
raise
|
|
91
|
+
|
|
92
|
+
def __call__(self, args: Dict[str, Any]) -> Any:
|
|
93
|
+
"""
|
|
94
|
+
Make handle callable for shorthand syntax.
|
|
95
|
+
|
|
96
|
+
Example (Lua):
|
|
97
|
+
local result = my_tool({arg1 = "value"})
|
|
98
|
+
"""
|
|
99
|
+
return self.call(args)
|
|
100
|
+
|
|
101
|
+
def called(self) -> bool:
|
|
102
|
+
"""
|
|
103
|
+
Check if this tool has been called at least once.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
True if tool was called, False otherwise
|
|
107
|
+
|
|
108
|
+
Example (Lua):
|
|
109
|
+
if done.called() then
|
|
110
|
+
Log.info("Task completed!")
|
|
111
|
+
end
|
|
112
|
+
"""
|
|
113
|
+
if not self.tool_primitive:
|
|
114
|
+
logger.warning(f"ToolHandle.called('{self.name}'): No tool_primitive attached")
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
result = self.tool_primitive.called(self.name)
|
|
118
|
+
logger.debug(f"ToolHandle.called('{self.name}') = {result}")
|
|
119
|
+
return result
|
|
120
|
+
|
|
121
|
+
def last_call(self) -> Optional[Dict[str, Any]]:
|
|
122
|
+
"""
|
|
123
|
+
Get the last call record for this tool.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
Dictionary with 'name', 'args', 'result' or None if never called
|
|
127
|
+
|
|
128
|
+
Example (Lua):
|
|
129
|
+
local call = multiply.last_call()
|
|
130
|
+
if call then
|
|
131
|
+
Log.info("Last multiply: " .. call.args.a .. " * " .. call.args.b)
|
|
132
|
+
end
|
|
133
|
+
"""
|
|
134
|
+
if not self.tool_primitive:
|
|
135
|
+
logger.warning(f"ToolHandle.last_call('{self.name}'): No tool_primitive attached")
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
result = self.tool_primitive.last_call(self.name)
|
|
139
|
+
logger.debug(f"ToolHandle.last_call('{self.name}') = {result}")
|
|
140
|
+
return result
|
|
141
|
+
|
|
142
|
+
def last_result(self) -> Any:
|
|
143
|
+
"""
|
|
144
|
+
Get the result from the last call to this tool.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Result value from last call, or None if never called
|
|
148
|
+
|
|
149
|
+
Example (Lua):
|
|
150
|
+
local answer = done.last_result()
|
|
151
|
+
return { result = answer }
|
|
152
|
+
"""
|
|
153
|
+
if not self.tool_primitive:
|
|
154
|
+
logger.warning(f"ToolHandle.last_result('{self.name}'): No tool_primitive attached")
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
result = self.tool_primitive.last_result(self.name)
|
|
158
|
+
logger.debug(f"ToolHandle.last_result('{self.name}') = {result}")
|
|
159
|
+
return result
|
|
160
|
+
|
|
161
|
+
def call_count(self) -> int:
|
|
162
|
+
"""
|
|
163
|
+
Get the number of times this tool has been called.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
Number of calls (0 if never called)
|
|
167
|
+
|
|
168
|
+
Example (Lua):
|
|
169
|
+
local count = multiply.call_count()
|
|
170
|
+
Log.info("Multiply was called " .. count .. " times")
|
|
171
|
+
"""
|
|
172
|
+
if not self.tool_primitive:
|
|
173
|
+
logger.warning(f"ToolHandle.call_count('{self.name}'): No tool_primitive attached")
|
|
174
|
+
return 0
|
|
175
|
+
|
|
176
|
+
# Count all calls with this tool name
|
|
177
|
+
count = sum(1 for call in self.tool_primitive._tool_calls if call.name == self.name)
|
|
178
|
+
logger.debug(f"ToolHandle.call_count('{self.name}') = {count}")
|
|
179
|
+
return count
|
|
180
|
+
|
|
181
|
+
def reset(self) -> None:
|
|
182
|
+
"""
|
|
183
|
+
Clear all recorded calls for this tool.
|
|
184
|
+
|
|
185
|
+
This is useful when reusing the same tool handle in multiple sequential
|
|
186
|
+
operations within a single procedure, allowing called() checks to work
|
|
187
|
+
independently for each operation.
|
|
188
|
+
|
|
189
|
+
Example (Lua):
|
|
190
|
+
-- First agent uses done
|
|
191
|
+
agent1()
|
|
192
|
+
if done.called() then
|
|
193
|
+
Log.info("Agent 1 completed")
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
-- Reset for second agent
|
|
197
|
+
done.reset()
|
|
198
|
+
|
|
199
|
+
-- Second agent uses done independently
|
|
200
|
+
agent2()
|
|
201
|
+
if done.called() then
|
|
202
|
+
Log.info("Agent 2 completed")
|
|
203
|
+
end
|
|
204
|
+
"""
|
|
205
|
+
if not self.tool_primitive:
|
|
206
|
+
logger.warning(f"ToolHandle.reset('{self.name}'): No tool_primitive attached")
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
# Remove all calls for this tool
|
|
210
|
+
self.tool_primitive._tool_calls = [
|
|
211
|
+
call for call in self.tool_primitive._tool_calls if call.name != self.name
|
|
212
|
+
]
|
|
213
|
+
logger.debug(f"ToolHandle.reset('{self.name}'): Cleared all call records")
|
|
214
|
+
|
|
215
|
+
def _run_async(self, args: Dict[str, Any]) -> Any:
|
|
216
|
+
"""
|
|
217
|
+
Run async function from sync context.
|
|
218
|
+
|
|
219
|
+
Handles the complexity of running async code from Lua's sync context.
|
|
220
|
+
"""
|
|
221
|
+
try:
|
|
222
|
+
# Try to get a running event loop
|
|
223
|
+
loop = asyncio.get_running_loop()
|
|
224
|
+
|
|
225
|
+
# We're in an async context - use nest_asyncio if available
|
|
226
|
+
try:
|
|
227
|
+
import nest_asyncio
|
|
228
|
+
|
|
229
|
+
nest_asyncio.apply(loop)
|
|
230
|
+
return asyncio.run(self.impl_fn(args))
|
|
231
|
+
except ImportError:
|
|
232
|
+
# nest_asyncio not available, fall back to threading
|
|
233
|
+
import threading
|
|
234
|
+
|
|
235
|
+
result_container = {"value": None, "exception": None}
|
|
236
|
+
|
|
237
|
+
def run_in_thread():
|
|
238
|
+
try:
|
|
239
|
+
new_loop = asyncio.new_event_loop()
|
|
240
|
+
asyncio.set_event_loop(new_loop)
|
|
241
|
+
try:
|
|
242
|
+
result_container["value"] = new_loop.run_until_complete(
|
|
243
|
+
self.impl_fn(args)
|
|
244
|
+
)
|
|
245
|
+
finally:
|
|
246
|
+
new_loop.close()
|
|
247
|
+
except Exception as e:
|
|
248
|
+
result_container["exception"] = e
|
|
249
|
+
|
|
250
|
+
thread = threading.Thread(target=run_in_thread)
|
|
251
|
+
thread.start()
|
|
252
|
+
thread.join()
|
|
253
|
+
|
|
254
|
+
if result_container["exception"]:
|
|
255
|
+
raise result_container["exception"]
|
|
256
|
+
return result_container["value"]
|
|
257
|
+
|
|
258
|
+
except RuntimeError:
|
|
259
|
+
# No event loop running - safe to use asyncio.run()
|
|
260
|
+
return asyncio.run(self.impl_fn(args))
|
|
261
|
+
|
|
262
|
+
def _lua_table_to_dict(self, lua_table) -> Dict[str, Any]:
|
|
263
|
+
"""Convert a Lua table to Python dict recursively."""
|
|
264
|
+
if lua_table is None:
|
|
265
|
+
return {}
|
|
266
|
+
|
|
267
|
+
if not hasattr(lua_table, "items"):
|
|
268
|
+
return lua_table
|
|
269
|
+
|
|
270
|
+
result = {}
|
|
271
|
+
for key, value in lua_table.items():
|
|
272
|
+
if hasattr(value, "items"):
|
|
273
|
+
result[key] = self._lua_table_to_dict(value)
|
|
274
|
+
else:
|
|
275
|
+
result[key] = value
|
|
276
|
+
return result
|
|
277
|
+
|
|
278
|
+
def __repr__(self) -> str:
|
|
279
|
+
return f"ToolHandle('{self.name}')"
|