tactus 0.34.1__py3-none-any.whl → 0.35.1__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 (81) hide show
  1. tactus/__init__.py +1 -1
  2. tactus/adapters/broker_log.py +17 -14
  3. tactus/adapters/channels/__init__.py +17 -15
  4. tactus/adapters/channels/base.py +16 -7
  5. tactus/adapters/channels/broker.py +43 -13
  6. tactus/adapters/channels/cli.py +19 -15
  7. tactus/adapters/channels/host.py +40 -25
  8. tactus/adapters/channels/ipc.py +82 -31
  9. tactus/adapters/channels/sse.py +41 -23
  10. tactus/adapters/cli_hitl.py +19 -19
  11. tactus/adapters/cli_log.py +4 -4
  12. tactus/adapters/control_loop.py +138 -99
  13. tactus/adapters/cost_collector_log.py +9 -9
  14. tactus/adapters/file_storage.py +56 -52
  15. tactus/adapters/http_callback_log.py +23 -13
  16. tactus/adapters/ide_log.py +17 -9
  17. tactus/adapters/lua_tools.py +4 -5
  18. tactus/adapters/mcp.py +16 -19
  19. tactus/adapters/mcp_manager.py +46 -30
  20. tactus/adapters/memory.py +9 -9
  21. tactus/adapters/plugins.py +42 -42
  22. tactus/broker/client.py +75 -78
  23. tactus/broker/protocol.py +57 -57
  24. tactus/broker/server.py +252 -197
  25. tactus/cli/app.py +3 -1
  26. tactus/cli/control.py +2 -2
  27. tactus/core/config_manager.py +181 -135
  28. tactus/core/dependencies/registry.py +66 -48
  29. tactus/core/dsl_stubs.py +222 -163
  30. tactus/core/exceptions.py +10 -1
  31. tactus/core/execution_context.py +152 -112
  32. tactus/core/lua_sandbox.py +72 -64
  33. tactus/core/message_history_manager.py +138 -43
  34. tactus/core/mocking.py +41 -27
  35. tactus/core/output_validator.py +49 -44
  36. tactus/core/registry.py +94 -80
  37. tactus/core/runtime.py +211 -176
  38. tactus/core/template_resolver.py +16 -16
  39. tactus/core/yaml_parser.py +55 -45
  40. tactus/docs/extractor.py +7 -6
  41. tactus/ide/server.py +119 -78
  42. tactus/primitives/control.py +10 -6
  43. tactus/primitives/file.py +48 -46
  44. tactus/primitives/handles.py +47 -35
  45. tactus/primitives/host.py +29 -27
  46. tactus/primitives/human.py +154 -137
  47. tactus/primitives/json.py +22 -23
  48. tactus/primitives/log.py +26 -26
  49. tactus/primitives/message_history.py +285 -31
  50. tactus/primitives/model.py +15 -9
  51. tactus/primitives/procedure.py +86 -64
  52. tactus/primitives/procedure_callable.py +58 -51
  53. tactus/primitives/retry.py +31 -29
  54. tactus/primitives/session.py +42 -29
  55. tactus/primitives/state.py +54 -43
  56. tactus/primitives/step.py +9 -13
  57. tactus/primitives/system.py +34 -21
  58. tactus/primitives/tool.py +44 -31
  59. tactus/primitives/tool_handle.py +76 -54
  60. tactus/primitives/toolset.py +25 -22
  61. tactus/sandbox/config.py +4 -4
  62. tactus/sandbox/container_runner.py +161 -107
  63. tactus/sandbox/docker_manager.py +20 -20
  64. tactus/sandbox/entrypoint.py +16 -14
  65. tactus/sandbox/protocol.py +15 -15
  66. tactus/stdlib/classify/llm.py +1 -3
  67. tactus/stdlib/core/validation.py +0 -3
  68. tactus/testing/pydantic_eval_runner.py +1 -1
  69. tactus/utils/asyncio_helpers.py +27 -0
  70. tactus/utils/cost_calculator.py +7 -7
  71. tactus/utils/model_pricing.py +11 -12
  72. tactus/utils/safe_file_library.py +156 -132
  73. tactus/utils/safe_libraries.py +27 -27
  74. tactus/validation/error_listener.py +18 -5
  75. tactus/validation/semantic_visitor.py +392 -333
  76. tactus/validation/validator.py +89 -49
  77. {tactus-0.34.1.dist-info → tactus-0.35.1.dist-info}/METADATA +15 -3
  78. {tactus-0.34.1.dist-info → tactus-0.35.1.dist-info}/RECORD +81 -80
  79. {tactus-0.34.1.dist-info → tactus-0.35.1.dist-info}/WHEEL +0 -0
  80. {tactus-0.34.1.dist-info → tactus-0.35.1.dist-info}/entry_points.txt +0 -0
  81. {tactus-0.34.1.dist-info → tactus-0.35.1.dist-info}/licenses/LICENSE +0 -0
