tactus 0.35.0__py3-none-any.whl → 0.36.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/core/runtime.py CHANGED
@@ -221,17 +221,7 @@ class TactusRuntime:
221
221
  logger.info("Step 0: Setting up Lua sandbox")
222
222
  strict_determinism = self.external_config.get("strict_determinism", False)
223
223
 
224
- # Compute base_path for sandbox from source file path if available
225
- # This ensures require() works correctly even when running from different directories
226
- sandbox_base_path = None
227
- if self.source_file_path:
228
- from pathlib import Path
229
-
230
- sandbox_base_path = str(Path(self.source_file_path).parent.resolve())
231
- logger.debug(
232
- "Using source file directory as sandbox base_path: %s",
233
- sandbox_base_path,
234
- )
224
+ sandbox_base_path = self._resolve_sandbox_base_path()
235
225
 
236
226
  self.lua_sandbox = LuaSandbox(
237
227
  execution_context=None,
@@ -242,13 +232,7 @@ class TactusRuntime:
242
232
  # 0.5. Create execution context EARLY so it's available during DSL parsing
243
233
  # This is critical for immediate agent creation during parsing
244
234
  logger.info("Step 0.5: Creating execution context (early)")
245
- self.execution_context = BaseExecutionContext(
246
- procedure_id=self.procedure_id,
247
- storage_backend=self.storage_backend,
248
- hitl_handler=self.hitl_handler,
249
- strict_determinism=strict_determinism,
250
- log_handler=self.log_handler,
251
- )
235
+ self.execution_context = self._create_execution_context(strict_determinism)
252
236
 
253
237
  # Set run_id if provided
254
238
  if self.run_id:
@@ -788,6 +772,30 @@ class TactusRuntime:
788
772
  except Exception as e:
789
773
  logger.warning("Error cleaning up dependencies: %s", e)
790
774
 
775
+ def _resolve_sandbox_base_path(self) -> str | None:
776
+ # Compute base_path for sandbox from source file path if available.
777
+ # This ensures require() works correctly even when running from different directories.
778
+ if not self.source_file_path:
779
+ return None
780
+
781
+ from pathlib import Path
782
+
783
+ sandbox_base_path = str(Path(self.source_file_path).parent.resolve())
784
+ logger.debug(
785
+ "Using source file directory as sandbox base_path: %s",
786
+ sandbox_base_path,
787
+ )
788
+ return sandbox_base_path
789
+
790
+ def _create_execution_context(self, strict_determinism: bool) -> BaseExecutionContext:
791
+ return BaseExecutionContext(
792
+ procedure_id=self.procedure_id,
793
+ storage_backend=self.storage_backend,
794
+ hitl_handler=self.hitl_handler,
795
+ strict_determinism=strict_determinism,
796
+ log_handler=self.log_handler,
797
+ )
798
+
791
799
  async def _initialize_primitives(
792
800
  self,
793
801
  placeholder_tool: ToolPrimitive | None = None,
tactus/ide/server.py CHANGED
@@ -11,7 +11,7 @@ import queue
11
11
  import subprocess
12
12
  import threading
13
13
  import time
14
- from datetime import datetime
14
+ from datetime import datetime, timezone
15
15
  from pathlib import Path
16
16
  from flask import Flask, request, jsonify, Response, stream_with_context
17
17
  from flask_cors import CORS
@@ -29,6 +29,11 @@ WORKSPACE_ROOT = None
29
29
  _clear_runtime_caches_fn = None
30
30
 
31
31
 
32
+ def _utc_now_iso() -> str:
33
+ """Return an ISO-8601 UTC timestamp with a trailing Z."""
34
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
35
+
36
+
32
37
  def clear_runtime_caches():
33
38
  """Clear cached runtime instances. Must be called after create_app() initializes."""
34
39
  if _clear_runtime_caches_fn:
@@ -559,7 +564,6 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
559
564
  """Generator function that yields SSE validation events."""
560
565
  try:
561
566
  import json
562
- from datetime import datetime
563
567
 
564
568
  # Read and validate file
565
569
  content = path.read_text()
@@ -588,7 +592,7 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
588
592
  }
589
593
  for warn in result.warnings
590
594
  ],
