tactus 0.31.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tactus/__init__.py +49 -0
- tactus/adapters/__init__.py +9 -0
- tactus/adapters/broker_log.py +76 -0
- tactus/adapters/cli_hitl.py +189 -0
- tactus/adapters/cli_log.py +223 -0
- tactus/adapters/cost_collector_log.py +56 -0
- tactus/adapters/file_storage.py +367 -0
- tactus/adapters/http_callback_log.py +109 -0
- tactus/adapters/ide_log.py +71 -0
- tactus/adapters/lua_tools.py +336 -0
- tactus/adapters/mcp.py +289 -0
- tactus/adapters/mcp_manager.py +196 -0
- tactus/adapters/memory.py +53 -0
- tactus/adapters/plugins.py +419 -0
- tactus/backends/http_backend.py +58 -0
- tactus/backends/model_backend.py +35 -0
- tactus/backends/pytorch_backend.py +110 -0
- tactus/broker/__init__.py +12 -0
- tactus/broker/client.py +247 -0
- tactus/broker/protocol.py +183 -0
- tactus/broker/server.py +1123 -0
- tactus/broker/stdio.py +12 -0
- tactus/cli/__init__.py +7 -0
- tactus/cli/app.py +2245 -0
- tactus/cli/commands/__init__.py +0 -0
- tactus/core/__init__.py +32 -0
- tactus/core/config_manager.py +790 -0
- tactus/core/dependencies/__init__.py +14 -0
- tactus/core/dependencies/registry.py +180 -0
- tactus/core/dsl_stubs.py +2117 -0
- tactus/core/exceptions.py +66 -0
- tactus/core/execution_context.py +480 -0
- tactus/core/lua_sandbox.py +508 -0
- tactus/core/message_history_manager.py +236 -0
- tactus/core/mocking.py +286 -0
- tactus/core/output_validator.py +291 -0
- tactus/core/registry.py +499 -0
- tactus/core/runtime.py +2907 -0
- tactus/core/template_resolver.py +142 -0
- tactus/core/yaml_parser.py +301 -0
- tactus/docker/Dockerfile +61 -0
- tactus/docker/entrypoint.sh +69 -0
- tactus/dspy/__init__.py +39 -0
- tactus/dspy/agent.py +1144 -0
- tactus/dspy/broker_lm.py +181 -0
- tactus/dspy/config.py +212 -0
- tactus/dspy/history.py +196 -0
- tactus/dspy/module.py +405 -0
- tactus/dspy/prediction.py +318 -0
- tactus/dspy/signature.py +185 -0
- tactus/formatting/__init__.py +7 -0
- tactus/formatting/formatter.py +437 -0
- tactus/ide/__init__.py +9 -0
- tactus/ide/coding_assistant.py +343 -0
- tactus/ide/server.py +2223 -0
- tactus/primitives/__init__.py +49 -0
- tactus/primitives/control.py +168 -0
- tactus/primitives/file.py +229 -0
- tactus/primitives/handles.py +378 -0
- tactus/primitives/host.py +94 -0
- tactus/primitives/human.py +342 -0
- tactus/primitives/json.py +189 -0
- tactus/primitives/log.py +187 -0
- tactus/primitives/message_history.py +157 -0
- tactus/primitives/model.py +163 -0
- tactus/primitives/procedure.py +564 -0
- tactus/primitives/procedure_callable.py +318 -0
- tactus/primitives/retry.py +155 -0
- tactus/primitives/session.py +152 -0
- tactus/primitives/state.py +182 -0
- tactus/primitives/step.py +209 -0
- tactus/primitives/system.py +93 -0
- tactus/primitives/tool.py +375 -0
- tactus/primitives/tool_handle.py +279 -0
- tactus/primitives/toolset.py +229 -0
- tactus/protocols/__init__.py +38 -0
- tactus/protocols/chat_recorder.py +81 -0
- tactus/protocols/config.py +97 -0
- tactus/protocols/cost.py +31 -0
- tactus/protocols/hitl.py +71 -0
- tactus/protocols/log_handler.py +27 -0
- tactus/protocols/models.py +355 -0
- tactus/protocols/result.py +33 -0
- tactus/protocols/storage.py +90 -0
- tactus/providers/__init__.py +13 -0
- tactus/providers/base.py +92 -0
- tactus/providers/bedrock.py +117 -0
- tactus/providers/google.py +105 -0
- tactus/providers/openai.py +98 -0
- tactus/sandbox/__init__.py +63 -0
- tactus/sandbox/config.py +171 -0
- tactus/sandbox/container_runner.py +1099 -0
- tactus/sandbox/docker_manager.py +433 -0
- tactus/sandbox/entrypoint.py +227 -0
- tactus/sandbox/protocol.py +213 -0
- tactus/stdlib/__init__.py +10 -0
- tactus/stdlib/io/__init__.py +13 -0
- tactus/stdlib/io/csv.py +88 -0
- tactus/stdlib/io/excel.py +136 -0
- tactus/stdlib/io/file.py +90 -0
- tactus/stdlib/io/fs.py +154 -0
- tactus/stdlib/io/hdf5.py +121 -0
- tactus/stdlib/io/json.py +109 -0
- tactus/stdlib/io/parquet.py +83 -0
- tactus/stdlib/io/tsv.py +88 -0
- tactus/stdlib/loader.py +274 -0
- tactus/stdlib/tac/tactus/tools/done.tac +33 -0
- tactus/stdlib/tac/tactus/tools/log.tac +50 -0
- tactus/testing/README.md +273 -0
- tactus/testing/__init__.py +61 -0
- tactus/testing/behave_integration.py +380 -0
- tactus/testing/context.py +486 -0
- tactus/testing/eval_models.py +114 -0
- tactus/testing/evaluation_runner.py +222 -0
- tactus/testing/evaluators.py +634 -0
- tactus/testing/events.py +94 -0
- tactus/testing/gherkin_parser.py +134 -0
- tactus/testing/mock_agent.py +315 -0
- tactus/testing/mock_dependencies.py +234 -0
- tactus/testing/mock_hitl.py +171 -0
- tactus/testing/mock_registry.py +168 -0
- tactus/testing/mock_tools.py +133 -0
- tactus/testing/models.py +115 -0
- tactus/testing/pydantic_eval_runner.py +508 -0
- tactus/testing/steps/__init__.py +13 -0
- tactus/testing/steps/builtin.py +902 -0
- tactus/testing/steps/custom.py +69 -0
- tactus/testing/steps/registry.py +68 -0
- tactus/testing/test_runner.py +489 -0
- tactus/tracing/__init__.py +5 -0
- tactus/tracing/trace_manager.py +417 -0
- tactus/utils/__init__.py +1 -0
- tactus/utils/cost_calculator.py +72 -0
- tactus/utils/model_pricing.py +132 -0
- tactus/utils/safe_file_library.py +502 -0
- tactus/utils/safe_libraries.py +234 -0
- tactus/validation/LuaLexerBase.py +66 -0
- tactus/validation/LuaParserBase.py +23 -0
- tactus/validation/README.md +224 -0
- tactus/validation/__init__.py +7 -0
- tactus/validation/error_listener.py +21 -0
- tactus/validation/generated/LuaLexer.interp +231 -0
- tactus/validation/generated/LuaLexer.py +5548 -0
- tactus/validation/generated/LuaLexer.tokens +124 -0
- tactus/validation/generated/LuaLexerBase.py +66 -0
- tactus/validation/generated/LuaParser.interp +173 -0
- tactus/validation/generated/LuaParser.py +6439 -0
- tactus/validation/generated/LuaParser.tokens +124 -0
- tactus/validation/generated/LuaParserBase.py +23 -0
- tactus/validation/generated/LuaParserVisitor.py +118 -0
- tactus/validation/generated/__init__.py +7 -0
- tactus/validation/grammar/LuaLexer.g4 +123 -0
- tactus/validation/grammar/LuaParser.g4 +178 -0
- tactus/validation/semantic_visitor.py +817 -0
- tactus/validation/validator.py +157 -0
- tactus-0.31.2.dist-info/METADATA +1809 -0
- tactus-0.31.2.dist-info/RECORD +160 -0
- tactus-0.31.2.dist-info/WHEEL +4 -0
- tactus-0.31.2.dist-info/entry_points.txt +2 -0
- tactus-0.31.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tactus exception classes.
|
|
3
|
+
|
|
4
|
+
All custom exceptions raised by the Tactus runtime.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TactusRuntimeError(Exception):
|
|
9
|
+
"""Base exception for all Tactus runtime errors."""
|
|
10
|
+
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ProcedureWaitingForHuman(Exception):
|
|
15
|
+
"""
|
|
16
|
+
Raised to exit workflow when waiting for human response.
|
|
17
|
+
|
|
18
|
+
In execution contexts that support exit-and-resume, this signals:
|
|
19
|
+
1. Update Procedure status to 'WAITING_FOR_HUMAN'
|
|
20
|
+
2. Save the pending message ID
|
|
21
|
+
3. Exit cleanly
|
|
22
|
+
4. Wait for resume trigger
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, procedure_id: str, pending_message_id: str):
|
|
26
|
+
self.procedure_id = procedure_id
|
|
27
|
+
self.pending_message_id = pending_message_id
|
|
28
|
+
super().__init__(
|
|
29
|
+
f"Procedure {procedure_id} waiting for human response to message {pending_message_id}"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ProcedureConfigError(Exception):
|
|
34
|
+
"""Raised when procedure configuration is invalid."""
|
|
35
|
+
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class LuaSandboxError(Exception):
|
|
40
|
+
"""Raised when Lua sandbox setup or execution fails."""
|
|
41
|
+
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class OutputValidationError(Exception):
|
|
46
|
+
"""Raised when workflow output doesn't match schema."""
|
|
47
|
+
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class StorageError(TactusRuntimeError):
|
|
52
|
+
"""Raised when storage backend operations fail."""
|
|
53
|
+
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class HITLError(TactusRuntimeError):
|
|
58
|
+
"""Raised when HITL handler operations fail."""
|
|
59
|
+
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ChatRecorderError(TactusRuntimeError):
|
|
64
|
+
"""Raised when chat recorder operations fail."""
|
|
65
|
+
|
|
66
|
+
pass
|
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Execution context abstraction for Tactus runtime.
|
|
3
|
+
|
|
4
|
+
Provides execution backend support with position-based checkpointing and HITL capabilities.
|
|
5
|
+
Uses pluggable storage and HITL handlers via protocols.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
from typing import Any, Optional, Callable, List, Dict
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
import logging
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
|
|
15
|
+
from tactus.protocols.storage import StorageBackend
|
|
16
|
+
from tactus.protocols.hitl import HITLHandler
|
|
17
|
+
from tactus.protocols.models import (
|
|
18
|
+
HITLRequest,
|
|
19
|
+
HITLResponse,
|
|
20
|
+
CheckpointEntry,
|
|
21
|
+
SourceLocation,
|
|
22
|
+
ExecutionRun,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ExecutionContext(ABC):
|
|
29
|
+
"""
|
|
30
|
+
Abstract execution context for procedure workflows.
|
|
31
|
+
|
|
32
|
+
Provides position-based checkpointing and HITL capabilities. Implementations
|
|
33
|
+
determine how to persist state and handle human interactions.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
def checkpoint(
|
|
38
|
+
self,
|
|
39
|
+
fn: Callable[[], Any],
|
|
40
|
+
checkpoint_type: str,
|
|
41
|
+
source_info: Optional[Dict[str, Any]] = None,
|
|
42
|
+
) -> Any:
|
|
43
|
+
"""
|
|
44
|
+
Execute fn with position-based checkpointing. On replay, return stored result.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
fn: Function to execute (should be deterministic)
|
|
48
|
+
checkpoint_type: Type of checkpoint (agent_turn, model_predict, procedure_call, etc.)
|
|
49
|
+
source_info: Optional dict with {file, line, function} for debugging
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Result of fn() on first execution, cached result from execution log on replay
|
|
53
|
+
"""
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
@abstractmethod
|
|
57
|
+
def wait_for_human(
|
|
58
|
+
self,
|
|
59
|
+
request_type: str,
|
|
60
|
+
message: str,
|
|
61
|
+
timeout_seconds: Optional[int],
|
|
62
|
+
default_value: Any,
|
|
63
|
+
options: Optional[List[dict]],
|
|
64
|
+
metadata: dict,
|
|
65
|
+
) -> HITLResponse:
|
|
66
|
+
"""
|
|
67
|
+
Suspend until human responds.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
request_type: 'approval', 'input', 'review', or 'escalation'
|
|
71
|
+
message: Message to display to human
|
|
72
|
+
timeout_seconds: Timeout in seconds, None = wait forever
|
|
73
|
+
default_value: Value to return on timeout
|
|
74
|
+
options: For review requests: [{label, type}, ...]
|
|
75
|
+
metadata: Additional context data
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
HITLResponse with value and timestamp
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
ProcedureWaitingForHuman: May exit to wait for resume
|
|
82
|
+
"""
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
@abstractmethod
|
|
86
|
+
def sleep(self, seconds: int) -> None:
|
|
87
|
+
"""
|
|
88
|
+
Sleep without consuming resources.
|
|
89
|
+
|
|
90
|
+
Different contexts may implement this differently.
|
|
91
|
+
"""
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
@abstractmethod
|
|
95
|
+
def checkpoint_clear_all(self) -> None:
|
|
96
|
+
"""Clear all checkpoints (execution log). Used for testing."""
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
@abstractmethod
|
|
100
|
+
def checkpoint_clear_after(self, position: int) -> None:
|
|
101
|
+
"""Clear checkpoint at position and all subsequent ones. Used for testing."""
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
@abstractmethod
|
|
105
|
+
def next_position(self) -> int:
|
|
106
|
+
"""Get the next checkpoint position."""
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class BaseExecutionContext(ExecutionContext):
|
|
111
|
+
"""
|
|
112
|
+
Base execution context using pluggable storage and HITL handlers.
|
|
113
|
+
|
|
114
|
+
Uses position-based checkpointing with execution log for replay.
|
|
115
|
+
This implementation works with any StorageBackend and HITLHandler,
|
|
116
|
+
making it suitable for various deployment scenarios (CLI, web, API, etc.).
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def __init__(
|
|
120
|
+
self,
|
|
121
|
+
procedure_id: str,
|
|
122
|
+
storage_backend: StorageBackend,
|
|
123
|
+
hitl_handler: Optional[HITLHandler] = None,
|
|
124
|
+
strict_determinism: bool = False,
|
|
125
|
+
log_handler=None,
|
|
126
|
+
):
|
|
127
|
+
"""
|
|
128
|
+
Initialize base execution context.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
procedure_id: ID of the running procedure
|
|
132
|
+
storage_backend: Storage backend for execution log and state
|
|
133
|
+
hitl_handler: Optional HITL handler for human interactions
|
|
134
|
+
strict_determinism: If True, raise errors for non-deterministic operations outside checkpoints
|
|
135
|
+
log_handler: Optional log handler for emitting events
|
|
136
|
+
"""
|
|
137
|
+
self.procedure_id = procedure_id
|
|
138
|
+
self.storage = storage_backend
|
|
139
|
+
self.hitl = hitl_handler
|
|
140
|
+
self.strict_determinism = strict_determinism
|
|
141
|
+
self.log_handler = log_handler
|
|
142
|
+
|
|
143
|
+
# Checkpoint scope tracking for determinism safety
|
|
144
|
+
self._inside_checkpoint = False
|
|
145
|
+
|
|
146
|
+
# Run ID tracking for distinguishing between different executions
|
|
147
|
+
self.current_run_id: Optional[str] = None
|
|
148
|
+
|
|
149
|
+
# .tac file tracking for accurate source locations
|
|
150
|
+
self.current_tac_file: Optional[str] = None
|
|
151
|
+
self.current_tac_content: Optional[str] = None
|
|
152
|
+
|
|
153
|
+
# Lua sandbox reference for debug.getinfo access
|
|
154
|
+
self.lua_sandbox: Optional[Any] = None
|
|
155
|
+
|
|
156
|
+
# Load procedure metadata (contains execution_log and replay_index)
|
|
157
|
+
self.metadata = self.storage.load_procedure_metadata(procedure_id)
|
|
158
|
+
|
|
159
|
+
def set_run_id(self, run_id: str) -> None:
|
|
160
|
+
"""Set the run_id for subsequent checkpoints in this execution."""
|
|
161
|
+
self.current_run_id = run_id
|
|
162
|
+
|
|
163
|
+
def set_tac_file(self, file_path: str, content: Optional[str] = None) -> None:
|
|
164
|
+
"""
|
|
165
|
+
Store the currently executing .tac file for accurate source location capture.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
file_path: Path to the .tac file being executed
|
|
169
|
+
content: Optional content of the .tac file (for code context)
|
|
170
|
+
"""
|
|
171
|
+
self.current_tac_file = file_path
|
|
172
|
+
self.current_tac_content = content
|
|
173
|
+
|
|
174
|
+
def set_lua_sandbox(self, lua_sandbox: Any) -> None:
|
|
175
|
+
"""Store reference to Lua sandbox for debug.getinfo access."""
|
|
176
|
+
self.lua_sandbox = lua_sandbox
|
|
177
|
+
|
|
178
|
+
def checkpoint(
|
|
179
|
+
self,
|
|
180
|
+
fn: Callable[[], Any],
|
|
181
|
+
checkpoint_type: str,
|
|
182
|
+
source_info: Optional[Dict[str, Any]] = None,
|
|
183
|
+
) -> Any:
|
|
184
|
+
"""
|
|
185
|
+
Execute fn with position-based checkpointing and source tracking.
|
|
186
|
+
|
|
187
|
+
On replay, returns cached result from execution log.
|
|
188
|
+
On first execution, runs fn(), records in log, and returns result.
|
|
189
|
+
"""
|
|
190
|
+
logger.debug(
|
|
191
|
+
f"[CHECKPOINT] checkpoint() called, type={checkpoint_type}, has_log_handler={self.log_handler is not None}"
|
|
192
|
+
)
|
|
193
|
+
current_position = self.metadata.replay_index
|
|
194
|
+
|
|
195
|
+
# Check if we're in replay mode (checkpoint exists at this position)
|
|
196
|
+
if current_position < len(self.metadata.execution_log):
|
|
197
|
+
# Replay mode: return cached result
|
|
198
|
+
entry = self.metadata.execution_log[current_position]
|
|
199
|
+
self.metadata.replay_index += 1
|
|
200
|
+
return entry.result
|
|
201
|
+
|
|
202
|
+
# Execute mode: run function with checkpoint scope tracking
|
|
203
|
+
old_checkpoint_flag = self._inside_checkpoint
|
|
204
|
+
self._inside_checkpoint = True
|
|
205
|
+
|
|
206
|
+
# Capture source location if provided
|
|
207
|
+
source_location = None
|
|
208
|
+
if source_info:
|
|
209
|
+
source_location = SourceLocation(
|
|
210
|
+
file=source_info["file"],
|
|
211
|
+
line=source_info["line"],
|
|
212
|
+
function=source_info.get("function"),
|
|
213
|
+
code_context=self._get_code_context(source_info["file"], source_info["line"]),
|
|
214
|
+
)
|
|
215
|
+
elif self.current_tac_file:
|
|
216
|
+
# Use .tac file context if no source_info provided
|
|
217
|
+
source_location = SourceLocation(
|
|
218
|
+
file=self.current_tac_file,
|
|
219
|
+
line=0, # Will be improved with Lua line tracking
|
|
220
|
+
function="unknown",
|
|
221
|
+
code_context=None, # Can be added later if needed
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
start_time = time.time()
|
|
226
|
+
result = fn()
|
|
227
|
+
duration_ms = (time.time() - start_time) * 1000
|
|
228
|
+
|
|
229
|
+
# Create checkpoint entry with source location and run_id (if available)
|
|
230
|
+
entry = CheckpointEntry(
|
|
231
|
+
position=current_position,
|
|
232
|
+
type=checkpoint_type,
|
|
233
|
+
result=result,
|
|
234
|
+
timestamp=datetime.now(timezone.utc),
|
|
235
|
+
duration_ms=duration_ms,
|
|
236
|
+
run_id=self.current_run_id, # Can be None for backward compatibility
|
|
237
|
+
source_location=source_location,
|
|
238
|
+
captured_vars=(
|
|
239
|
+
self.metadata.state.copy() if hasattr(self.metadata, "state") else None
|
|
240
|
+
),
|
|
241
|
+
)
|
|
242
|
+
finally:
|
|
243
|
+
# Always restore checkpoint flag, even if fn() raises
|
|
244
|
+
self._inside_checkpoint = old_checkpoint_flag
|
|
245
|
+
|
|
246
|
+
# Add to execution log
|
|
247
|
+
self.metadata.execution_log.append(entry)
|
|
248
|
+
self.metadata.replay_index += 1
|
|
249
|
+
|
|
250
|
+
# Emit checkpoint created event if we have a log handler
|
|
251
|
+
if self.log_handler:
|
|
252
|
+
try:
|
|
253
|
+
from tactus.protocols.models import CheckpointCreatedEvent
|
|
254
|
+
|
|
255
|
+
event = CheckpointCreatedEvent(
|
|
256
|
+
checkpoint_position=current_position,
|
|
257
|
+
checkpoint_type=checkpoint_type,
|
|
258
|
+
duration_ms=duration_ms,
|
|
259
|
+
source_location=source_location,
|
|
260
|
+
procedure_id=self.procedure_id,
|
|
261
|
+
)
|
|
262
|
+
logger.debug(
|
|
263
|
+
f"[CHECKPOINT] Emitting CheckpointCreatedEvent: position={current_position}, type={checkpoint_type}, duration_ms={duration_ms}"
|
|
264
|
+
)
|
|
265
|
+
self.log_handler.log(event)
|
|
266
|
+
except Exception as e:
|
|
267
|
+
logger.warning(f"Failed to emit checkpoint event: {e}")
|
|
268
|
+
else:
|
|
269
|
+
logger.warning("[CHECKPOINT] No log_handler available to emit checkpoint event")
|
|
270
|
+
|
|
271
|
+
# Persist metadata
|
|
272
|
+
self.storage.save_procedure_metadata(self.procedure_id, self.metadata)
|
|
273
|
+
|
|
274
|
+
return result
|
|
275
|
+
|
|
276
|
+
def _get_code_context(self, file_path: str, line: int, context_lines: int = 3) -> Optional[str]:
|
|
277
|
+
"""Read source file and extract surrounding lines for debugging."""
|
|
278
|
+
try:
|
|
279
|
+
with open(file_path, "r") as f:
|
|
280
|
+
lines = f.readlines()
|
|
281
|
+
start = max(0, line - context_lines - 1)
|
|
282
|
+
end = min(len(lines), line + context_lines)
|
|
283
|
+
return "".join(lines[start:end])
|
|
284
|
+
except Exception:
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
def wait_for_human(
|
|
288
|
+
self,
|
|
289
|
+
request_type: str,
|
|
290
|
+
message: str,
|
|
291
|
+
timeout_seconds: Optional[int],
|
|
292
|
+
default_value: Any,
|
|
293
|
+
options: Optional[List[dict]],
|
|
294
|
+
metadata: dict,
|
|
295
|
+
) -> HITLResponse:
|
|
296
|
+
"""
|
|
297
|
+
Wait for human response using the configured HITL handler.
|
|
298
|
+
|
|
299
|
+
Delegates to the HITLHandler protocol implementation.
|
|
300
|
+
"""
|
|
301
|
+
if not self.hitl:
|
|
302
|
+
# No HITL handler - return default immediately
|
|
303
|
+
return HITLResponse(
|
|
304
|
+
value=default_value, responded_at=datetime.now(timezone.utc), timed_out=True
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
# Create HITL request
|
|
308
|
+
request = HITLRequest(
|
|
309
|
+
request_type=request_type,
|
|
310
|
+
message=message,
|
|
311
|
+
timeout_seconds=timeout_seconds,
|
|
312
|
+
default_value=default_value,
|
|
313
|
+
options=options,
|
|
314
|
+
metadata=metadata,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
# Delegate to HITL handler (may raise ProcedureWaitingForHuman)
|
|
318
|
+
return self.hitl.request_interaction(self.procedure_id, request)
|
|
319
|
+
|
|
320
|
+
def sleep(self, seconds: int) -> None:
|
|
321
|
+
"""
|
|
322
|
+
Sleep with checkpointing.
|
|
323
|
+
|
|
324
|
+
On replay, skips the sleep. On first execution, sleeps and checkpoints.
|
|
325
|
+
"""
|
|
326
|
+
|
|
327
|
+
def sleep_fn():
|
|
328
|
+
time.sleep(seconds)
|
|
329
|
+
return None
|
|
330
|
+
|
|
331
|
+
self.checkpoint(sleep_fn, "sleep")
|
|
332
|
+
|
|
333
|
+
def checkpoint_clear_all(self) -> None:
|
|
334
|
+
"""Clear all checkpoints (execution log)."""
|
|
335
|
+
self.metadata.execution_log.clear()
|
|
336
|
+
self.metadata.replay_index = 0
|
|
337
|
+
self.storage.save_procedure_metadata(self.procedure_id, self.metadata)
|
|
338
|
+
|
|
339
|
+
def checkpoint_clear_after(self, position: int) -> None:
|
|
340
|
+
"""Clear checkpoint at position and all subsequent ones."""
|
|
341
|
+
# Keep only checkpoints before the given position
|
|
342
|
+
self.metadata.execution_log = self.metadata.execution_log[:position]
|
|
343
|
+
self.metadata.replay_index = min(self.metadata.replay_index, position)
|
|
344
|
+
self.storage.save_procedure_metadata(self.procedure_id, self.metadata)
|
|
345
|
+
|
|
346
|
+
def next_position(self) -> int:
|
|
347
|
+
"""Get the next checkpoint position."""
|
|
348
|
+
return self.metadata.replay_index
|
|
349
|
+
|
|
350
|
+
def store_procedure_handle(self, handle: Any) -> None:
|
|
351
|
+
"""
|
|
352
|
+
Store async procedure handle.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
handle: ProcedureHandle instance
|
|
356
|
+
"""
|
|
357
|
+
# Store in metadata under "async_procedures" key
|
|
358
|
+
if "async_procedures" not in self.metadata:
|
|
359
|
+
self.metadata["async_procedures"] = {}
|
|
360
|
+
|
|
361
|
+
self.metadata["async_procedures"][handle.procedure_id] = handle.to_dict()
|
|
362
|
+
self.storage.save_procedure_metadata(self.procedure_id, self.metadata)
|
|
363
|
+
|
|
364
|
+
def get_procedure_handle(self, procedure_id: str) -> Optional[Dict[str, Any]]:
|
|
365
|
+
"""
|
|
366
|
+
Retrieve procedure handle.
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
procedure_id: ID of the procedure
|
|
370
|
+
|
|
371
|
+
Returns:
|
|
372
|
+
Handle dict or None
|
|
373
|
+
"""
|
|
374
|
+
async_procedures = self.metadata.get("async_procedures", {})
|
|
375
|
+
return async_procedures.get(procedure_id)
|
|
376
|
+
|
|
377
|
+
def list_pending_procedures(self) -> List[Dict[str, Any]]:
|
|
378
|
+
"""
|
|
379
|
+
List all pending async procedures.
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
List of handle dicts for procedures with status "running" or "waiting"
|
|
383
|
+
"""
|
|
384
|
+
async_procedures = self.metadata.get("async_procedures", {})
|
|
385
|
+
return [
|
|
386
|
+
handle
|
|
387
|
+
for handle in async_procedures.values()
|
|
388
|
+
if handle.get("status") in ("running", "waiting")
|
|
389
|
+
]
|
|
390
|
+
|
|
391
|
+
def update_procedure_status(
|
|
392
|
+
self, procedure_id: str, status: str, result: Any = None, error: str = None
|
|
393
|
+
) -> None:
|
|
394
|
+
"""
|
|
395
|
+
Update procedure status.
|
|
396
|
+
|
|
397
|
+
Args:
|
|
398
|
+
procedure_id: ID of the procedure
|
|
399
|
+
status: New status
|
|
400
|
+
result: Optional result value
|
|
401
|
+
error: Optional error message
|
|
402
|
+
"""
|
|
403
|
+
if "async_procedures" not in self.metadata:
|
|
404
|
+
return
|
|
405
|
+
|
|
406
|
+
if procedure_id in self.metadata["async_procedures"]:
|
|
407
|
+
handle = self.metadata["async_procedures"][procedure_id]
|
|
408
|
+
handle["status"] = status
|
|
409
|
+
if result is not None:
|
|
410
|
+
handle["result"] = result
|
|
411
|
+
if error is not None:
|
|
412
|
+
handle["error"] = error
|
|
413
|
+
if status in ("completed", "failed", "cancelled"):
|
|
414
|
+
handle["completed_at"] = datetime.now(timezone.utc).isoformat()
|
|
415
|
+
|
|
416
|
+
self.storage.save_procedure_metadata(self.procedure_id, self.metadata)
|
|
417
|
+
|
|
418
|
+
def save_execution_run(
|
|
419
|
+
self, procedure_name: str, file_path: str, status: str = "COMPLETED"
|
|
420
|
+
) -> str:
|
|
421
|
+
"""
|
|
422
|
+
Convert current execution to ExecutionRun and save for tracing.
|
|
423
|
+
|
|
424
|
+
Args:
|
|
425
|
+
procedure_name: Name of the procedure
|
|
426
|
+
file_path: Path to the .tac file
|
|
427
|
+
status: Run status (COMPLETED, FAILED, etc.)
|
|
428
|
+
|
|
429
|
+
Returns:
|
|
430
|
+
The run_id of the saved run
|
|
431
|
+
"""
|
|
432
|
+
# Generate run ID
|
|
433
|
+
run_id = str(uuid.uuid4())
|
|
434
|
+
|
|
435
|
+
# Determine start time from first checkpoint or now
|
|
436
|
+
start_time = (
|
|
437
|
+
self.metadata.execution_log[0].timestamp
|
|
438
|
+
if self.metadata.execution_log
|
|
439
|
+
else datetime.now(timezone.utc)
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
# Create ExecutionRun
|
|
443
|
+
run = ExecutionRun(
|
|
444
|
+
run_id=run_id,
|
|
445
|
+
procedure_name=procedure_name,
|
|
446
|
+
file_path=file_path,
|
|
447
|
+
start_time=start_time,
|
|
448
|
+
end_time=datetime.now(timezone.utc),
|
|
449
|
+
status=status,
|
|
450
|
+
execution_log=self.metadata.execution_log.copy(),
|
|
451
|
+
final_state=self.metadata.state.copy() if hasattr(self.metadata, "state") else {},
|
|
452
|
+
breakpoints=[],
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
# Save to storage
|
|
456
|
+
self.storage.save_run(run)
|
|
457
|
+
|
|
458
|
+
return run_id
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
class InMemoryExecutionContext(BaseExecutionContext):
|
|
462
|
+
"""
|
|
463
|
+
Simple in-memory execution context.
|
|
464
|
+
|
|
465
|
+
Uses in-memory storage with no persistence. Useful for testing
|
|
466
|
+
and simple CLI workflows that don't need to survive restarts.
|
|
467
|
+
"""
|
|
468
|
+
|
|
469
|
+
def __init__(self, procedure_id: str, hitl_handler: Optional[HITLHandler] = None):
|
|
470
|
+
"""
|
|
471
|
+
Initialize with in-memory storage.
|
|
472
|
+
|
|
473
|
+
Args:
|
|
474
|
+
procedure_id: ID of the running procedure
|
|
475
|
+
hitl_handler: Optional HITL handler
|
|
476
|
+
"""
|
|
477
|
+
from tactus.adapters.memory import MemoryStorage
|
|
478
|
+
|
|
479
|
+
storage = MemoryStorage()
|
|
480
|
+
super().__init__(procedure_id, storage, hitl_handler)
|