tactus/primitives/file.py CHANGED
@@ -39,29 +39,31 @@ class FilePrimitive:
39
39
  """
40
40
  self.base_path = Path(base_path) if base_path else Path.cwd()
41
41
  self.execution_context = execution_context
42
- logger.debug(f"FilePrimitive initialized with base_path: {self.base_path}")
42
+ logger.debug("FilePrimitive initialized with base_path: %s", self.base_path)
43
43
 
44
- def _check_determinism(self, operation: str):
44
+ def _check_determinism(self, operation: str) -> None:
45
45
  """Warn if file operation called outside checkpoint."""
46
46
  if self.execution_context and not getattr(
47
47
  self.execution_context, "_inside_checkpoint", False
48
48
  ):
49
49
  import warnings
50
50
 
51
+ warning_banner = "=" * 70
51
52
  warnings.warn(
52
- f"\n{'=' * 70}\n"
53
+ "\n"
54
+ f"{warning_banner}\n"
53
55
  f"DETERMINISM WARNING: File.{operation}() called outside checkpoint\n"
54
- f"{'=' * 70}\n\n"
55
- f"File operations are non-deterministic - "
56
- f"file contents can change between executions.\n\n"
57
- f"To fix, wrap in Step.checkpoint():\n\n"
58
- f" state.data = Step.checkpoint(function()\n"
56
+ f"{warning_banner}\n\n"
57
+ "File operations are non-deterministic - "
58
+ "file contents can change between executions.\n\n"
59
+ "To fix, wrap in Step.checkpoint():\n\n"
60
+ " state.data = Step.checkpoint(function()\n"
59
61
  f" return File.{operation}(...)\n"
60
- f" end)\n\n"
61
- f"Why: Files can be modified, deleted, or created "
62
- f"between procedure executions,\n"
63
- f"causing different behavior on replay.\n"
64
- f"\n{'=' * 70}\n",
62
+ " end)\n\n"
63
+ "Why: Files can be modified, deleted, or created "
64
+ "between procedure executions,\n"
65
+ "causing different behavior on replay.\n"
66
+ f"\n{warning_banner}\n",
65
67
  UserWarning,
66
68
  stacklevel=3,
67
69
  )
@@ -88,21 +90,21 @@ class FilePrimitive:
88
90
  file_path = self._resolve_path(path)
89
91
 
90
92
  try:
91
- logger.debug(f"Reading file: {file_path}")
92
- with open(file_path, "r", encoding="utf-8") as f:
93
- content = f.read()
94
- logger.info(f"Read {len(content)} bytes from {file_path}")
93
+ logger.debug("Reading file: %s", file_path)
94
+ with open(file_path, "r", encoding="utf-8") as file_handle:
95
+ content = file_handle.read()
96
+ logger.info("Read %s bytes from %s", len(content), file_path)
95
97
  return content
96
98
 
97
99
  except FileNotFoundError:
98
- error_msg = f"File not found: {file_path}"
99
- logger.error(error_msg)
100
- raise FileNotFoundError(error_msg)
100
+ error_message = f"File not found: {file_path}"
101
+ logger.error(error_message)
102
+ raise FileNotFoundError(error_message)
101
103
 
102
- except Exception as e:
103
- error_msg = f"Failed to read file {file_path}: {e}"
104
- logger.error(error_msg)
105
- raise IOError(error_msg)
104
+ except Exception as error:
105
+ error_message = f"Failed to read file {file_path}: {error}"
106
+ logger.error(error_message)
107
+ raise IOError(error_message)
106
108
 
107
109
  def write(self, path: str, content: str) -> bool:
108
110
  """
