tactus 0.35.1__py3-none-any.whl → 0.37.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.
Files changed (43) hide show
  1. tactus/__init__.py +1 -1
  2. tactus/adapters/channels/base.py +20 -2
  3. tactus/adapters/channels/broker.py +1 -0
  4. tactus/adapters/channels/host.py +3 -1
  5. tactus/adapters/channels/ipc.py +18 -3
  6. tactus/adapters/channels/sse.py +13 -5
  7. tactus/adapters/control_loop.py +44 -30
  8. tactus/adapters/mcp_manager.py +24 -7
  9. tactus/backends/http_backend.py +2 -2
  10. tactus/backends/pytorch_backend.py +2 -2
  11. tactus/broker/client.py +3 -3
  12. tactus/broker/server.py +17 -5
  13. tactus/core/dsl_stubs.py +3 -3
  14. tactus/core/execution_context.py +32 -27
  15. tactus/core/lua_sandbox.py +42 -34
  16. tactus/core/message_history_manager.py +51 -28
  17. tactus/core/output_validator.py +65 -51
  18. tactus/core/registry.py +29 -29
  19. tactus/core/runtime.py +69 -61
  20. tactus/dspy/broker_lm.py +13 -7
  21. tactus/dspy/config.py +7 -4
  22. tactus/ide/server.py +63 -33
  23. tactus/primitives/host.py +19 -16
  24. tactus/primitives/message_history.py +11 -14
  25. tactus/primitives/model.py +1 -1
  26. tactus/primitives/procedure.py +11 -8
  27. tactus/primitives/session.py +9 -9
  28. tactus/primitives/state.py +2 -2
  29. tactus/primitives/tool_handle.py +27 -24
  30. tactus/sandbox/container_runner.py +11 -6
  31. tactus/testing/context.py +6 -6
  32. tactus/testing/evaluation_runner.py +5 -5
  33. tactus/testing/mock_hitl.py +2 -2
  34. tactus/testing/models.py +2 -0
  35. tactus/testing/steps/builtin.py +2 -2
  36. tactus/testing/test_runner.py +6 -4
  37. tactus/utils/asyncio_helpers.py +2 -1
  38. tactus/utils/safe_libraries.py +2 -2
  39. {tactus-0.35.1.dist-info → tactus-0.37.0.dist-info}/METADATA +11 -5
  40. {tactus-0.35.1.dist-info → tactus-0.37.0.dist-info}/RECORD +43 -43
  41. {tactus-0.35.1.dist-info → tactus-0.37.0.dist-info}/WHEEL +0 -0
  42. {tactus-0.35.1.dist-info → tactus-0.37.0.dist-info}/entry_points.txt +0 -0
  43. {tactus-0.35.1.dist-info → tactus-0.37.0.dist-info}/licenses/LICENSE +0 -0
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)
@@ -27,7 +27,7 @@ class ModelPrimitive:
27
27
  self,
28
28
  model_name: str,
29
29
  config: dict,
30
- context: ExecutionContext | None = None,
30
+ context: Optional[ExecutionContext] = None,
31
31
  mock_manager: Optional[Any] = None,
32
32
  ):
33
33
  """
@@ -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.
@@ -547,7 +550,7 @@ class ProcedurePrimitive:
547
550
 
548
551
  name_path = Path(name)
549
552
 
550
- def add_candidates(base: Path | None, rel: Path) -> None:
553
+ def add_candidates(base: Optional[Path], rel: Path) -> None:
551
554
  candidate = (base / rel) if base is not None else rel
552
555
  add_path(candidate)
553
556
  if candidate.suffix != ".tac":
@@ -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
  """
@@ -10,7 +10,7 @@ Provides:
10
10
  """
11
11
 
12
12
  import logging
13
- from typing import Any
13
+ from typing import Any, Dict, Optional
14
14
 
15
15
  logger = logging.getLogger(__name__)
16
16
 
@@ -23,7 +23,7 @@ class StatePrimitive:
23
23
  progress, accumulate results, and coordinate between agents.
24
24
  """
25
25
 
26
- def __init__(self, state_schema: dict[str, Any] | None = None):
26
+ def __init__(self, state_schema: Optional[Dict[str, Any]] = None):
27
27
  """
28
28
  Initialize state storage.
29
29
 
@@ -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}')"
@@ -17,7 +17,7 @@ import tempfile
17
17
  import time
18
18
  import uuid
19
19
  from pathlib import Path
20
- from typing import Any, Callable, Dict, List, Optional
20
+ from typing import Any, Callable, Dict, List, Optional, Tuple
21
21
 
22
22
  from .config import SandboxConfig
23
23
  from .docker_manager import (
@@ -523,6 +523,11 @@ class ContainerRunner:
523
523
  llm_backend_config=llm_backend_config,
524
524
  )
525
525
  finally:
526
+ try:
527
+ await broker_server.aclose()
528
+ broker_server = None
529
+ except Exception:
530
+ logger.debug("[BROKER] Failed to close broker server", exc_info=True)
526
531
  # Cancel broker task when container finishes
527
532
  broker_task.cancel()
528
533
  try:
@@ -589,7 +594,7 @@ class ContainerRunner:
589
594
  """
590
595
  broker_transport = (self.config.broker_transport or "stdio").lower()
591
596
 
592
- stdio_request_prefix: str | None = None
597
+ stdio_request_prefix: Optional[str] = None
593
598
  if broker_transport == "stdio":
594
599
  from tactus.broker.server import OpenAIChatBackend
595
600
  from tactus.broker.server import HostToolRegistry
@@ -950,9 +955,9 @@ class ContainerRunner:
950
955
  )
951
956
  logger.debug("[SANDBOX] Spawned container process pid=%s", process.pid)
952
957
 
953
- stdout_task: asyncio.Task[None] | None = None
954
- stderr_task: asyncio.Task[None] | None = None
955
- wait_task: asyncio.Task[int] | None = None
958
+ stdout_task: Optional[asyncio.Task[None]] = None
959
+ stderr_task: Optional[asyncio.Task[None]] = None
960
+ wait_task: Optional[asyncio.Task[int]] = None
956
961
 
957
962
  try:
958
963
  assert process.stdin is not None
@@ -1159,7 +1164,7 @@ class ContainerRunner:
1159
1164
  return
1160
1165
 
1161
1166
  # Rich/terminal: parse our container log format and re-emit.
1162
- current: tuple[str, int, list[str]] | None = None # (logger_name, levelno, lines)
1167
+ current: Optional[Tuple[str, int, List[str]]] = None # (logger_name, levelno, lines)
1163
1168
 
1164
1169
  def flush_current() -> None:
1165
1170
  nonlocal current