tactus 0.31.0__py3-none-any.whl → 0.34.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tactus/__init__.py +1 -1
- tactus/adapters/__init__.py +18 -1
- tactus/adapters/broker_log.py +127 -34
- tactus/adapters/channels/__init__.py +153 -0
- tactus/adapters/channels/base.py +174 -0
- tactus/adapters/channels/broker.py +179 -0
- tactus/adapters/channels/cli.py +448 -0
- tactus/adapters/channels/host.py +225 -0
- tactus/adapters/channels/ipc.py +297 -0
- tactus/adapters/channels/sse.py +305 -0
- tactus/adapters/cli_hitl.py +223 -1
- tactus/adapters/control_loop.py +879 -0
- tactus/adapters/file_storage.py +35 -2
- tactus/adapters/ide_log.py +7 -1
- tactus/backends/http_backend.py +0 -1
- tactus/broker/client.py +31 -1
- tactus/broker/server.py +416 -92
- tactus/cli/app.py +270 -7
- tactus/cli/control.py +393 -0
- tactus/core/config_manager.py +33 -6
- tactus/core/dsl_stubs.py +102 -18
- tactus/core/execution_context.py +265 -8
- tactus/core/lua_sandbox.py +8 -9
- tactus/core/registry.py +19 -2
- tactus/core/runtime.py +235 -27
- tactus/docker/Dockerfile.pypi +49 -0
- tactus/docs/__init__.py +33 -0
- tactus/docs/extractor.py +326 -0
- tactus/docs/html_renderer.py +72 -0
- tactus/docs/models.py +121 -0
- tactus/docs/templates/base.html +204 -0
- tactus/docs/templates/index.html +58 -0
- tactus/docs/templates/module.html +96 -0
- tactus/dspy/agent.py +403 -22
- tactus/dspy/broker_lm.py +57 -6
- tactus/dspy/config.py +14 -3
- tactus/dspy/history.py +2 -1
- tactus/dspy/module.py +136 -11
- tactus/dspy/signature.py +0 -1
- tactus/ide/config_server.py +536 -0
- tactus/ide/server.py +345 -21
- tactus/primitives/human.py +619 -47
- tactus/primitives/system.py +0 -1
- tactus/protocols/__init__.py +25 -0
- tactus/protocols/control.py +427 -0
- tactus/protocols/notification.py +207 -0
- tactus/sandbox/container_runner.py +79 -11
- tactus/sandbox/docker_manager.py +23 -0
- tactus/sandbox/entrypoint.py +26 -0
- tactus/sandbox/protocol.py +3 -0
- tactus/stdlib/README.md +77 -0
- tactus/stdlib/__init__.py +27 -1
- tactus/stdlib/classify/__init__.py +165 -0
- tactus/stdlib/classify/classify.spec.tac +195 -0
- tactus/stdlib/classify/classify.tac +257 -0
- tactus/stdlib/classify/fuzzy.py +282 -0
- tactus/stdlib/classify/llm.py +319 -0
- tactus/stdlib/classify/primitive.py +287 -0
- tactus/stdlib/core/__init__.py +57 -0
- tactus/stdlib/core/base.py +320 -0
- tactus/stdlib/core/confidence.py +211 -0
- tactus/stdlib/core/models.py +161 -0
- tactus/stdlib/core/retry.py +171 -0
- tactus/stdlib/core/validation.py +274 -0
- tactus/stdlib/extract/__init__.py +125 -0
- tactus/stdlib/extract/llm.py +330 -0
- tactus/stdlib/extract/primitive.py +256 -0
- tactus/stdlib/tac/tactus/classify/base.tac +51 -0
- tactus/stdlib/tac/tactus/classify/fuzzy.tac +87 -0
- tactus/stdlib/tac/tactus/classify/index.md +77 -0
- tactus/stdlib/tac/tactus/classify/init.tac +29 -0
- tactus/stdlib/tac/tactus/classify/llm.tac +150 -0
- tactus/stdlib/tac/tactus/classify.spec.tac +191 -0
- tactus/stdlib/tac/tactus/extract/base.tac +138 -0
- tactus/stdlib/tac/tactus/extract/index.md +96 -0
- tactus/stdlib/tac/tactus/extract/init.tac +27 -0
- tactus/stdlib/tac/tactus/extract/llm.tac +201 -0
- tactus/stdlib/tac/tactus/extract.spec.tac +153 -0
- tactus/stdlib/tac/tactus/generate/base.tac +142 -0
- tactus/stdlib/tac/tactus/generate/index.md +195 -0
- tactus/stdlib/tac/tactus/generate/init.tac +28 -0
- tactus/stdlib/tac/tactus/generate/llm.tac +169 -0
- tactus/stdlib/tac/tactus/generate.spec.tac +210 -0
- tactus/testing/behave_integration.py +171 -7
- tactus/testing/context.py +0 -1
- tactus/testing/evaluation_runner.py +0 -1
- tactus/testing/gherkin_parser.py +0 -1
- tactus/testing/mock_hitl.py +0 -1
- tactus/testing/mock_tools.py +0 -1
- tactus/testing/models.py +0 -1
- tactus/testing/steps/builtin.py +0 -1
- tactus/testing/steps/custom.py +81 -22
- tactus/testing/steps/registry.py +0 -1
- tactus/testing/test_runner.py +7 -1
- tactus/validation/semantic_visitor.py +11 -5
- tactus/validation/validator.py +0 -1
- {tactus-0.31.0.dist-info → tactus-0.34.1.dist-info}/METADATA +16 -2
- {tactus-0.31.0.dist-info → tactus-0.34.1.dist-info}/RECORD +101 -49
- {tactus-0.31.0.dist-info → tactus-0.34.1.dist-info}/WHEEL +0 -0
- {tactus-0.31.0.dist-info → tactus-0.34.1.dist-info}/entry_points.txt +0 -0
- {tactus-0.31.0.dist-info → tactus-0.34.1.dist-info}/licenses/LICENSE +0 -0
tactus/core/execution_context.py
CHANGED
|
@@ -21,6 +21,7 @@ from tactus.protocols.models import (
|
|
|
21
21
|
SourceLocation,
|
|
22
22
|
ExecutionRun,
|
|
23
23
|
)
|
|
24
|
+
from tactus.core.exceptions import ProcedureWaitingForHuman
|
|
24
25
|
|
|
25
26
|
logger = logging.getLogger(__name__)
|
|
26
27
|
|
|
@@ -153,9 +154,20 @@ class BaseExecutionContext(ExecutionContext):
|
|
|
153
154
|
# Lua sandbox reference for debug.getinfo access
|
|
154
155
|
self.lua_sandbox: Optional[Any] = None
|
|
155
156
|
|
|
157
|
+
# Rich metadata for HITL notifications
|
|
158
|
+
self.procedure_name: str = procedure_id # Use procedure_id as default name
|
|
159
|
+
self.invocation_id: str = str(uuid.uuid4())
|
|
160
|
+
self._started_at: datetime = datetime.now(timezone.utc)
|
|
161
|
+
self._input_data: Any = None
|
|
162
|
+
|
|
156
163
|
# Load procedure metadata (contains execution_log and replay_index)
|
|
157
164
|
self.metadata = self.storage.load_procedure_metadata(procedure_id)
|
|
158
165
|
|
|
166
|
+
# CRITICAL: Reset replay_index to 0 when starting a new execution
|
|
167
|
+
# The replay_index tracks our position when replaying the execution_log
|
|
168
|
+
# It must start at 0 for each new run, even though it was incremented during the previous run
|
|
169
|
+
self.metadata.replay_index = 0
|
|
170
|
+
|
|
159
171
|
def set_run_id(self, run_id: str) -> None:
|
|
160
172
|
"""Set the run_id for subsequent checkpoints in this execution."""
|
|
161
173
|
self.current_run_id = run_id
|
|
@@ -175,6 +187,21 @@ class BaseExecutionContext(ExecutionContext):
|
|
|
175
187
|
"""Store reference to Lua sandbox for debug.getinfo access."""
|
|
176
188
|
self.lua_sandbox = lua_sandbox
|
|
177
189
|
|
|
190
|
+
def set_procedure_metadata(
|
|
191
|
+
self, procedure_name: Optional[str] = None, input_data: Any = None
|
|
192
|
+
) -> None:
|
|
193
|
+
"""
|
|
194
|
+
Set rich metadata for HITL notifications.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
procedure_name: Human-readable name for the procedure
|
|
198
|
+
input_data: Input data passed to the procedure
|
|
199
|
+
"""
|
|
200
|
+
if procedure_name is not None:
|
|
201
|
+
self.procedure_name = procedure_name
|
|
202
|
+
if input_data is not None:
|
|
203
|
+
self._input_data = input_data
|
|
204
|
+
|
|
178
205
|
def checkpoint(
|
|
179
206
|
self,
|
|
180
207
|
fn: Callable[[], Any],
|
|
@@ -187,17 +214,50 @@ class BaseExecutionContext(ExecutionContext):
|
|
|
187
214
|
On replay, returns cached result from execution log.
|
|
188
215
|
On first execution, runs fn(), records in log, and returns result.
|
|
189
216
|
"""
|
|
190
|
-
logger.
|
|
191
|
-
f"[CHECKPOINT] checkpoint() called, type={checkpoint_type},
|
|
217
|
+
logger.info(
|
|
218
|
+
f"[CHECKPOINT] checkpoint() called, type={checkpoint_type}, position={self.metadata.replay_index}, "
|
|
219
|
+
f"current_run_id={self.current_run_id}, has_log_handler={self.log_handler is not None}"
|
|
192
220
|
)
|
|
193
221
|
current_position = self.metadata.replay_index
|
|
194
222
|
|
|
195
223
|
# Check if we're in replay mode (checkpoint exists at this position)
|
|
196
224
|
if current_position < len(self.metadata.execution_log):
|
|
197
|
-
# Replay mode: return cached result
|
|
198
225
|
entry = self.metadata.execution_log[current_position]
|
|
199
|
-
|
|
200
|
-
|
|
226
|
+
logger.info(
|
|
227
|
+
f"[CHECKPOINT] Found existing checkpoint at position {current_position}: "
|
|
228
|
+
f"type={entry.type}, run_id={entry.run_id}, result_type={type(entry.result).__name__}"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
# CRITICAL: Only replay checkpoints from the CURRENT run
|
|
232
|
+
# Each new run should execute fresh, not use cached results from previous runs
|
|
233
|
+
if entry.run_id != self.current_run_id:
|
|
234
|
+
logger.info(
|
|
235
|
+
f"[CHECKPOINT] Checkpoint is from DIFFERENT run "
|
|
236
|
+
f"(checkpoint run_id={entry.run_id}, current run_id={self.current_run_id}), "
|
|
237
|
+
f"executing fresh (NOT replaying)"
|
|
238
|
+
)
|
|
239
|
+
# Fall through to execute mode - this is a new run
|
|
240
|
+
# Special case: HITL checkpoints may have result=None if saved before response arrived
|
|
241
|
+
# In this case, re-execute to check for cached response from control loop
|
|
242
|
+
elif entry.result is None and checkpoint_type.startswith("hitl_"):
|
|
243
|
+
logger.info(
|
|
244
|
+
f"[CHECKPOINT] HITL checkpoint at position {current_position} has no result, "
|
|
245
|
+
f"re-executing to check for cached response"
|
|
246
|
+
)
|
|
247
|
+
# Fall through to execute mode - will check for cached response
|
|
248
|
+
else:
|
|
249
|
+
# Normal replay: return cached result from CURRENT run
|
|
250
|
+
self.metadata.replay_index += 1
|
|
251
|
+
logger.info(
|
|
252
|
+
f"[CHECKPOINT] REPLAYING checkpoint at position {current_position}, "
|
|
253
|
+
f"type={entry.type}, run_id={entry.run_id}, returning cached result"
|
|
254
|
+
)
|
|
255
|
+
return entry.result
|
|
256
|
+
else:
|
|
257
|
+
logger.info(
|
|
258
|
+
f"[CHECKPOINT] No checkpoint at position {current_position} "
|
|
259
|
+
f"(only {len(self.metadata.execution_log)} checkpoints exist), executing fresh"
|
|
260
|
+
)
|
|
201
261
|
|
|
202
262
|
# Execute mode: run function with checkpoint scope tracking
|
|
203
263
|
old_checkpoint_flag = self._inside_checkpoint
|
|
@@ -239,12 +299,56 @@ class BaseExecutionContext(ExecutionContext):
|
|
|
239
299
|
self.metadata.state.copy() if hasattr(self.metadata, "state") else None
|
|
240
300
|
),
|
|
241
301
|
)
|
|
302
|
+
except ProcedureWaitingForHuman:
|
|
303
|
+
# CRITICAL: For HITL checkpoints, we need to save the checkpoint BEFORE exiting
|
|
304
|
+
# This enables transparent resume - on restart, we'll have a checkpoint at this position
|
|
305
|
+
# with result=None, and the control loop will check for cached responses
|
|
306
|
+
duration_ms = (time.time() - start_time) * 1000
|
|
307
|
+
entry = CheckpointEntry(
|
|
308
|
+
position=current_position,
|
|
309
|
+
type=checkpoint_type,
|
|
310
|
+
result=None, # Will be filled in when response arrives
|
|
311
|
+
timestamp=datetime.now(timezone.utc),
|
|
312
|
+
duration_ms=duration_ms,
|
|
313
|
+
run_id=self.current_run_id,
|
|
314
|
+
source_location=source_location,
|
|
315
|
+
captured_vars=(
|
|
316
|
+
self.metadata.state.copy() if hasattr(self.metadata, "state") else None
|
|
317
|
+
),
|
|
318
|
+
)
|
|
319
|
+
# Only append if checkpoint doesn't already exist (from previous failed attempt)
|
|
320
|
+
if current_position < len(self.metadata.execution_log):
|
|
321
|
+
# Checkpoint already exists - update it
|
|
322
|
+
logger.debug(
|
|
323
|
+
f"[CHECKPOINT] Updating existing HITL checkpoint at position {current_position} before exit"
|
|
324
|
+
)
|
|
325
|
+
self.metadata.execution_log[current_position] = entry
|
|
326
|
+
else:
|
|
327
|
+
# New checkpoint - append and increment
|
|
328
|
+
logger.debug(
|
|
329
|
+
f"[CHECKPOINT] Creating new HITL checkpoint at position {current_position} before exit"
|
|
330
|
+
)
|
|
331
|
+
self.metadata.execution_log.append(entry)
|
|
332
|
+
self.metadata.replay_index += 1
|
|
333
|
+
|
|
334
|
+
self.storage.save_procedure_metadata(self.procedure_id, self.metadata)
|
|
335
|
+
# Restore checkpoint flag and re-raise
|
|
336
|
+
self._inside_checkpoint = old_checkpoint_flag
|
|
337
|
+
raise
|
|
242
338
|
finally:
|
|
243
339
|
# Always restore checkpoint flag, even if fn() raises
|
|
244
340
|
self._inside_checkpoint = old_checkpoint_flag
|
|
245
341
|
|
|
246
|
-
# Add to execution log
|
|
247
|
-
self.metadata.execution_log
|
|
342
|
+
# Add to execution log (or update if checkpoint already exists from HITL exit)
|
|
343
|
+
if current_position < len(self.metadata.execution_log):
|
|
344
|
+
# Checkpoint already exists (saved during HITL exit) - update it with the result
|
|
345
|
+
logger.debug(
|
|
346
|
+
f"[CHECKPOINT] Updating existing HITL checkpoint at position {current_position} with result"
|
|
347
|
+
)
|
|
348
|
+
self.metadata.execution_log[current_position] = entry
|
|
349
|
+
else:
|
|
350
|
+
# New checkpoint - append to log
|
|
351
|
+
self.metadata.execution_log.append(entry)
|
|
248
352
|
self.metadata.replay_index += 1
|
|
249
353
|
|
|
250
354
|
# Emit checkpoint created event if we have a log handler
|
|
@@ -298,8 +402,14 @@ class BaseExecutionContext(ExecutionContext):
|
|
|
298
402
|
|
|
299
403
|
Delegates to the HITLHandler protocol implementation.
|
|
300
404
|
"""
|
|
405
|
+
logger.debug(
|
|
406
|
+
f"[HITL] wait_for_human called: type={request_type}, message={message[:50] if message else 'None'}, hitl_handler={self.hitl}"
|
|
407
|
+
)
|
|
301
408
|
if not self.hitl:
|
|
302
409
|
# No HITL handler - return default immediately
|
|
410
|
+
logger.warning(
|
|
411
|
+
f"[HITL] No HITL handler configured - returning default value: {default_value}"
|
|
412
|
+
)
|
|
303
413
|
return HITLResponse(
|
|
304
414
|
value=default_value, responded_at=datetime.now(timezone.utc), timed_out=True
|
|
305
415
|
)
|
|
@@ -315,7 +425,8 @@ class BaseExecutionContext(ExecutionContext):
|
|
|
315
425
|
)
|
|
316
426
|
|
|
317
427
|
# Delegate to HITL handler (may raise ProcedureWaitingForHuman)
|
|
318
|
-
|
|
428
|
+
# Pass self (execution_context) for deterministic request ID generation
|
|
429
|
+
return self.hitl.request_interaction(self.procedure_id, request, execution_context=self)
|
|
319
430
|
|
|
320
431
|
def sleep(self, seconds: int) -> None:
|
|
321
432
|
"""
|
|
@@ -457,6 +568,152 @@ class BaseExecutionContext(ExecutionContext):
|
|
|
457
568
|
|
|
458
569
|
return run_id
|
|
459
570
|
|
|
571
|
+
def get_subject(self) -> Optional[str]:
|
|
572
|
+
"""
|
|
573
|
+
Return a human-readable subject line for this execution.
|
|
574
|
+
|
|
575
|
+
Returns:
|
|
576
|
+
Subject line combining procedure name and current checkpoint position
|
|
577
|
+
"""
|
|
578
|
+
checkpoint_pos = self.next_position()
|
|
579
|
+
if self.procedure_name:
|
|
580
|
+
return f"{self.procedure_name} (checkpoint {checkpoint_pos})"
|
|
581
|
+
return f"Procedure {self.procedure_id} (checkpoint {checkpoint_pos})"
|
|
582
|
+
|
|
583
|
+
def get_started_at(self) -> Optional[datetime]:
|
|
584
|
+
"""
|
|
585
|
+
Return when this execution started.
|
|
586
|
+
|
|
587
|
+
Returns:
|
|
588
|
+
Timestamp when execution context was created
|
|
589
|
+
"""
|
|
590
|
+
return self._started_at
|
|
591
|
+
|
|
592
|
+
def get_input_summary(self) -> Optional[Dict[str, Any]]:
|
|
593
|
+
"""
|
|
594
|
+
Return a summary of the initial input to this procedure.
|
|
595
|
+
|
|
596
|
+
Returns:
|
|
597
|
+
Dict of input data, or None if no input
|
|
598
|
+
"""
|
|
599
|
+
if self._input_data is None:
|
|
600
|
+
return None
|
|
601
|
+
|
|
602
|
+
# If input_data is already a dict, return it
|
|
603
|
+
if isinstance(self._input_data, dict):
|
|
604
|
+
return self._input_data
|
|
605
|
+
|
|
606
|
+
# Otherwise wrap it in a dict
|
|
607
|
+
return {"value": self._input_data}
|
|
608
|
+
|
|
609
|
+
def get_conversation_history(self) -> Optional[List[Dict]]:
|
|
610
|
+
"""
|
|
611
|
+
Return conversation history if available.
|
|
612
|
+
|
|
613
|
+
Returns:
|
|
614
|
+
List of conversation messages, or None if not tracked
|
|
615
|
+
"""
|
|
616
|
+
# For now, return None - could be extended to track agent conversations
|
|
617
|
+
# in future implementations
|
|
618
|
+
return None
|
|
619
|
+
|
|
620
|
+
def get_prior_control_interactions(self) -> Optional[List[Dict]]:
|
|
621
|
+
"""
|
|
622
|
+
Return list of prior HITL interactions in this execution.
|
|
623
|
+
|
|
624
|
+
Returns:
|
|
625
|
+
List of HITL checkpoint entries from execution log
|
|
626
|
+
"""
|
|
627
|
+
if not self.metadata or not self.metadata.execution_log:
|
|
628
|
+
return None
|
|
629
|
+
|
|
630
|
+
# Filter execution log for HITL checkpoints
|
|
631
|
+
hitl_checkpoints = [
|
|
632
|
+
{
|
|
633
|
+
"position": entry.position,
|
|
634
|
+
"type": entry.type,
|
|
635
|
+
"timestamp": entry.timestamp.isoformat() if entry.timestamp else None,
|
|
636
|
+
"duration_ms": entry.duration_ms,
|
|
637
|
+
}
|
|
638
|
+
for entry in self.metadata.execution_log
|
|
639
|
+
if entry.type.startswith("hitl_")
|
|
640
|
+
]
|
|
641
|
+
|
|
642
|
+
return hitl_checkpoints if hitl_checkpoints else None
|
|
643
|
+
|
|
644
|
+
def get_lua_source_line(self) -> Optional[int]:
|
|
645
|
+
"""
|
|
646
|
+
Get the current source line from Lua debug.getinfo.
|
|
647
|
+
|
|
648
|
+
Returns:
|
|
649
|
+
Line number or None if unavailable
|
|
650
|
+
"""
|
|
651
|
+
if not self.lua_sandbox:
|
|
652
|
+
return None
|
|
653
|
+
|
|
654
|
+
try:
|
|
655
|
+
# Access Lua debug module to get current line
|
|
656
|
+
debug_mod = self.lua_sandbox.globals().debug
|
|
657
|
+
if debug_mod and hasattr(debug_mod, "getinfo"):
|
|
658
|
+
# getinfo(2) gets info about the calling function
|
|
659
|
+
# We need to go up the stack to find the user's code
|
|
660
|
+
for level in range(2, 10):
|
|
661
|
+
try:
|
|
662
|
+
info = debug_mod.getinfo(level, "Sl")
|
|
663
|
+
if info:
|
|
664
|
+
line = info.get("currentline")
|
|
665
|
+
source = info.get("source", "")
|
|
666
|
+
# Skip internal sources (start with @)
|
|
667
|
+
if line and line > 0 and not source.startswith("@"):
|
|
668
|
+
return int(line)
|
|
669
|
+
except Exception:
|
|
670
|
+
break
|
|
671
|
+
except Exception as e:
|
|
672
|
+
logger.debug(f"Could not get Lua source line: {e}")
|
|
673
|
+
|
|
674
|
+
return None
|
|
675
|
+
|
|
676
|
+
def get_runtime_context(self) -> Dict[str, Any]:
|
|
677
|
+
"""
|
|
678
|
+
Build RuntimeContext dict for HITL requests.
|
|
679
|
+
|
|
680
|
+
Captures source location, execution position, elapsed time, and backtrace.
|
|
681
|
+
|
|
682
|
+
Returns:
|
|
683
|
+
Dict with runtime context fields
|
|
684
|
+
"""
|
|
685
|
+
# Calculate elapsed time
|
|
686
|
+
elapsed = 0.0
|
|
687
|
+
if self._started_at:
|
|
688
|
+
elapsed = (datetime.now(timezone.utc) - self._started_at).total_seconds()
|
|
689
|
+
|
|
690
|
+
# Get current source location
|
|
691
|
+
source_line = self.get_lua_source_line()
|
|
692
|
+
|
|
693
|
+
# Build backtrace from execution log
|
|
694
|
+
backtrace = []
|
|
695
|
+
if self.metadata and self.metadata.execution_log:
|
|
696
|
+
for entry in self.metadata.execution_log:
|
|
697
|
+
bt_entry = {
|
|
698
|
+
"checkpoint_type": entry.type,
|
|
699
|
+
"duration_ms": entry.duration_ms,
|
|
700
|
+
}
|
|
701
|
+
if entry.source_location:
|
|
702
|
+
bt_entry["line"] = entry.source_location.line
|
|
703
|
+
bt_entry["function_name"] = entry.source_location.function
|
|
704
|
+
backtrace.append(bt_entry)
|
|
705
|
+
|
|
706
|
+
return {
|
|
707
|
+
"source_line": source_line,
|
|
708
|
+
"source_file": self.current_tac_file,
|
|
709
|
+
"checkpoint_position": self.next_position(),
|
|
710
|
+
"procedure_name": self.procedure_name,
|
|
711
|
+
"invocation_id": self.invocation_id,
|
|
712
|
+
"started_at": self._started_at.isoformat() if self._started_at else None,
|
|
713
|
+
"elapsed_seconds": elapsed,
|
|
714
|
+
"backtrace": backtrace,
|
|
715
|
+
}
|
|
716
|
+
|
|
460
717
|
|
|
461
718
|
class InMemoryExecutionContext(BaseExecutionContext):
|
|
462
719
|
"""
|
tactus/core/lua_sandbox.py
CHANGED
|
@@ -125,16 +125,14 @@ class LuaSandbox:
|
|
|
125
125
|
# Whitelist only safe debug functions for source location tracking
|
|
126
126
|
# Keep debug.getinfo but remove dangerous debug functions
|
|
127
127
|
if "debug" in lua_globals:
|
|
128
|
-
self.lua.execute(
|
|
129
|
-
"""
|
|
128
|
+
self.lua.execute("""
|
|
130
129
|
if debug then
|
|
131
130
|
local safe_debug = {
|
|
132
131
|
getinfo = debug.getinfo
|
|
133
132
|
}
|
|
134
133
|
debug = safe_debug
|
|
135
134
|
end
|
|
136
|
-
"""
|
|
137
|
-
)
|
|
135
|
+
""")
|
|
138
136
|
logger.debug("Replaced debug module with safe_debug (only getinfo allowed)")
|
|
139
137
|
|
|
140
138
|
def _setup_safe_require(self):
|
|
@@ -158,11 +156,14 @@ class LuaSandbox:
|
|
|
158
156
|
# Build search paths:
|
|
159
157
|
# 1. User's project directory (existing behavior)
|
|
160
158
|
# 2. Tactus stdlib .tac files
|
|
159
|
+
# Both single-file modules (?.tac) and directory modules (?/init.tac) are supported
|
|
161
160
|
user_path = os.path.join(self.base_path, "?.tac")
|
|
161
|
+
user_init_path = os.path.join(self.base_path, "?", "init.tac")
|
|
162
162
|
stdlib_path = os.path.join(stdlib_tac_path, "?.tac")
|
|
163
|
+
stdlib_init_path = os.path.join(stdlib_tac_path, "?", "init.tac")
|
|
163
164
|
|
|
164
165
|
# Normalize backslashes for cross-platform compatibility
|
|
165
|
-
paths = [user_path, stdlib_path]
|
|
166
|
+
paths = [user_path, user_init_path, stdlib_path, stdlib_init_path]
|
|
166
167
|
paths = [p.replace("\\", "/") for p in paths]
|
|
167
168
|
|
|
168
169
|
# Join with Lua's path separator (semicolon)
|
|
@@ -202,8 +203,7 @@ class LuaSandbox:
|
|
|
202
203
|
|
|
203
204
|
# Add to package.loaders (Lua 5.1) or package.searchers (Lua 5.2+)
|
|
204
205
|
# Lupa uses LuaJIT which follows Lua 5.1 conventions
|
|
205
|
-
self.lua.execute(
|
|
206
|
-
"""
|
|
206
|
+
self.lua.execute("""
|
|
207
207
|
-- Add Python stdlib loader to package.loaders
|
|
208
208
|
-- Insert after the preload loader but before path loader
|
|
209
209
|
local loaders = package.loaders or package.searchers
|
|
@@ -221,8 +221,7 @@ class LuaSandbox:
|
|
|
221
221
|
-- Insert at position 2 (after preload, before path)
|
|
222
222
|
table.insert(loaders, 2, python_searcher)
|
|
223
223
|
end
|
|
224
|
-
"""
|
|
225
|
-
)
|
|
224
|
+
""")
|
|
226
225
|
|
|
227
226
|
logger.debug("Python stdlib loader installed")
|
|
228
227
|
|
tactus/core/registry.py
CHANGED
|
@@ -155,6 +155,7 @@ class ProcedureRegistry(BaseModel):
|
|
|
155
155
|
|
|
156
156
|
# Gherkin BDD Testing
|
|
157
157
|
gherkin_specifications: Optional[str] = None # Raw Gherkin text
|
|
158
|
+
specs_from_references: list[str] = Field(default_factory=list) # External spec file paths
|
|
158
159
|
custom_steps: dict[str, Any] = Field(default_factory=dict) # step_text -> lua_function
|
|
159
160
|
evaluation_config: dict[str, Any] = Field(default_factory=dict) # runs, parallel, etc.
|
|
160
161
|
|
|
@@ -413,6 +414,17 @@ class RegistryBuilder:
|
|
|
413
414
|
"""Register Gherkin BDD specifications."""
|
|
414
415
|
self.registry.gherkin_specifications = gherkin_text
|
|
415
416
|
|
|
417
|
+
def register_specs_from(self, file_path: str) -> None:
|
|
418
|
+
"""Register a reference to external specifications.
|
|
419
|
+
|
|
420
|
+
Stores the path for lazy loading. Actual spec content
|
|
421
|
+
is loaded during test execution, not parse time.
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
file_path: Path to .spec.tac file or module name
|
|
425
|
+
"""
|
|
426
|
+
self.registry.specs_from_references.append(file_path)
|
|
427
|
+
|
|
416
428
|
def register_custom_step(self, step_text: str, lua_function: Any) -> None:
|
|
417
429
|
"""Register a custom step definition."""
|
|
418
430
|
self.registry.custom_steps[step_text] = lua_function
|
|
@@ -479,11 +491,16 @@ class RegistryBuilder:
|
|
|
479
491
|
)
|
|
480
492
|
|
|
481
493
|
# Warnings for missing specifications
|
|
482
|
-
|
|
494
|
+
has_specs = (
|
|
495
|
+
self.registry.specifications
|
|
496
|
+
or self.registry.gherkin_specifications
|
|
497
|
+
or self.registry.specs_from_references
|
|
498
|
+
)
|
|
499
|
+
if not has_specs:
|
|
483
500
|
warnings.append(
|
|
484
501
|
ValidationMessage(
|
|
485
502
|
level="warning",
|
|
486
|
-
message=
|
|
503
|
+
message='No specifications defined - consider adding BDD tests using Specification([[...]]) or Specification { from = "path" }',
|
|
487
504
|
)
|
|
488
505
|
)
|
|
489
506
|
|