@@ -130,17 +132,17 @@ class FilePrimitive:
130
132
  # Create parent directories if needed
131
133
  file_path.parent.mkdir(parents=True, exist_ok=True)
132
134
 
133
- logger.debug(f"Writing to file: {file_path}")
134
- with open(file_path, "w", encoding="utf-8") as f:
135
- f.write(content)
135
+ logger.debug("Writing to file: %s", file_path)
136
+ with open(file_path, "w", encoding="utf-8") as file_handle:
137
+ file_handle.write(content)
136
138
 
137
- logger.info(f"Wrote {len(content)} bytes to {file_path}")
139
+ logger.info("Wrote %s bytes to %s", len(content), file_path)
138
140
  return True
139
141
 
140
- except Exception as e:
141
- error_msg = f"Failed to write file {file_path}: {e}"
142
- logger.error(error_msg)
143
- raise IOError(error_msg)
142
+ except Exception as error:
143
+ error_message = f"Failed to write file {file_path}: {error}"
144
+ logger.error(error_message)
145
+ raise IOError(error_message)
144
146
 
145
147
  def exists(self, path: str) -> bool:
146
148
  """
@@ -162,9 +164,9 @@ class FilePrimitive:
162
164
  """
163
165
  self._check_determinism("exists")
164
166
  file_path = self._resolve_path(path)
165
- exists = file_path.exists() and file_path.is_file()
166
- logger.debug(f"File exists check for {file_path}: {exists}")
167
- return exists
167
+ file_exists = file_path.exists() and file_path.is_file()
168
+ logger.debug("File exists check for %s: %s", file_path, file_exists)
169
+ return file_exists
168
170
 
169
171
  def size(self, path: str) -> int:
170
172
  """
@@ -187,13 +189,13 @@ class FilePrimitive:
187
189
  file_path = self._resolve_path(path)
188
190
 
189
191
  if not file_path.exists():
190
- error_msg = f"File not found: {file_path}"
191
- logger.error(error_msg)
192
- raise FileNotFoundError(error_msg)
192
+ error_message = f"File not found: {file_path}"
193
+ logger.error(error_message)
194
+ raise FileNotFoundError(error_message)
193
195
 
194
- size = file_path.stat().st_size
195
- logger.debug(f"File size for {file_path}: {size} bytes")
196
- return size
196
+ file_size_bytes = file_path.stat().st_size
197
+ logger.debug("File size for %s: %s bytes", file_path, file_size_bytes)
198
+ return file_size_bytes
197
199
 
198
200
  def _resolve_path(self, path: str) -> Path:
199
201
  """
@@ -208,22 +210,22 @@ class FilePrimitive:
208
210
  Raises:
209
211
  ValueError: If absolute path or path traversal detected
210
212
  """
211
- path_obj = Path(path)
213
+ relative_path = Path(path)
212
214
 
213
215
  # Security: Never allow absolute paths
214
- if path_obj.is_absolute():
216
+ if relative_path.is_absolute():
215
217
  raise ValueError(f"Absolute paths not allowed: {path}")
216
218
 
217
219
  # Resolve relative to base_path
218
- resolved = (self.base_path / path_obj).resolve()
220
+ resolved_path = (self.base_path / relative_path).resolve()
219
221
 
220
222
  # Security: Verify resolved path is under base_path
221
223
  try:
222
- resolved.relative_to(self.base_path)
224
+ resolved_path.relative_to(self.base_path)
223
225
  except ValueError:
224
226
  raise ValueError(f"Path traversal detected: {path} resolves outside base directory")
225
227
 
226
- return resolved
228
+ return resolved_path
227
229
 
228
230
  def __repr__(self) -> str:
229
231
  return f"FilePrimitive(base_path={self.base_path})"
@@ -17,7 +17,7 @@ Usage:
17
17
  """
