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/__init__.py +1 -1
- tactus/adapters/channels/host.py +24 -18
- tactus/adapters/channels/sse.py +11 -5
- tactus/adapters/control_loop.py +44 -30
- tactus/core/execution_context.py +9 -4
- tactus/core/lua_sandbox.py +42 -34
- tactus/core/message_history_manager.py +50 -27
- tactus/core/output_validator.py +63 -49
- tactus/core/runtime.py +26 -18
- tactus/ide/server.py +63 -33
- tactus/primitives/host.py +19 -16
- tactus/primitives/message_history.py +11 -14
- tactus/primitives/procedure.py +10 -7
- tactus/primitives/session.py +9 -9
- tactus/primitives/tool_handle.py +27 -24
- tactus/testing/mock_hitl.py +2 -2
- tactus/testing/models.py +2 -0
- tactus/utils/safe_libraries.py +2 -2
- {tactus-0.35.0.dist-info → tactus-0.36.0.dist-info}/METADATA +8 -1
- {tactus-0.35.0.dist-info → tactus-0.36.0.dist-info}/RECORD +23 -23
- {tactus-0.35.0.dist-info → tactus-0.36.0.dist-info}/WHEEL +0 -0
- {tactus-0.35.0.dist-info → tactus-0.36.0.dist-info}/entry_points.txt +0 -0
- {tactus-0.35.0.dist-info → tactus-0.36.0.dist-info}/licenses/LICENSE +0 -0
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
|
-
|
|
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 =
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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
|
-
|
|
40
|
+
return self._run_coro_in_thread(coroutine)
|
|
41
41
|
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
+
worker_thread = threading.Thread(target=run_in_thread)
|
|
58
|
+
worker_thread.start()
|
|
59
|
+
worker_thread.join()
|
|
57
60
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
tactus/primitives/procedure.py
CHANGED
|
@@ -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
|
|
334
|
-
|
|
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.
|
tactus/primitives/session.py
CHANGED
|
@@ -80,11 +80,7 @@ class SessionPrimitive:
|
|
|
80
80
|
if not self._has_session_context():
|
|
81
81
|
return
|
|
82
82
|
|
|
83
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
"""
|
tactus/primitives/tool_handle.py
CHANGED
|
@@ -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
|
-
|
|
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}')"
|
tactus/testing/mock_hitl.py
CHANGED
|
@@ -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.
|
|
74
|
+
responded_at=datetime.now(timezone.utc),
|
|
75
75
|
timed_out=False,
|
|
76
76
|
)
|
|
77
77
|
|
tactus/testing/models.py
CHANGED