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,318 @@
1
+ """
2
+ Lua-callable wrapper for named sub-procedures.
3
+
4
+ This module provides the ProcedureCallable class that enables direct function
5
+ call syntax for named procedures with automatic checkpointing and replay support.
6
+ """
7
+
8
+ from typing import Any, Dict, Optional
9
+
10
+
11
+ class ProcedureCallable:
12
+ """
13
+ Lua-callable wrapper for named sub-procedures.
14
+
15
+ Enables direct function call syntax: result = my_proc({input})
16
+ Integrates with checkpoint system for auto-replay.
17
+
18
+ Example:
19
+ helper = procedure("helper", {
20
+ input = {x = {type = "number"}},
21
+ output = {y = {type = "number"}}
22
+ }, function()
23
+ return {y = input.x * 2}
24
+ end)
25
+
26
+ -- Call it directly:
27
+ result = helper({x = 10}) -- Returns {y = 20}
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ name: str,
33
+ procedure_function: Any, # Lua function reference
34
+ input_schema: Dict[str, Any],
35
+ output_schema: Dict[str, Any],
36
+ state_schema: Dict[str, Any],
37
+ execution_context, # ExecutionContext instance
38
+ lua_sandbox, # LuaSandbox instance
39
+ is_main: bool = False, # Whether this is the main entry procedure
40
+ ):
41
+ """
42
+ Initialize a callable procedure wrapper.
43
+
44
+ Args:
45
+ name: Procedure name
46
+ procedure_function: Lua function reference to execute
47
+ input_schema: Input validation schema
48
+ output_schema: Output validation schema
49
+ state_schema: State initialization schema
50
+ execution_context: ExecutionContext for checkpointing
51
+ lua_sandbox: LuaSandbox for Lua global management
52
+ is_main: If True, don't checkpoint (main is entry point)
53
+ """
54
+ self.name = name
55
+ self.procedure_function = procedure_function
56
+ self.input_schema = input_schema
57
+ self.output_schema = output_schema
58
+ self.state_schema = state_schema
59
+ self.execution_context = execution_context
60
+ self.lua_sandbox = lua_sandbox
61
+ self.is_main = is_main
62
+
63
+ def __call__(self, params: Optional[Dict[str, Any]] = None) -> Any:
64
+ """
65
+ Execute the sub-procedure when called from Lua.
66
+
67
+ This method is invoked when Lua code calls: result = my_proc({...})
68
+
69
+ Args:
70
+ params: Input parameters as a dictionary (converted from Lua table)
71
+
72
+ Returns:
73
+ The procedure's result (will be converted to Lua table)
74
+
75
+ Raises:
76
+ ValueError: If input validation fails or output is missing required fields
77
+ """
78
+ params = params or {}
79
+
80
+ # Convert Lua table to dict if needed
81
+ if hasattr(params, "items"):
82
+ from tactus.core.dsl_stubs import lua_table_to_dict
83
+
84
+ params = lua_table_to_dict(params)
85
+
86
+ # Handle empty list case (lua_table_to_dict converts empty {} to [])
87
+ if isinstance(params, list) and len(params) == 0:
88
+ params = {}
89
+
90
+ # Validate input against schema
91
+ self._validate_input(params)
92
+
93
+ # Wrap execution in checkpoint for automatic replay
94
+ def execute_procedure():
95
+ # Convert Python lists/dicts to Lua tables before setting as input
96
+ def convert_to_lua(value):
97
+ """Recursively convert Python lists and dicts to Lua tables."""
98
+ if isinstance(value, list):
99
+ # Convert Python list to Lua table (1-indexed)
100
+ lua_table = self.lua_sandbox.lua.table()
101
+ for i, item in enumerate(value, 1):
102
+ lua_table[i] = convert_to_lua(item)
103
+ return lua_table
104
+ elif isinstance(value, dict):
105
+ # Convert Python dict to Lua table
106
+ lua_table = self.lua_sandbox.lua.table()
107
+ for k, v in value.items():
108
+ lua_table[k] = convert_to_lua(v)
109
+ return lua_table
110
+ else:
111
+ return value
112
+
113
+ # Convert params to Lua-compatible format
114
+ lua_params = self.lua_sandbox.lua.table()
115
+ for key, value in params.items():
116
+ lua_params[key] = convert_to_lua(value)
117
+
118
+ # Initialize state defaults WITHOUT replacing the state table
119
+ # (preserving the metatable setup)
120
+ state_defaults = self._initialize_state()
121
+ if state_defaults:
122
+ # Access state via globals and assign - this will trigger the metatable
123
+ state_table = self.lua_sandbox.lua.globals()["state"]
124
+ for key, value in state_defaults.items():
125
+ state_table[key] = convert_to_lua(value)
126
+
127
+ # Execute the procedure function with input as explicit parameter
128
+ result = self.procedure_function(lua_params)
129
+
130
+ # Convert Lua table result to Python dict
131
+ # Check for lupa table (not Python dict/list)
132
+ if result and hasattr(result, "items") and not isinstance(result, (dict, list)):
133
+ from tactus.core.dsl_stubs import lua_table_to_dict
134
+
135
+ result = lua_table_to_dict(result)
136
+
137
+ # Validate output
138
+ self._validate_output(result)
139
+
140
+ return result
141
+
142
+ # Use existing checkpoint infrastructure for sub-procedures
143
+ # Main procedure is NOT checkpointed (it's the entry point)
144
+ if self.is_main:
145
+ # Main procedure: execute directly without checkpointing
146
+ return execute_procedure()
147
+ else:
148
+ # Sub-procedure: checkpoint for automatic replay
149
+ # Try to capture Lua source location if available
150
+ source_info = None
151
+
152
+ # First try to get Lua debug info
153
+ # When called from Lua, we need to find the Lua caller's location
154
+ try:
155
+ # Get debug.getinfo function from Lua globals
156
+ lua_globals = self.lua_sandbox.lua.globals()
157
+ if hasattr(lua_globals, "debug") and hasattr(lua_globals.debug, "getinfo"):
158
+ # Try different stack levels to find the Lua caller
159
+ debug_info = None
160
+ with open("/tmp/tactus-debug.log", "a") as f:
161
+ f.write(f"DEBUG: Trying debug.getinfo for {self.name}\n")
162
+ for level in [1, 2, 3, 4, 5, 6, 7, 8]:
163
+ try:
164
+ info = lua_globals.debug.getinfo(level, "Sl")
165
+ if info:
166
+ lua_dict = dict(info.items()) if hasattr(info, "items") else {}
167
+ source = lua_dict.get("source", "")
168
+ line = lua_dict.get("currentline", -1)
169
+ with open("/tmp/tactus-debug.log", "a") as f:
170
+ f.write(f"DEBUG: Level {level}: source={source}, line={line}\n")
171
+ # Look for a valid source location (not -1, not C function)
172
+ # Accept [string "<python>"] sources since that's our Lua code
173
+ if line > 0 and source:
174
+ if source.startswith("=[C]"):
175
+ continue # Skip C functions
176
+ debug_info = lua_dict
177
+ with open("/tmp/tactus-debug.log", "a") as f:
178
+ f.write(f"DEBUG: Found valid source at level {level}\n")
179
+ break
180
+ except Exception as inner_e:
181
+ with open("/tmp/tactus-debug.log", "a") as f:
182
+ f.write(f"DEBUG: Level {level} error: {inner_e}\n")
183
+ continue
184
+
185
+ if debug_info:
186
+ source_info = {
187
+ "file": self.execution_context.current_tac_file
188
+ or debug_info.get("source", "unknown"),
189
+ "line": debug_info.get("currentline", 0),
190
+ "function": debug_info.get("name", self.name),
191
+ }
192
+ with open("/tmp/tactus-debug.log", "a") as f:
193
+ f.write(f"DEBUG: Final source_info: {source_info}\n")
194
+ except Exception as e:
195
+ with open("/tmp/tactus-debug.log", "a") as f:
196
+ f.write(f"DEBUG: Exception getting Lua debug info: {e}\n")
197
+
198
+ # If we still don't have source_info, use fallback
199
+ if not source_info:
200
+ import inspect
201
+
202
+ frame = inspect.currentframe()
203
+ if frame and frame.f_back:
204
+ caller_frame = frame.f_back
205
+ # Use .tac file if available, otherwise use Python file
206
+ tac_file = self.execution_context.current_tac_file
207
+ python_file = caller_frame.f_code.co_filename
208
+ with open("/tmp/tactus-debug.log", "a") as f:
209
+ f.write(
210
+ f"DEBUG: Fallback - current_tac_file={tac_file}, python_file={python_file}\n"
211
+ )
212
+ source_info = {
213
+ "file": tac_file or python_file,
214
+ "line": 0, # Line number unknown without Lua debug
215
+ "function": self.name,
216
+ }
217
+
218
+ return self.execution_context.checkpoint(
219
+ execute_procedure, checkpoint_type="procedure_call", source_info=source_info
220
+ )
221
+
222
+ def _validate_input(self, params: Dict[str, Any]) -> None:
223
+ """
224
+ Validate input parameters against input schema.
225
+
226
+ Args:
227
+ params: Input parameters to validate
228
+
229
+ Raises:
230
+ ValueError: If required fields are missing
231
+ """
232
+ missing_inputs = []
233
+ for field_name, field_def in self.input_schema.items():
234
+ if isinstance(field_def, dict) and field_def.get("required", False):
235
+ if field_name not in params:
236
+ field_type = field_def.get("type", "any")
237
+ field_desc = field_def.get("description", "")
238
+ missing_inputs.append(
239
+ f" - {field_name} ({field_type}): {field_desc}"
240
+ if field_desc
241
+ else f" - {field_name} ({field_type})"
242
+ )
243
+
244
+ if missing_inputs:
245
+ inputs_list = "\n".join(missing_inputs)
246
+ raise ValueError(
247
+ f"Procedure '{self.name}' requires input parameters that were not provided:\n{inputs_list}\n\n"
248
+ f"To run this procedure, provide the required inputs via the API or use a test specification."
249
+ )
250
+
251
+ def _validate_output(self, result: Any) -> None:
252
+ """
253
+ Validate output against output schema.
254
+
255
+ Args:
256
+ result: Output to validate
257
+
258
+ Raises:
259
+ ValueError: If output is not a dict or missing required fields
260
+ """
261
+ # If no output schema is declared, accept any return value.
262
+ if not self.output_schema:
263
+ return
264
+
265
+ # Scalar output schema support:
266
+ # output = field.string{...}
267
+ if (
268
+ isinstance(self.output_schema, dict)
269
+ and "type" in self.output_schema
270
+ and isinstance(self.output_schema.get("type"), str)
271
+ ):
272
+ expected_type = self.output_schema.get("type")
273
+ if expected_type not in {"string", "number", "boolean", "object", "array"}:
274
+ # Not a scalar schema; treat as normal object schema.
275
+ expected_type = None
276
+
277
+ else:
278
+ expected_type = None
279
+
280
+ if expected_type is not None:
281
+ is_required = bool(self.output_schema.get("required", False))
282
+ if result is None and not is_required:
283
+ return
284
+
285
+ if expected_type == "string" and not isinstance(result, str):
286
+ raise ValueError(f"Procedure '{self.name}' must return string, got {type(result)}")
287
+ if expected_type == "number" and not isinstance(result, (int, float)):
288
+ raise ValueError(f"Procedure '{self.name}' must return number, got {type(result)}")
289
+ if expected_type == "boolean" and not isinstance(result, bool):
290
+ raise ValueError(f"Procedure '{self.name}' must return boolean, got {type(result)}")
291
+ if expected_type == "object" and not isinstance(result, dict):
292
+ raise ValueError(f"Procedure '{self.name}' must return object, got {type(result)}")
293
+ if expected_type == "array" and not isinstance(result, list):
294
+ raise ValueError(f"Procedure '{self.name}' must return array, got {type(result)}")
295
+ return
296
+
297
+ if not isinstance(result, dict):
298
+ raise ValueError(f"Procedure '{self.name}' must return dict, got {type(result)}")
299
+
300
+ for field_name, field_def in self.output_schema.items():
301
+ if isinstance(field_def, dict) and field_def.get("required", False):
302
+ if field_name not in result:
303
+ raise ValueError(
304
+ f"Procedure '{self.name}' missing required output: {field_name}"
305
+ )
306
+
307
+ def _initialize_state(self) -> Dict[str, Any]:
308
+ """
309
+ Initialize state with default values from state schema.
310
+
311
+ Returns:
312
+ Dictionary with state defaults
313
+ """
314
+ state = {}
315
+ for field_name, field_def in self.state_schema.items():
316
+ if isinstance(field_def, dict) and "default" in field_def:
317
+ state[field_name] = field_def["default"]
318
+ return state
@@ -0,0 +1,155 @@
1
+ """
2
+ Retry Primitive - Error handling with exponential backoff.
3
+
4
+ Provides:
5
+ - Retry.with_backoff(fn, options) - Retry function with exponential backoff
6
+ """
7
+
8
+ import logging
9
+ import time
10
+ from typing import Callable, Any, Optional, Dict
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class RetryPrimitive:
16
+ """
17
+ Handles retry logic with exponential backoff for procedures.
18
+
19
+ Enables workflows to:
20
+ - Retry failed operations automatically
21
+ - Use exponential backoff between attempts
22
+ - Handle transient errors gracefully
23
+ - Configure max attempts and delays
24
+ """
25
+
26
+ def __init__(self):
27
+ """Initialize Retry primitive."""
28
+ logger.debug("RetryPrimitive initialized")
29
+
30
+ def with_backoff(self, fn: Callable, options: Optional[Dict[str, Any]] = None) -> Any:
31
+ """
32
+ Retry a function with exponential backoff.
33
+
34
+ Args:
35
+ fn: Function to retry (Lua function)
36
+ options: Dict with:
37
+ - max_attempts: Maximum retry attempts (default: 3)
38
+ - initial_delay: Initial delay in seconds (default: 1)
39
+ - max_delay: Maximum delay in seconds (default: 60)
40
+ - backoff_factor: Multiplier for delay (default: 2)
41
+ - on_error: Optional callback when error occurs
42
+
43
+ Returns:
44
+ Result from successful function call
45
+
46
+ Raises:
47
+ Exception: If all retry attempts fail
48
+
49
+ Example (Lua):
50
+ local result = Retry.with_backoff(function()
51
+ -- Try to fetch data from API
52
+ local data = fetch_api_data()
53
+ if not data then
54
+ error("API returned no data")
55
+ end
56
+ return data
57
+ end, {
58
+ max_attempts = 5,
59
+ initial_delay = 2,
60
+ backoff_factor = 2
61
+ })
62
+ """
63
+ # Convert Lua tables to Python dicts if needed
64
+ opts = self._convert_lua_to_python(options) or {}
65
+
66
+ max_attempts = opts.get("max_attempts", 3)
67
+ initial_delay = opts.get("initial_delay", 1.0)
68
+ max_delay = opts.get("max_delay", 60.0)
69
+ backoff_factor = opts.get("backoff_factor", 2.0)
70
+ on_error = opts.get("on_error")
71
+
72
+ attempt = 0
73
+ delay = initial_delay
74
+ last_error = None
75
+
76
+ logger.info(f"Starting retry with_backoff (max_attempts={max_attempts})")
77
+
78
+ while attempt < max_attempts:
79
+ attempt += 1
80
+
81
+ try:
82
+ logger.debug(f"Retry attempt {attempt}/{max_attempts}")
83
+ result = fn()
84
+ logger.info(f"Success on attempt {attempt}/{max_attempts}")
85
+ return result
86
+
87
+ except Exception as e:
88
+ last_error = e
89
+ logger.warning(f"Attempt {attempt}/{max_attempts} failed: {e}")
90
+
91
+ # Call error callback if provided
92
+ if on_error and callable(on_error):
93
+ try:
94
+ on_error(
95
+ {
96
+ "attempt": attempt,
97
+ "max_attempts": max_attempts,
98
+ "error": str(e),
99
+ "delay": delay,
100
+ }
101
+ )
102
+ except Exception as callback_error:
103
+ logger.error(f"Error callback failed: {callback_error}")
104
+
105
+ # Check if we should retry
106
+ if attempt >= max_attempts:
107
+ logger.error(f"All {max_attempts} attempts failed")
108
+ raise Exception(f"Retry failed after {max_attempts} attempts: {last_error}")
109
+
110
+ # Wait with exponential backoff
111
+ logger.info(f"Waiting {delay:.2f}s before retry...")
112
+ time.sleep(delay)
113
+
114
+ # Increase delay for next attempt (exponential backoff)
115
+ delay = min(delay * backoff_factor, max_delay)
116
+
117
+ # Should not reach here, but handle it
118
+ raise Exception(f"Retry logic error: {last_error}")
119
+
120
+ def _convert_lua_to_python(self, value: Any) -> Any:
121
+ """
122
+ Recursively convert Lua tables to Python dicts.
123
+
124
+ Args:
125
+ value: Lua value to convert
126
+
127
+ Returns:
128
+ Python equivalent (dict or primitive)
129
+ """
130
+ if value is None:
131
+ return None
132
+
133
+ # Import lupa for table checking
134
+ try:
135
+ from lupa import lua_type
136
+
137
+ # Check if it's a Lua table
138
+ if lua_type(value) == "table":
139
+ result = {}
140
+ for k, v in value.items():
141
+ # Convert key and value recursively
142
+ py_key = self._convert_lua_to_python(k) if lua_type(k) == "table" else k
143
+ py_value = self._convert_lua_to_python(v)
144
+ result[py_key] = py_value
145
+ return result
146
+ else:
147
+ # Primitive value or function
148
+ return value
149
+
150
+ except ImportError:
151
+ # If lupa not available, just return as-is
152
+ return value
153
+
154
+ def __repr__(self) -> str:
155
+ return "RetryPrimitive()"
@@ -0,0 +1,152 @@
1
+ """
2
+ Session primitive for managing conversation history.
3
+
4
+ Provides Lua-accessible methods for manipulating chat session state.
5
+ """
6
+
7
+ from typing import Any, Optional
8
+
9
+ try:
10
+ from pydantic_ai.messages import ModelMessage, ModelRequest, ModelResponse, TextPart
11
+ except ImportError:
12
+ # Fallback types if pydantic_ai not available
13
+ ModelMessage = dict
14
+ ModelRequest = dict
15
+ ModelResponse = dict
16
+ TextPart = dict
17
+
18
+
19
+ class SessionPrimitive:
20
+ """
21
+ Primitive for managing conversation session state.
22
+
23
+ Provides methods to:
24
+ - Append messages to history
25
+ - Inject system messages
26
+ - Clear history
27
+ - Access full history
28
+ - Save/load session state
29
+ """
30
+
31
+ def __init__(self, session_manager=None, agent_name: Optional[str] = None):
32
+ """
33
+ Initialize Session primitive.
34
+
35
+ Args:
36
+ session_manager: SessionManager instance
37
+ agent_name: Name of the agent this session belongs to
38
+ """
39
+ self.session_manager = session_manager
40
+ self.agent_name = agent_name
41
+
42
+ def append(self, message_data: dict) -> None:
43
+ """
44
+ Append a message to the session history.
45
+
46
+ Args:
47
+ message_data: Dict with 'role' and 'content' keys
48
+ role: 'user', 'assistant', 'system'
49
+ content: message text
50
+
51
+ Example:
52
+ Session.append({role = "user", content = "Hello"})
53
+ """
54
+ if not self.session_manager or not self.agent_name:
55
+ return
56
+
57
+ role = message_data.get("role", "user")
58
+ content = message_data.get("content", "")
59
+
60
+ # Create a simple message dict
61
+ message = {"role": role, "content": content}
62
+
63
+ self.session_manager.add_message(self.agent_name, message)
64
+
65
+ def inject_system(self, text: str) -> None:
66
+ """
67
+ Inject a system message into the session.
68
+
69
+ This is useful for providing context or instructions
70
+ for the next agent turn.
71
+
72
+ Args:
73
+ text: System message content
74
+
75
+ Example:
76
+ Session.inject_system("Focus on security implications")
77
+ """
78
+ self.append({"role": "system", "content": text})
79
+
80
+ def clear(self) -> None:
81
+ """
82
+ Clear the session history for this agent.
83
+
84
+ Example:
85
+ Session.clear()
86
+ """
87
+ if not self.session_manager or not self.agent_name:
88
+ return
89
+
90
+ self.session_manager.clear_agent_history(self.agent_name)
91
+
92
+ def history(self) -> list:
93
+ """
94
+ Get the full conversation history for this agent.
95
+
96
+ Returns:
97
+ List of message dicts with 'role' and 'content' keys
98
+
99
+ Example:
100
+ local messages = Session.history()
101
+ for i, msg in ipairs(messages) do
102
+ Log.info(msg.role .. ": " .. msg.content)
103
+ end
104
+ """
105
+ if not self.session_manager or not self.agent_name:
106
+ return []
107
+
108
+ messages = self.session_manager.histories.get(self.agent_name, [])
109
+
110
+ # Convert to Lua-friendly format
111
+ result = []
112
+ for msg in messages:
113
+ if isinstance(msg, dict):
114
+ result.append({"role": msg.get("role", ""), "content": str(msg.get("content", ""))})
115
+ else:
116
+ # Handle pydantic_ai ModelMessage objects
117
+ try:
118
+ result.append(
119
+ {
120
+ "role": getattr(msg, "role", ""),
121
+ "content": str(getattr(msg, "content", "")),
122
+ }
123
+ )
124
+ except Exception:
125
+ # Fallback: convert to string
126
+ result.append({"role": "unknown", "content": str(msg)})
127
+
128
+ return result
129
+
130
+ def load_from_node(self, node: Any) -> None:
131
+ """
132
+ Load session state from a graph node.
133
+
134
+ Not yet implemented - placeholder for future graph support.
135
+
136
+ Args:
137
+ node: Graph node containing saved session state
138
+ """
139
+ # TODO: Implement when graph primitives are added
140
+ pass
141
+
142
+ def save_to_node(self, node: Any) -> None:
143
+ """
144
+ Save session state to a graph node.
145
+
146
+ Not yet implemented - placeholder for future graph support.
147
+
148
+ Args:
149
+ node: Graph node to save session state to
150
+ """
151
+ # TODO: Implement when graph primitives are added
152
+ pass