18
18
 
19
19
  import logging
20
- from typing import Any, Optional, Dict, TYPE_CHECKING
20
+ from typing import Any, Optional, TYPE_CHECKING
21
21
 
22
22
  if TYPE_CHECKING:
23
23
  from tactus.dspy.agent import DSPyAgentHandle
@@ -26,7 +26,7 @@ if TYPE_CHECKING:
26
26
  logger = logging.getLogger(__name__)
27
27
 
28
28
 
29
- def _convert_lua_table(lua_table):
29
+ def _convert_lua_table(lua_table: Any) -> Any:
30
30
  """
31
31
  Convert a lupa Lua table to a Python dict or list.
32
32
 
@@ -87,7 +87,7 @@ class AgentHandle:
87
87
  self._execution_context: Optional[Any] = None
88
88
  logger.debug(f"AgentHandle created for '{name}'")
89
89
 
90
- def __call__(self, inputs=None):
90
+ def __call__(self, inputs: Any = None) -> Any:
91
91
  """
92
92
  Execute an agent turn using the callable interface.
93
93
 
@@ -108,27 +108,33 @@ class AgentHandle:
108
108
  result = worker({message = "Process this task"})
109
109
  print(result.response)
110
110
  """
111
- logger.debug(
112
- f"[CHECKPOINT] AgentHandle '{self.name}'.__call__ invoked, _primitive={self._primitive is not None}, _execution_context={self._execution_context is not None}"
113
- )
111
+ logger.debug(
112
+ "[CHECKPOINT] AgentHandle '%s'.__call__ invoked, _primitive=%s, "
113
+ "_execution_context=%s",
114
+ self.name,
115
+ self._primitive is not None,
116
+ self._execution_context is not None,
117
+ )
114
118
  if self._primitive is None:
115
119
  raise RuntimeError(
116
120
  f"Agent '{self.name}' initialization failed.\n"
117
121
  f"This should not happen with immediate agent creation.\n"
118
122
  f"Please report this as a bug with a minimal reproduction example."
119
123
  )
120
- # Convert Lua table to Python dict if needed
121
- converted_inputs = _convert_lua_table(inputs) if inputs is not None else None
122
-
123
- # Convenience: allow shorthand string calls in Lua:
124
- # World("Hello") == World({message = "Hello"})
125
- if isinstance(converted_inputs, str):
126
- converted_inputs = {"message": converted_inputs}
124
+ # Convert Lua table to Python dict if needed
125
+ converted_inputs = _convert_lua_table(inputs) if inputs is not None else None
126
+
127
+ # Convenience: allow shorthand string calls in Lua:
128
+ # World("Hello") == World({message = "Hello"})
129
+ if isinstance(converted_inputs, str):
130
+ converted_inputs = {"message": converted_inputs}
127
131
 
128
132
  # If we have an execution context, checkpoint the agent call
129
- logger.debug(
130
- f"[CHECKPOINT] AgentHandle '{self.name}' called, has_execution_context={self._execution_context is not None}"
131
- )
133
+ logger.debug(
134
+ "[CHECKPOINT] AgentHandle '%s' called, has_execution_context=%s",
135
+ self.name,
136
+ self._execution_context is not None,
137
+ )
132
138
  if self._execution_context is not None:
133
139
 
134
140
  def agent_call():
@@ -148,18 +154,20 @@ class AgentHandle:
148
154
  "file": info.get("source", "unknown"),
149
155
  "line": info.get("currentline", 0),
150
156
  }
151
- except Exception as e:
152
- logger.debug(f"Could not capture source location: {e}")
153
-
154
- logger.debug(
155
- f"[CHECKPOINT] Creating checkpoint for agent '{self.name}', type=agent_turn, source_info={source_info}"
156
- )
157
- result = self._execution_context.checkpoint(
158
- agent_call, checkpoint_type="agent_turn", source_info=source_info
159
- )
160
- else:
161
- # No execution context - call directly without checkpointing
162
- result = self._primitive(converted_inputs)
157
+ except Exception as error:
158
+ logger.debug("Could not capture source location: %s", error)
159
+
160
+ logger.debug(
161
+ "[CHECKPOINT] Creating checkpoint for agent '%s', type=agent_turn, source_info=%s",
162
+ self.name,
163
+ source_info,
164
+ )
165
+ result = self._execution_context.checkpoint(
166
+ agent_call, checkpoint_type="agent_turn", source_info=source_info
167
+ )
168
+ else:
169
+ # No execution context - call directly without checkpointing
170
+ result = self._primitive(converted_inputs)
163
171
 
164
172
  # Convenience: expose the last agent output on the handle as `.output`
165
173
  # for Lua patterns like `agent(); return agent.output`.
@@ -201,9 +209,13 @@ class AgentHandle:
201
209
  """