591
- "timestamp": datetime.utcnow().isoformat() + "Z",
595
+ "timestamp": _utc_now_iso(),
592
596
  }
593
597
  yield f"data: {json.dumps(validation_event)}\n\n"
594
598
 
@@ -597,7 +601,7 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
597
601
  error_event = {
598
602
  "event_type": "execution",
599
603
  "lifecycle_stage": "error",
600
- "timestamp": datetime.utcnow().isoformat() + "Z",
604
+ "timestamp": _utc_now_iso(),
601
605
  "details": {"error": str(e)},
602
606
  }
603
607
  yield f"data: {json.dumps(error_event)}\n\n"
@@ -720,7 +724,6 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
720
724
  try:
721
725
  # Send start event
722
726
  import json
723
- from datetime import datetime
724
727
  from tactus.adapters.ide_log import IDELogHandler
725
728
  from tactus.core.runtime import TactusRuntime
726
729
  from tactus.adapters.file_storage import FileStorage
@@ -734,7 +737,7 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
734
737
  "lifecycle_stage": "start",
735
738
  "procedure_id": procedure_id,
736
739
  "run_id": run_id,
737
- "timestamp": datetime.utcnow().isoformat() + "Z",
740
+ "timestamp": _utc_now_iso(),
738
741
  "details": {"path": file_path},
739
742
  "inputs": inputs, # Include inputs in start event
740
743
  }
@@ -837,7 +840,7 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
837
840
  "event_type": "container_status",
838
841
  "status": "starting",
839
842
  "execution_id": run_id,
840
- "timestamp": datetime.utcnow().isoformat() + "Z",
843
+ "timestamp": _utc_now_iso(),
841
844
  }
842
845
  all_events.append(container_starting_event)
843
846
  yield f"data: {json.dumps(container_starting_event)}\n\n"
@@ -992,7 +995,7 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
992
995
  "event_type": "container_status",
993
996
  "status": "running",
994
997
  "execution_id": run_id,
995
- "timestamp": datetime.utcnow().isoformat() + "Z",
998
+ "timestamp": _utc_now_iso(),
996
999
  }
997
1000
  all_events.append(container_running_event)
998
1001
  yield f"data: {json.dumps(container_running_event)}\n\n"
@@ -1088,12 +1091,41 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
1088
1091
  "event_type": "container_status",
1089
1092
  "status": "stopped",
1090
1093
  "execution_id": run_id,
1091
- "timestamp": datetime.utcnow().isoformat() + "Z",
1094
+ "timestamp": _utc_now_iso(),
1092
1095
  }
1093
1096
  all_events.append(container_stopped_event)
1094
1097
  yield f"data: {json.dumps(container_stopped_event)}\n\n"
1095
1098
  else:
1096
1099
  # Drain IDELogHandler events (direct execution)
1100
+ # Drain queued events that may have been enqueued before execution completed.
1101
+ while True:
1102
+ try:
1103
+ event = log_handler.events.get(timeout=0.01)
1104
+ except queue.Empty:
1105
+ break
1106
+ try:
1107
+ event_dict = event.model_dump(mode="json")
1108
+ iso_string = event.timestamp.isoformat()
1109
+ if not (
1110
+ iso_string.endswith("Z")
1111
+ or "+" in iso_string
1112
+ or iso_string.count("-") > 2
1113
+ ):
1114
+ iso_string += "Z"
1115
+ event_dict["timestamp"] = iso_string
1116
+ all_events.append(event_dict)
1117
+ yield f"data: {json.dumps(event_dict)}\n\n"
1118
+ except Exception as e:
1119
+ logger.error(
1120
+ "Error serializing event: %s",
1121
+ e,
1122
+ exc_info=True,
1123
+ )
1124
+ logger.error(
1125
+ "Event type: %s, Event: %s",
1126
+ type(event),
1127
+ event,
1128
+ )
1097
1129
  events = log_handler.get_events(timeout=0.1)
1098
1130
  for event in events:
1099
1131
  try:
@@ -1132,7 +1164,7 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
1132
1164
  "lifecycle_stage": "error",
1133
1165
  "procedure_id": procedure_id,
1134
1166
  "exit_code": 1,
1135
- "timestamp": datetime.utcnow().isoformat() + "Z",
1167
+ "timestamp": _utc_now_iso(),
1136
1168
  "details": {"success": False, "error": str(result_container["error"])},
1137
1169
  }
1138
1170
  else:
@@ -1141,7 +1173,7 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
1141
1173
  "lifecycle_stage": "complete",
1142
1174
  "procedure_id": procedure_id,
1143
1175
  "exit_code": 0,
1144
- "timestamp": datetime.utcnow().isoformat() + "Z",
1176
+ "timestamp": _utc_now_iso(),
1145
1177
  "details": {"success": True},
1146
1178
  }
1147
1179
  all_events.append(complete_event)
@@ -1186,7 +1218,7 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
1186
1218
  "event_type": "execution",
1187
1219
  "lifecycle_stage": "error",
1188
1220
  "procedure_id": procedure_id,
1189
- "timestamp": datetime.utcnow().isoformat() + "Z",
1221
+ "timestamp": _utc_now_iso(),
1190
1222
  "details": {"error": str(e)},
1191
1223
  }
1192
1224
  yield f"data: {json.dumps(error_event)}\n\n"
@@ -1241,7 +1273,6 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
1241
1273
  """Generator function that yields SSE test events."""
1242
1274
  try:
1243
1275
  import json
1244
- from datetime import datetime
1245
1276
  from tactus.validation import TactusValidator
1246
1277
  from tactus.testing import TactusTestRunner, GherkinParser
1247
1278
 
@@ -1255,7 +1286,7 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
1255
1286
  "event_type": "execution",
1256
1287
  "lifecycle_stage": "error",
1257
1288
  "procedure_id": procedure_id,
1258
- "timestamp": datetime.utcnow().isoformat() + "Z",
1289
+ "timestamp": _utc_now_iso(),
1259
1290
  "details": {
1260
1291
  "error": "Validation failed",
1261
1292
  "errors": [
@@ -1276,7 +1307,7 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
1276
1307
  "event_type": "execution",
1277
1308
  "lifecycle_stage": "error",
1278
1309
  "procedure_id": procedure_id,
1279
- "timestamp": datetime.utcnow().isoformat() + "Z",
1310
+ "timestamp": _utc_now_iso(),
1280
1311
  "details": {"error": "No specifications found in procedure"},
1281
1312
  }
1282
1313
  yield f"data: {json.dumps(error_event)}\n\n"
@@ -1327,7 +1358,7 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
1327
1358
  "event_type": "test_started",
1328
1359
  "procedure_file": str(path),
1329
1360
  "total_scenarios": total_scenarios,
1330
- "timestamp": datetime.utcnow().isoformat() + "Z",
1361
+ "timestamp": _utc_now_iso(),
1331
1362
  }
1332
1363
  yield f"data: {json.dumps(start_event)}\n\n"
1333
1364
 
@@ -1347,7 +1378,7 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
1347
1378
  "llm_calls": scenario.llm_calls,
1348
1379
  "iterations": scenario.iterations,
1349
1380
  "tools_used": scenario.tools_used,
1350
- "timestamp": datetime.utcnow().isoformat() + "Z",
1381
+ "timestamp": _utc_now_iso(),
1351
1382
  }
1352
1383
  yield f"data: {json.dumps(scenario_event)}\n\n"
1353
1384
 
@@ -1387,7 +1418,7 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
1387
1418
  for f in test_result.features
1388
1419
  ],
1389
1420
  },
1390
- "timestamp": datetime.utcnow().isoformat() + "Z",
1421
+ "timestamp": _utc_now_iso(),
1391
1422
  }
1392
1423
  yield f"data: {json.dumps(complete_event)}\n\n"
1393
1424
 
@@ -1400,7 +1431,7 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
1400
1431
  "event_type": "execution",
1401
1432
  "lifecycle_stage": "error",
1402
1433
  "procedure_id": procedure_id,
