tactus 0.31.2__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.
Files changed (160) hide show
  1. tactus/__init__.py +49 -0
  2. tactus/adapters/__init__.py +9 -0
  3. tactus/adapters/broker_log.py +76 -0
  4. tactus/adapters/cli_hitl.py +189 -0
  5. tactus/adapters/cli_log.py +223 -0
  6. tactus/adapters/cost_collector_log.py +56 -0
  7. tactus/adapters/file_storage.py +367 -0
  8. tactus/adapters/http_callback_log.py +109 -0
  9. tactus/adapters/ide_log.py +71 -0
  10. tactus/adapters/lua_tools.py +336 -0
  11. tactus/adapters/mcp.py +289 -0
  12. tactus/adapters/mcp_manager.py +196 -0
  13. tactus/adapters/memory.py +53 -0
  14. tactus/adapters/plugins.py +419 -0
  15. tactus/backends/http_backend.py +58 -0
  16. tactus/backends/model_backend.py +35 -0
  17. tactus/backends/pytorch_backend.py +110 -0
  18. tactus/broker/__init__.py +12 -0
  19. tactus/broker/client.py +247 -0
  20. tactus/broker/protocol.py +183 -0
  21. tactus/broker/server.py +1123 -0
  22. tactus/broker/stdio.py +12 -0
  23. tactus/cli/__init__.py +7 -0
  24. tactus/cli/app.py +2245 -0
  25. tactus/cli/commands/__init__.py +0 -0
  26. tactus/core/__init__.py +32 -0
  27. tactus/core/config_manager.py +790 -0
  28. tactus/core/dependencies/__init__.py +14 -0
  29. tactus/core/dependencies/registry.py +180 -0
  30. tactus/core/dsl_stubs.py +2117 -0
  31. tactus/core/exceptions.py +66 -0
  32. tactus/core/execution_context.py +480 -0
  33. tactus/core/lua_sandbox.py +508 -0
  34. tactus/core/message_history_manager.py +236 -0
  35. tactus/core/mocking.py +286 -0
  36. tactus/core/output_validator.py +291 -0
  37. tactus/core/registry.py +499 -0
  38. tactus/core/runtime.py +2907 -0
  39. tactus/core/template_resolver.py +142 -0
  40. tactus/core/yaml_parser.py +301 -0
  41. tactus/docker/Dockerfile +61 -0
  42. tactus/docker/entrypoint.sh +69 -0
  43. tactus/dspy/__init__.py +39 -0
  44. tactus/dspy/agent.py +1144 -0
  45. tactus/dspy/broker_lm.py +181 -0
  46. tactus/dspy/config.py +212 -0
  47. tactus/dspy/history.py +196 -0
  48. tactus/dspy/module.py +405 -0
  49. tactus/dspy/prediction.py +318 -0
  50. tactus/dspy/signature.py +185 -0
  51. tactus/formatting/__init__.py +7 -0
  52. tactus/formatting/formatter.py +437 -0
  53. tactus/ide/__init__.py +9 -0
  54. tactus/ide/coding_assistant.py +343 -0
  55. tactus/ide/server.py +2223 -0
  56. tactus/primitives/__init__.py +49 -0
  57. tactus/primitives/control.py +168 -0
  58. tactus/primitives/file.py +229 -0
  59. tactus/primitives/handles.py +378 -0
  60. tactus/primitives/host.py +94 -0
  61. tactus/primitives/human.py +342 -0
  62. tactus/primitives/json.py +189 -0
  63. tactus/primitives/log.py +187 -0
  64. tactus/primitives/message_history.py +157 -0
  65. tactus/primitives/model.py +163 -0
  66. tactus/primitives/procedure.py +564 -0
  67. tactus/primitives/procedure_callable.py +318 -0
  68. tactus/primitives/retry.py +155 -0
  69. tactus/primitives/session.py +152 -0
  70. tactus/primitives/state.py +182 -0
  71. tactus/primitives/step.py +209 -0
  72. tactus/primitives/system.py +93 -0
  73. tactus/primitives/tool.py +375 -0
  74. tactus/primitives/tool_handle.py +279 -0
  75. tactus/primitives/toolset.py +229 -0
  76. tactus/protocols/__init__.py +38 -0
  77. tactus/protocols/chat_recorder.py +81 -0
  78. tactus/protocols/config.py +97 -0
  79. tactus/protocols/cost.py +31 -0
  80. tactus/protocols/hitl.py +71 -0
  81. tactus/protocols/log_handler.py +27 -0
  82. tactus/protocols/models.py +355 -0
  83. tactus/protocols/result.py +33 -0
  84. tactus/protocols/storage.py +90 -0
  85. tactus/providers/__init__.py +13 -0
  86. tactus/providers/base.py +92 -0
  87. tactus/providers/bedrock.py +117 -0
  88. tactus/providers/google.py +105 -0
  89. tactus/providers/openai.py +98 -0
  90. tactus/sandbox/__init__.py +63 -0
  91. tactus/sandbox/config.py +171 -0
  92. tactus/sandbox/container_runner.py +1099 -0
  93. tactus/sandbox/docker_manager.py +433 -0
  94. tactus/sandbox/entrypoint.py +227 -0
  95. tactus/sandbox/protocol.py +213 -0
  96. tactus/stdlib/__init__.py +10 -0
  97. tactus/stdlib/io/__init__.py +13 -0
  98. tactus/stdlib/io/csv.py +88 -0
  99. tactus/stdlib/io/excel.py +136 -0
  100. tactus/stdlib/io/file.py +90 -0
  101. tactus/stdlib/io/fs.py +154 -0
  102. tactus/stdlib/io/hdf5.py +121 -0
  103. tactus/stdlib/io/json.py +109 -0
  104. tactus/stdlib/io/parquet.py +83 -0
  105. tactus/stdlib/io/tsv.py +88 -0
  106. tactus/stdlib/loader.py +274 -0
  107. tactus/stdlib/tac/tactus/tools/done.tac +33 -0
  108. tactus/stdlib/tac/tactus/tools/log.tac +50 -0
  109. tactus/testing/README.md +273 -0
  110. tactus/testing/__init__.py +61 -0
  111. tactus/testing/behave_integration.py +380 -0
  112. tactus/testing/context.py +486 -0
  113. tactus/testing/eval_models.py +114 -0
  114. tactus/testing/evaluation_runner.py +222 -0
  115. tactus/testing/evaluators.py +634 -0
  116. tactus/testing/events.py +94 -0
  117. tactus/testing/gherkin_parser.py +134 -0
  118. tactus/testing/mock_agent.py +315 -0
  119. tactus/testing/mock_dependencies.py +234 -0
  120. tactus/testing/mock_hitl.py +171 -0
  121. tactus/testing/mock_registry.py +168 -0
  122. tactus/testing/mock_tools.py +133 -0
  123. tactus/testing/models.py +115 -0
  124. tactus/testing/pydantic_eval_runner.py +508 -0
  125. tactus/testing/steps/__init__.py +13 -0
  126. tactus/testing/steps/builtin.py +902 -0
  127. tactus/testing/steps/custom.py +69 -0
  128. tactus/testing/steps/registry.py +68 -0
  129. tactus/testing/test_runner.py +489 -0
  130. tactus/tracing/__init__.py +5 -0
  131. tactus/tracing/trace_manager.py +417 -0
  132. tactus/utils/__init__.py +1 -0
  133. tactus/utils/cost_calculator.py +72 -0
  134. tactus/utils/model_pricing.py +132 -0
  135. tactus/utils/safe_file_library.py +502 -0
  136. tactus/utils/safe_libraries.py +234 -0
  137. tactus/validation/LuaLexerBase.py +66 -0
  138. tactus/validation/LuaParserBase.py +23 -0
  139. tactus/validation/README.md +224 -0
  140. tactus/validation/__init__.py +7 -0
  141. tactus/validation/error_listener.py +21 -0
  142. tactus/validation/generated/LuaLexer.interp +231 -0
  143. tactus/validation/generated/LuaLexer.py +5548 -0
  144. tactus/validation/generated/LuaLexer.tokens +124 -0
  145. tactus/validation/generated/LuaLexerBase.py +66 -0
  146. tactus/validation/generated/LuaParser.interp +173 -0
  147. tactus/validation/generated/LuaParser.py +6439 -0
  148. tactus/validation/generated/LuaParser.tokens +124 -0
  149. tactus/validation/generated/LuaParserBase.py +23 -0
  150. tactus/validation/generated/LuaParserVisitor.py +118 -0
  151. tactus/validation/generated/__init__.py +7 -0
  152. tactus/validation/grammar/LuaLexer.g4 +123 -0
  153. tactus/validation/grammar/LuaParser.g4 +178 -0
  154. tactus/validation/semantic_visitor.py +817 -0
  155. tactus/validation/validator.py +157 -0
  156. tactus-0.31.2.dist-info/METADATA +1809 -0
  157. tactus-0.31.2.dist-info/RECORD +160 -0
  158. tactus-0.31.2.dist-info/WHEEL +4 -0
  159. tactus-0.31.2.dist-info/entry_points.txt +2 -0
  160. tactus-0.31.2.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}')"