202
210
  self._primitive = primitive
203
211
  self._execution_context = execution_context
204
- logger.debug(
205
- f"[CHECKPOINT] AgentHandle '{self.name}' connected to primitive (checkpointing={'enabled' if execution_context else 'disabled'}, execution_context={execution_context})"
206
- )
212
+ logger.debug(
213
+ "[CHECKPOINT] AgentHandle '%s' connected to primitive (checkpointing=%s, "
214
+ "execution_context=%s)",
215
+ self.name,
216
+ "enabled" if execution_context else "disabled",
217
+ execution_context,
218
+ )
207
219
 
208
220
  def __repr__(self) -> str:
209
221
  connected = "connected" if self._primitive else "disconnected"
@@ -227,7 +239,7 @@ class ModelHandle:
227
239
  """
228
240
  self.name = name
229
241
  self._primitive: Optional["ModelPrimitive"] = None
230
- logger.debug(f"ModelHandle created for '{name}'")
242
+ logger.debug("ModelHandle created for '%s'", name)
231
243
 
232
244
  def predict(self, data: Any) -> Any:
233
245
  """
@@ -289,7 +301,7 @@ class ModelHandle:
289
301
  primitive: The ModelPrimitive to delegate to
290
302
  """
291
303
  self._primitive = primitive
292
- logger.debug(f"ModelHandle '{self.name}' connected to primitive")
304
+ logger.debug("ModelHandle '%s' connected to primitive", self.name)
293
305
 
294
306
  def __repr__(self) -> str:
295
307
  connected = "connected" if self._primitive else "disconnected"
@@ -303,7 +315,7 @@ class AgentLookup:
303
315
  Injected into Lua as 'Agent'. Callable to look up agents by name.
304
316
  """
305
317
 
306
- def __init__(self, registry: Dict[str, AgentHandle]):
318
+ def __init__(self, registry: dict[str, AgentHandle]):
307
319
  """
308
320
  Initialize with reference to the agent registry.
309
321
 
@@ -344,7 +356,7 @@ class ModelLookup:
344
356
  Injected into Lua as 'Model'. Callable to look up models by name.
345
357
  """
346
358
 
347
- def __init__(self, registry: Dict[str, ModelHandle]):
359
+ def __init__(self, registry: dict[str, ModelHandle]):
348
360
  """
349
361
  Initialize with reference to the model registry.
350
362
 
tactus/primitives/host.py CHANGED
@@ -9,9 +9,10 @@ It delegates allowlisted operations to the trusted host-side broker via the
9
9
  from __future__ import annotations
10
10
 
11
11
  import asyncio
12
- from typing import Any, Dict, Optional
12
+ from typing import Any, Optional
13
13
 
14
14
  from tactus.broker.client import BrokerClient
15
+ from tactus.utils.asyncio_helpers import clear_closed_event_loop
15
16
 
16
17
 
17
18
  class HostPrimitive:
@@ -27,7 +28,7 @@ class HostPrimitive:
27
28
 
28
29
  self._registry = HostToolRegistry.default()
29
30
 
30
- def _run_coro(self, coro):
31
+ def _run_coro(self, coroutine: Any) -> Any:
31
32
  """