1403
- "timestamp": datetime.utcnow().isoformat() + "Z",
1434
+ "timestamp": _utc_now_iso(),
1404
1435
  "details": {"error": str(e)},
1405
1436
  }
1406
1437
  yield f"data: {json.dumps(error_event)}\n\n"
@@ -1457,7 +1488,6 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
1457
1488
  """Generator function that yields SSE evaluation events."""
1458
1489
  try:
1459
1490
  import json
1460
- from datetime import datetime
1461
1491
  from tactus.validation import TactusValidator
1462
1492
  from tactus.testing import TactusEvaluationRunner, GherkinParser
1463
1493
 
@@ -1470,7 +1500,7 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
1470
1500
  "event_type": "execution",
1471
1501
  "lifecycle_stage": "error",
1472
1502
  "procedure_id": procedure_id,
1473
- "timestamp": datetime.utcnow().isoformat() + "Z",
1503
+ "timestamp": _utc_now_iso(),
1474
1504
  "details": {
1475
1505
  "error": "Validation failed",
1476
1506
  "errors": [
@@ -1490,7 +1520,7 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
1490
1520
  "event_type": "execution",
1491
1521
  "lifecycle_stage": "error",
1492
1522
  "procedure_id": procedure_id,
1493
- "timestamp": datetime.utcnow().isoformat() + "Z",
1523
+ "timestamp": _utc_now_iso(),
1494
1524
  "details": {"error": "No specifications found in procedure"},
1495
1525
  }
1496
1526
  yield f"data: {json.dumps(error_event)}\n\n"
@@ -1512,7 +1542,7 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
1512
1542
  "procedure_file": str(path),
1513
1543
  "total_scenarios": total_scenarios,
1514
1544
  "runs_per_scenario": runs,
1515
- "timestamp": datetime.utcnow().isoformat() + "Z",
1545
+ "timestamp": _utc_now_iso(),
1516
1546
  }
1517
1547
  yield f"data: {json.dumps(start_event)}\n\n"
1518
1548
 
@@ -1526,7 +1556,7 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
1526
1556
  "scenario_name": eval_result.scenario_name,
1527
1557
  "completed_runs": eval_result.total_runs,
1528
1558
  "total_runs": eval_result.total_runs,
1529
- "timestamp": datetime.utcnow().isoformat() + "Z",
1559
+ "timestamp": _utc_now_iso(),
1530
1560
  }
1531
1561
  yield f"data: {json.dumps(progress_event)}\n\n"
1532
1562
 
@@ -1547,7 +1577,7 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
1547
1577
  }
1548
1578
  for r in eval_results
1549
1579
  ],
1550
- "timestamp": datetime.utcnow().isoformat() + "Z",
1580
+ "timestamp": _utc_now_iso(),
1551
1581
  }
1552
1582
  yield f"data: {json.dumps(complete_event)}\n\n"
1553
1583
 
@@ -1560,7 +1590,7 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
1560
1590
  "event_type": "execution",
1561
1591
  "lifecycle_stage": "error",
1562
1592
  "procedure_id": procedure_id,
1563
- "timestamp": datetime.utcnow().isoformat() + "Z",
1593
+ "timestamp": _utc_now_iso(),
1564
1594
  "details": {"error": str(e)},
1565
1595
  }
1566
1596
  yield f"data: {json.dumps(error_event)}\n\n"
@@ -1629,7 +1659,7 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
1629
1659
  "event_type": "execution",
1630
1660
  "lifecycle_stage": "error",
1631
1661
  "procedure_id": procedure_id,
1632
- "timestamp": datetime.utcnow().isoformat() + "Z",
1662
+ "timestamp": _utc_now_iso(),
1633
1663
  "details": {
1634
1664
  "error": "Validation failed",
1635
1665
  "errors": [e.message for e in validation_result.errors],
@@ -1646,7 +1676,7 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
1646
1676
  "event_type": "execution",
1647
1677
  "lifecycle_stage": "error",
1648
1678
  "procedure_id": procedure_id,
1649
- "timestamp": datetime.utcnow().isoformat() + "Z",
1679
+ "timestamp": _utc_now_iso(),
1650
1680
  "details": {"error": "No evaluations found in procedure"},
1651
1681
  }
1652
1682
  yield f"data: {json.dumps(error_event)}\n\n"
@@ -1672,7 +1702,7 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
1672
1702
  "event_type": "execution",
1673
1703
  "lifecycle_stage": "started",
1674
1704
  "procedure_id": procedure_id,
1675
- "timestamp": datetime.utcnow().isoformat() + "Z",
1705
+ "timestamp": _utc_now_iso(),
1676
1706
  "details": {"type": "pydantic_eval", "runs": actual_runs},
1677
1707
  }
1678
1708
  yield f"data: {json.dumps(start_event)}\n\n"
@@ -1747,7 +1777,7 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
1747
1777
  "event_type": "execution",
1748
1778
  "lifecycle_stage": "complete",
1749
1779
  "procedure_id": procedure_id,
1750
- "timestamp": datetime.utcnow().isoformat() + "Z",
1780
+ "timestamp": _utc_now_iso(),
1751
1781
  "details": result_details,
1752
1782
  }
1753
1783
  yield f"data: {json.dumps(result_event)}\n\n"
@@ -1757,7 +1787,7 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
1757
1787
  "event_type": "execution",
1758
1788
  "lifecycle_stage": "error",
1759
1789
  "procedure_id": procedure_id,
1760
- "timestamp": datetime.utcnow().isoformat() + "Z",
1790
+ "timestamp": _utc_now_iso(),
1761
1791
  "details": {"error": f"pydantic_evals not installed: {e}"},
1762
1792
  }
1763
1793
  yield f"data: {json.dumps(error_event)}\n\n"
@@ -1767,7 +1797,7 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
1767
1797
  "event_type": "execution",
1768
1798
  "lifecycle_stage": "error",
1769
1799
  "procedure_id": procedure_id,
1770
- "timestamp": datetime.utcnow().isoformat() + "Z",
1800
+ "timestamp": _utc_now_iso(),
1771
1801
  "details": {"error": str(e)},
1772
1802
  }
1773
1803
  yield f"data: {json.dumps(error_event)}\n\n"
@@ -2484,7 +2514,7 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
2484
2514
  connection_event = {
2485
2515
  "type": "connection",
2486
2516
  "status": "connected",
2487
- "timestamp": datetime.utcnow().isoformat() + "Z",
2517
+ "timestamp": _utc_now_iso(),
2488
2518
  }
2489
2519
  logger.info("[HITL-SSE] Sending connection event to client")
2490
2520
  yield f"data: {json.dumps(connection_event)}\n\n"
tactus/primitives/host.py CHANGED
@@ -37,27 +37,30 @@ class HostPrimitive:
37
37
  try:
38
38
  asyncio.get_running_loop()
39
39
 
40
- import threading
40
+ return self._run_coro_in_thread(coroutine)
41
41
 
42
- thread_result = {"value": None, "exception": None}
42
+ except RuntimeError:
43
+ clear_closed_event_loop()
44
+ return asyncio.run(coroutine)
45
+
46
+ def _run_coro_in_thread(self, coroutine: Any) -> Any:
47
+ import threading
43
48
 
44
- def run_in_thread():
45
- try:
46
- thread_result["value"] = asyncio.run(coroutine)
47
- except Exception as error:
48
- thread_result["exception"] = error
49
+ result_container = {"value": None, "exception": None}
49
50
 
50
- thread = threading.Thread(target=run_in_thread)
51
- thread.start()
52
- thread.join()
51
+ def run_in_thread():
52
+ try:
53
+ result_container["value"] = asyncio.run(coroutine)
54
+ except Exception as error:
55
+ result_container["exception"] = error
53
56
 
54
- if thread_result["exception"]:
55
- raise thread_result["exception"]
56
- return thread_result["value"]
57
+ worker_thread = threading.Thread(target=run_in_thread)
58
+ worker_thread.start()
59
+ worker_thread.join()
57
60
 
58
- except RuntimeError:
59
- clear_closed_event_loop()
60
- return asyncio.run(coroutine)
61
+ if result_container["exception"]:
62
+ raise result_container["exception"]
63
+ return result_container["value"]
61
64
 