32
33
  Run an async coroutine from Lua's synchronous context.
33
34
 
@@ -38,37 +39,38 @@ class HostPrimitive:
38
39
 
39
40
  import threading
40
41
 
41
- result_container = {"value": None, "exception": None}
42
+ thread_result = {"value": None, "exception": None}
42
43
 
43
44
  def run_in_thread():
44
45
  try:
45
- result_container["value"] = asyncio.run(coro)
46
- except Exception as e:
47
- result_container["exception"] = e
46
+ thread_result["value"] = asyncio.run(coroutine)
47
+ except Exception as error:
48
+ thread_result["exception"] = error
48
49
 
49
50
  thread = threading.Thread(target=run_in_thread)
50
51
  thread.start()
51
52
  thread.join()
52
53
 
53
- if result_container["exception"]:
54
- raise result_container["exception"]
55
- return result_container["value"]
54
+ if thread_result["exception"]:
55
+ raise thread_result["exception"]
56
+ return thread_result["value"]
56
57
 
57
58
  except RuntimeError:
58
- return asyncio.run(coro)
59
+ clear_closed_event_loop()
60
+ return asyncio.run(coroutine)
59
61
 
60
- def _lua_to_python(self, obj: Any) -> Any:
61
- if obj is None:
62
+ def _lua_to_python(self, value: Any) -> Any:
63
+ if value is None:
62
64
  return None
63
- if hasattr(obj, "items") and not isinstance(obj, dict):
64
- return {k: self._lua_to_python(v) for k, v in obj.items()}
65
- if isinstance(obj, dict):
66
- return {k: self._lua_to_python(v) for k, v in obj.items()}
67
- if isinstance(obj, (list, tuple)):
68
- return [self._lua_to_python(v) for v in obj]
69
- return obj
70
-
71
- def call(self, name: str, args: Optional[Dict[str, Any]] = None) -> Any:
65
+ if hasattr(value, "items") and not isinstance(value, dict):
66
+ return {k: self._lua_to_python(v) for k, v in value.items()}
67
+ if isinstance(value, dict):
68
+ return {k: self._lua_to_python(v) for k, v in value.items()}
69
+ if isinstance(value, (list, tuple)):
70
+ return [self._lua_to_python(v) for v in value]
71
+ return value
72
+
73
+ def call(self, name: str, args: Optional[dict[str, Any]] = None) -> Any:
72
74
  """
73
75
  Call an allowlisted host tool via the broker.
74
76
 
@@ -78,17 +80,17 @@ class HostPrimitive:
78
80
  if not isinstance(name, str) or not name:
79
81
  raise ValueError("Host.call requires a non-empty tool name string")
80
82
 
81
- args_dict = self._lua_to_python(args) or {}
82
- if not isinstance(args_dict, dict):
83
+ args_payload = self._lua_to_python(args) or {}
84
+ if not isinstance(args_payload, dict):
83
85
  raise ValueError("Host.call args must be an object/table")
84
86
 
85
87
  if self._client is not None:
86
- return self._run_coro(self._client.call_tool(name=name, args=args_dict))
88
+ return self._run_coro(self._client.call_tool(name=name, args=args_payload))
87
89
 
88
90
  if self._registry is not None:
89
91
  try:
90
- return self._registry.call(name, args_dict)
91
- except KeyError as e:
92
- raise RuntimeError(f"Tool not allowlisted: {name}") from e
92
+ return self._registry.call(name, args_payload)
93
+ except KeyError as error:
94
+ raise RuntimeError(f"Tool not allowlisted: {name}") from error
93
95
 
94
96
  raise RuntimeError("Host.call requires TACTUS_BROKER_SOCKET to be set")