62
65
  def _lua_to_python(self, value: Any) -> Any:
63
66
  if value is None:
@@ -58,13 +58,7 @@ class MessageHistoryPrimitive:
58
58
  return
59
59
 
60
60
  message_payload = self._normalize_message_payload(message_payload)
61
- role = message_payload.get("role", "user")
62
- content = message_payload.get("content", "")
63
-
64
- # Create a message dict and preserve extra fields
65
- message_entry = dict(message_payload)
66
- message_entry["role"] = role
67
- message_entry["content"] = content
61
+ message_entry = self._build_message_entry(message_payload)
68
62
 
69
63
  self.message_history_manager.add_message(self.agent_name, message_entry)
70
64
 
@@ -116,13 +110,7 @@ class MessageHistoryPrimitive:
116
110
  return []
117
111
  messages = self._get_history_ref()
118
112
 
119
- # Convert to Lua-friendly format
120
- result: list[dict[str, Any]] = []
121
- for message in messages:
122
- serialized_message = self._serialize_message(message)
123
- result.append(serialized_message)
124
-
125
- return result
113
+ return self._serialize_messages(messages)
126
114
 
127
115
  def replace(self, messages: list[Any]) -> None:
128
116
  """
@@ -345,6 +333,15 @@ class MessageHistoryPrimitive:
345
333
  pass
346
334
  return {"role": "user", "content": str(message_payload)}
347
335
 
336
+ def _build_message_entry(self, message_payload: dict[str, Any]) -> dict[str, Any]:
337
+ role = message_payload.get("role", "user")
338
+ content = message_payload.get("content", "")
339
+
340
+ message_entry = dict(message_payload)
341
+ message_entry["role"] = role
342
+ message_entry["content"] = content
343
+ return message_entry
344
+
348
345
  def _normalize_message_data(self, message_data: Any) -> dict[str, Any]:
349
346
  """Compatibility alias for existing tests and external callers."""
350
347
  return self._normalize_message_payload(message_data)
@@ -330,16 +330,11 @@ class ProcedurePrimitive:
330
330
  # Create runtime for sub-procedure
331
331
  runtime = self.runtime_factory(name, params)
332
332
 
333
- # Execute in new event loop (thread-safe)
334
- event_loop = asyncio.new_event_loop()
335
- asyncio.set_event_loop(event_loop)
336
-
337
- result = event_loop.run_until_complete(
333
+ # Execute in a dedicated event loop for thread safety
334
+ result = self._run_in_new_event_loop(
338
335
  runtime.execute(source=source, context=params, format="lua")
339
336
  )
340
337
 
341
- event_loop.close()
342
-
343
338
  # Update handle
344
339
  with self._lock:
345
340
  if result.get("success"):
@@ -368,6 +363,14 @@ class ProcedurePrimitive:
368
363
  handle.error = str(error)
369
364
  handle.completed_at = datetime.now()
370
365
 
366
+ def _run_in_new_event_loop(self, coroutine: Any) -> dict[str, Any]:
367
+ event_loop = asyncio.new_event_loop()
368
+ asyncio.set_event_loop(event_loop)
369
+ try:
370
+ return event_loop.run_until_complete(coroutine)
371
+ finally:
372
+ event_loop.close()
373
+
371
374
  def status(self, handle: ProcedureHandle) -> dict[str, Any]:
372
375
  """
373
376
  Get procedure status.
@@ -80,11 +80,7 @@ class SessionPrimitive:
80
80
  if not self._has_session_context():
81
81
  return
82
82
 
83
- message_role = message_payload.get("role", "user")
84
- message_content = message_payload.get("content", "")
85
-
86
- # Create a simple message dict
87
- message_entry = {"role": message_role, "content": message_content}
83
+ message_entry = self._build_message_entry(message_payload)
88
84
 
89
85
  self.session_manager.add_message(self.agent_name, message_entry)
90
86
 
@@ -134,11 +130,15 @@ class SessionPrimitive:
134
130
  messages = self.session_manager.histories.get(self.agent_name, [])
135
131
 
136
132
  # Convert to Lua-friendly format
137
- serialized_messages: list[dict[str, str]] = [
138
- self._serialize_message(message) for message in messages
139
- ]
133
+ return self._serialize_messages(messages)
134
+
135
+ def _build_message_entry(self, message_payload: dict[str, Any]) -> dict[str, Any]:
136
+ message_role = message_payload.get("role", "user")
137
+ message_content = message_payload.get("content", "")
138
+ return {"role": message_role, "content": message_content}
140
139
 
141
- return serialized_messages
140
+ def _serialize_messages(self, messages: list[Any]) -> list[dict[str, str]]:
141
+ return [self._serialize_message(message) for message in messages]
142
142
 
143
143
  def load_from_node(self, node: Any) -> None:
144
144
  """
@@ -251,30 +251,7 @@ class ToolHandle:
251
251
  return asyncio.run(self.implementation_function(args))
252
252
  except ImportError:
253
253
  # nest_asyncio not available, fall back to threading
254
- import threading
255
-
256
- async_result = {"value": None, "exception": None}
257
-
258
- def run_in_thread():
259
- try:
260
- thread_event_loop = asyncio.new_event_loop()
261
- asyncio.set_event_loop(thread_event_loop)
262
- try:
263
- async_result["value"] = thread_event_loop.run_until_complete(
264
- self.implementation_function(args)
265
- )
266
- finally:
267
- thread_event_loop.close()
268
- except Exception as error:
269
- async_result["exception"] = error
270
-
271
- worker_thread = threading.Thread(target=run_in_thread)
272
- worker_thread.start()
273
- worker_thread.join()
274
-
275
- if async_result["exception"]:
276
- raise async_result["exception"]
277
- return async_result["value"]
254
+ return self._run_async_in_thread(args)
278
255
 
279
256
  except RuntimeError:
280
257
  # No event loop running - safe to use asyncio.run()
@@ -297,5 +274,31 @@ class ToolHandle:
297
274
  result[key] = value
298
275
  return result
299
276
 
277
+ def _run_async_in_thread(self, args: dict[str, Any]) -> Any:
278
+ import threading
279
+
280
+ thread_result = {"value": None, "exception": None}
281
+
282
+ def run_in_thread():
283
+ try:
284
+ thread_event_loop = asyncio.new_event_loop()
285
+ asyncio.set_event_loop(thread_event_loop)
286
+ try:
287
+ thread_result["value"] = thread_event_loop.run_until_complete(
288
+ self.implementation_function(args)
289
+ )
290
+ finally:
291
+ thread_event_loop.close()
292
+ except Exception as error:
293
+ thread_result["exception"] = error
294
+
295
+ worker_thread = threading.Thread(target=run_in_thread)
296
+ worker_thread.start()
297
+ worker_thread.join()
298
+
299
+ if thread_result["exception"]:
300
+ raise thread_result["exception"]
301
+ return thread_result["value"]
302
+
300
303
  def __repr__(self) -> str:
301
304
  return f"ToolHandle('{self.name}')"
@@ -6,7 +6,7 @@ allowing tests to run without human intervention.
6
6
  """
7
7
 
8
8
  import logging
9
- from datetime import datetime
9
+ from datetime import datetime, timezone
10
10
  from typing import Any, Dict, Optional
11
11
 
12
12
  from tactus.protocols.models import HITLRequest, HITLResponse
@@ -71,7 +71,7 @@ class MockHITLHandler:
71
71
 
72
72
  return HITLResponse(
73
73
  value=value,
74
- responded_at=datetime.utcnow(),
74
+ responded_at=datetime.now(timezone.utc),
75
75
  timed_out=False,
76
76
  )
77
77
 
tactus/testing/models.py CHANGED
@@ -82,6 +82,8 @@ class FeatureResult(BaseModel):
82
82
  class TestResult(BaseModel):
83
83
  """Result from 'tactus test' command."""
84
84
 
85
+ __test__ = False
86
+
85
87
  features: List[FeatureResult] = Field(default_factory=list)
86
88
  total_scenarios: int
87
89
  passed_scenarios: int