tactus 0.31.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 +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.0.dist-info/METADATA +1809 -0
- tactus-0.31.0.dist-info/RECORD +160 -0
- tactus-0.31.0.dist-info/WHEEL +4 -0
- tactus-0.31.0.dist-info/entry_points.txt +2 -0
- tactus-0.31.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Procedure Primitive - Enables procedure invocation and composition.
|
|
3
|
+
|
|
4
|
+
Provides Procedure.run() for synchronous invocation and Procedure.spawn()
|
|
5
|
+
for async invocation, along with status tracking and waiting.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import uuid
|
|
10
|
+
import asyncio
|
|
11
|
+
import threading
|
|
12
|
+
from typing import Any, Optional, Dict, List, Callable
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ProcedureHandle:
|
|
21
|
+
"""Handle for tracking async procedure execution."""
|
|
22
|
+
|
|
23
|
+
procedure_id: str
|
|
24
|
+
name: str
|
|
25
|
+
status: str = "running" # "running", "completed", "failed", "waiting"
|
|
26
|
+
result: Any = None
|
|
27
|
+
error: Optional[str] = None
|
|
28
|
+
started_at: datetime = field(default_factory=datetime.now)
|
|
29
|
+
completed_at: Optional[datetime] = None
|
|
30
|
+
thread: Optional[threading.Thread] = None
|
|
31
|
+
|
|
32
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
33
|
+
"""Convert to dictionary for Lua access."""
|
|
34
|
+
return {
|
|
35
|
+
"procedure_id": self.procedure_id,
|
|
36
|
+
"name": self.name,
|
|
37
|
+
"status": self.status,
|
|
38
|
+
"result": self.result,
|
|
39
|
+
"error": self.error,
|
|
40
|
+
"started_at": self.started_at.isoformat() if self.started_at else None,
|
|
41
|
+
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ProcedureExecutionError(Exception):
|
|
46
|
+
"""Raised when a procedure execution fails."""
|
|
47
|
+
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ProcedureRecursionError(Exception):
|
|
52
|
+
"""Raised when recursion depth is exceeded."""
|
|
53
|
+
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ProcedurePrimitive:
|
|
58
|
+
"""
|
|
59
|
+
Primitive for invoking other procedures.
|
|
60
|
+
|
|
61
|
+
Supports both synchronous and asynchronous invocation,
|
|
62
|
+
enabling procedure composition and recursion.
|
|
63
|
+
|
|
64
|
+
Example usage (Lua):
|
|
65
|
+
-- Synchronous
|
|
66
|
+
local result = Procedure.run("researcher", {query = "AI"})
|
|
67
|
+
|
|
68
|
+
-- Asynchronous
|
|
69
|
+
local handle = Procedure.spawn("researcher", {query = "AI"})
|
|
70
|
+
local status = Procedure.status(handle)
|
|
71
|
+
local result = Procedure.wait(handle)
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
execution_context: Any,
|
|
77
|
+
runtime_factory: Callable[[str, Dict[str, Any]], Any],
|
|
78
|
+
lua_sandbox: Any = None,
|
|
79
|
+
max_depth: int = 5,
|
|
80
|
+
current_depth: int = 0,
|
|
81
|
+
):
|
|
82
|
+
"""
|
|
83
|
+
Initialize procedure primitive.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
execution_context: Execution context for state management
|
|
87
|
+
runtime_factory: Factory function to create TactusRuntime instances
|
|
88
|
+
lua_sandbox: LuaSandbox instance for in-file procedure lookup
|
|
89
|
+
max_depth: Maximum recursion depth
|
|
90
|
+
current_depth: Current recursion depth
|
|
91
|
+
"""
|
|
92
|
+
self.execution_context = execution_context
|
|
93
|
+
self.runtime_factory = runtime_factory
|
|
94
|
+
self.lua_sandbox = lua_sandbox
|
|
95
|
+
self.max_depth = max_depth
|
|
96
|
+
self.current_depth = current_depth
|
|
97
|
+
self.handles: Dict[str, ProcedureHandle] = {}
|
|
98
|
+
self._lock = threading.Lock()
|
|
99
|
+
|
|
100
|
+
logger.info(f"ProcedurePrimitive initialized (depth {current_depth}/{max_depth})")
|
|
101
|
+
|
|
102
|
+
def __call__(self, name: str) -> Any:
|
|
103
|
+
"""
|
|
104
|
+
Look up an in-file named procedure by name.
|
|
105
|
+
|
|
106
|
+
Enables Lua syntax:
|
|
107
|
+
local res = Procedure("my_proc")({ ... })
|
|
108
|
+
|
|
109
|
+
Named procedures are injected into Lua globals by the runtime during initialization.
|
|
110
|
+
"""
|
|
111
|
+
if not self.lua_sandbox or not hasattr(self.lua_sandbox, "lua"):
|
|
112
|
+
raise ProcedureExecutionError("Procedure lookup is not available (lua_sandbox missing)")
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
proc = self.lua_sandbox.lua.globals()[name]
|
|
116
|
+
except Exception:
|
|
117
|
+
proc = None
|
|
118
|
+
|
|
119
|
+
if proc is None:
|
|
120
|
+
raise ProcedureExecutionError(f"Named procedure '{name}' not found")
|
|
121
|
+
|
|
122
|
+
return proc
|
|
123
|
+
|
|
124
|
+
def run(self, name: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
125
|
+
"""
|
|
126
|
+
Synchronous procedure invocation with auto-checkpointing.
|
|
127
|
+
|
|
128
|
+
Sub-procedure calls are automatically checkpointed for durability.
|
|
129
|
+
On replay, the cached result is returned without re-executing.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
name: Procedure name or file path
|
|
133
|
+
params: Parameters to pass to the procedure
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Procedure result
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
ProcedureRecursionError: If recursion depth exceeded
|
|
140
|
+
ProcedureExecutionError: If procedure execution fails
|
|
141
|
+
"""
|
|
142
|
+
# Check recursion depth
|
|
143
|
+
if self.current_depth >= self.max_depth:
|
|
144
|
+
raise ProcedureRecursionError(f"Maximum recursion depth ({self.max_depth}) exceeded")
|
|
145
|
+
|
|
146
|
+
logger.info(f"Running procedure '{name}' synchronously (depth {self.current_depth})")
|
|
147
|
+
|
|
148
|
+
# Normalize params
|
|
149
|
+
params = params or {}
|
|
150
|
+
if hasattr(params, "items"):
|
|
151
|
+
from tactus.core.dsl_stubs import lua_table_to_dict
|
|
152
|
+
|
|
153
|
+
params = lua_table_to_dict(params)
|
|
154
|
+
if isinstance(params, list) and len(params) == 0:
|
|
155
|
+
params = {}
|
|
156
|
+
|
|
157
|
+
# Wrap execution in checkpoint for durability
|
|
158
|
+
def execute_procedure():
|
|
159
|
+
try:
|
|
160
|
+
# Load procedure source
|
|
161
|
+
source = self._load_procedure_source(name)
|
|
162
|
+
|
|
163
|
+
# Create runtime for sub-procedure
|
|
164
|
+
runtime = self.runtime_factory(name, params)
|
|
165
|
+
|
|
166
|
+
# Execute synchronously (runtime.execute is async, so we need to run it)
|
|
167
|
+
import asyncio
|
|
168
|
+
import threading
|
|
169
|
+
|
|
170
|
+
async def run_subprocedure():
|
|
171
|
+
return await runtime.execute(source=source, context=params, format="lua")
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
asyncio.get_running_loop()
|
|
175
|
+
has_running_loop = True
|
|
176
|
+
except RuntimeError:
|
|
177
|
+
has_running_loop = False
|
|
178
|
+
|
|
179
|
+
if has_running_loop:
|
|
180
|
+
result_holder = {}
|
|
181
|
+
error_holder = {}
|
|
182
|
+
|
|
183
|
+
def run_in_thread():
|
|
184
|
+
try:
|
|
185
|
+
result_holder["result"] = asyncio.run(run_subprocedure())
|
|
186
|
+
except Exception as e:
|
|
187
|
+
error_holder["error"] = e
|
|
188
|
+
|
|
189
|
+
t = threading.Thread(target=run_in_thread, daemon=True)
|
|
190
|
+
t.start()
|
|
191
|
+
t.join()
|
|
192
|
+
|
|
193
|
+
if "error" in error_holder:
|
|
194
|
+
raise error_holder["error"]
|
|
195
|
+
|
|
196
|
+
result = result_holder.get("result")
|
|
197
|
+
else:
|
|
198
|
+
result = asyncio.run(run_subprocedure())
|
|
199
|
+
|
|
200
|
+
# Extract result from execution response
|
|
201
|
+
if result.get("success"):
|
|
202
|
+
logger.info(f"Procedure '{name}' completed successfully")
|
|
203
|
+
return result.get("result")
|
|
204
|
+
else:
|
|
205
|
+
error_msg = result.get("error", "Unknown error")
|
|
206
|
+
logger.error(f"Procedure '{name}' failed: {error_msg}")
|
|
207
|
+
raise ProcedureExecutionError(f"Procedure '{name}' failed: {error_msg}")
|
|
208
|
+
|
|
209
|
+
except ProcedureExecutionError:
|
|
210
|
+
raise
|
|
211
|
+
except ProcedureRecursionError:
|
|
212
|
+
raise
|
|
213
|
+
except Exception as e:
|
|
214
|
+
logger.error(f"Error executing procedure '{name}': {e}")
|
|
215
|
+
raise ProcedureExecutionError(f"Failed to execute procedure '{name}': {e}")
|
|
216
|
+
|
|
217
|
+
# Auto-checkpoint sub-procedure call
|
|
218
|
+
# Try to capture Lua source location if available
|
|
219
|
+
source_info = None
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
# Get debug.getinfo function from Lua globals
|
|
223
|
+
lua_globals = self.lua_sandbox.lua.globals()
|
|
224
|
+
if hasattr(lua_globals, "debug") and hasattr(lua_globals.debug, "getinfo"):
|
|
225
|
+
# Try different stack levels to find the Lua caller
|
|
226
|
+
debug_info = None
|
|
227
|
+
for level in [1, 2, 3, 4]:
|
|
228
|
+
try:
|
|
229
|
+
info = lua_globals.debug.getinfo(level, "Sl")
|
|
230
|
+
if info:
|
|
231
|
+
lua_dict = dict(info.items()) if hasattr(info, "items") else {}
|
|
232
|
+
source = lua_dict.get("source", "")
|
|
233
|
+
line = lua_dict.get("currentline", -1)
|
|
234
|
+
# Look for a valid source location (not -1, not C function, not internal)
|
|
235
|
+
if (
|
|
236
|
+
line > 0
|
|
237
|
+
and source
|
|
238
|
+
and not source.startswith("=[C]")
|
|
239
|
+
and not source.startswith("[string")
|
|
240
|
+
):
|
|
241
|
+
debug_info = lua_dict
|
|
242
|
+
break
|
|
243
|
+
except Exception:
|
|
244
|
+
continue
|
|
245
|
+
|
|
246
|
+
if debug_info:
|
|
247
|
+
source_info = {
|
|
248
|
+
"file": self.execution_context.current_tac_file
|
|
249
|
+
or debug_info.get("source", "unknown"),
|
|
250
|
+
"line": debug_info.get("currentline", 0),
|
|
251
|
+
"function": debug_info.get("name", name),
|
|
252
|
+
}
|
|
253
|
+
except Exception:
|
|
254
|
+
pass
|
|
255
|
+
|
|
256
|
+
# If we still don't have source_info, use fallback
|
|
257
|
+
if not source_info:
|
|
258
|
+
import inspect
|
|
259
|
+
|
|
260
|
+
frame = inspect.currentframe()
|
|
261
|
+
if frame and frame.f_back:
|
|
262
|
+
caller_frame = frame.f_back
|
|
263
|
+
# Use .tac file if available, otherwise use Python file
|
|
264
|
+
source_info = {
|
|
265
|
+
"file": self.execution_context.current_tac_file
|
|
266
|
+
or caller_frame.f_code.co_filename,
|
|
267
|
+
"line": 0, # Line number unknown without Lua debug
|
|
268
|
+
"function": name,
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return self.execution_context.checkpoint(
|
|
272
|
+
execute_procedure, "procedure_call", source_info=source_info
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
def spawn(self, name: str, params: Optional[Dict[str, Any]] = None) -> ProcedureHandle:
|
|
276
|
+
"""
|
|
277
|
+
Async procedure invocation.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
name: Procedure name or file path
|
|
281
|
+
params: Parameters to pass to the procedure
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
Handle for tracking execution
|
|
285
|
+
|
|
286
|
+
Raises:
|
|
287
|
+
ProcedureRecursionError: If recursion depth exceeded
|
|
288
|
+
"""
|
|
289
|
+
# Check recursion depth
|
|
290
|
+
if self.current_depth >= self.max_depth:
|
|
291
|
+
raise ProcedureRecursionError(f"Maximum recursion depth ({self.max_depth}) exceeded")
|
|
292
|
+
|
|
293
|
+
# Create handle
|
|
294
|
+
procedure_id = str(uuid.uuid4())
|
|
295
|
+
handle = ProcedureHandle(procedure_id=procedure_id, name=name, status="running")
|
|
296
|
+
|
|
297
|
+
# Store handle
|
|
298
|
+
with self._lock:
|
|
299
|
+
self.handles[procedure_id] = handle
|
|
300
|
+
|
|
301
|
+
logger.info(f"Spawning procedure '{name}' asynchronously (id: {procedure_id})")
|
|
302
|
+
|
|
303
|
+
# Start async execution in thread
|
|
304
|
+
params = params or {}
|
|
305
|
+
thread = threading.Thread(
|
|
306
|
+
target=self._execute_async, args=(handle, name, params), daemon=True
|
|
307
|
+
)
|
|
308
|
+
handle.thread = thread
|
|
309
|
+
thread.start()
|
|
310
|
+
|
|
311
|
+
return handle
|
|
312
|
+
|
|
313
|
+
def _execute_async(self, handle: ProcedureHandle, name: str, params: Dict[str, Any]):
|
|
314
|
+
"""Execute procedure asynchronously in background thread."""
|
|
315
|
+
try:
|
|
316
|
+
# Load procedure source
|
|
317
|
+
source = self._load_procedure_source(name)
|
|
318
|
+
|
|
319
|
+
# Create runtime for sub-procedure
|
|
320
|
+
runtime = self.runtime_factory(name, params)
|
|
321
|
+
|
|
322
|
+
# Execute in new event loop (thread-safe)
|
|
323
|
+
loop = asyncio.new_event_loop()
|
|
324
|
+
asyncio.set_event_loop(loop)
|
|
325
|
+
|
|
326
|
+
result = loop.run_until_complete(
|
|
327
|
+
runtime.execute(source=source, context=params, format="lua")
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
loop.close()
|
|
331
|
+
|
|
332
|
+
# Update handle
|
|
333
|
+
with self._lock:
|
|
334
|
+
if result.get("success"):
|
|
335
|
+
handle.status = "completed"
|
|
336
|
+
handle.result = result.get("result")
|
|
337
|
+
logger.info(f"Async procedure '{name}' completed (id: {handle.procedure_id})")
|
|
338
|
+
else:
|
|
339
|
+
handle.status = "failed"
|
|
340
|
+
handle.error = result.get("error", "Unknown error")
|
|
341
|
+
logger.error(f"Async procedure '{name}' failed: {handle.error}")
|
|
342
|
+
|
|
343
|
+
handle.completed_at = datetime.now()
|
|
344
|
+
|
|
345
|
+
except Exception as e:
|
|
346
|
+
logger.error(f"Error in async procedure '{name}': {e}")
|
|
347
|
+
with self._lock:
|
|
348
|
+
handle.status = "failed"
|
|
349
|
+
handle.error = str(e)
|
|
350
|
+
handle.completed_at = datetime.now()
|
|
351
|
+
|
|
352
|
+
def status(self, handle: ProcedureHandle) -> Dict[str, Any]:
|
|
353
|
+
"""
|
|
354
|
+
Get procedure status.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
handle: Procedure handle
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
Status dictionary
|
|
361
|
+
"""
|
|
362
|
+
with self._lock:
|
|
363
|
+
return handle.to_dict()
|
|
364
|
+
|
|
365
|
+
def wait(self, handle: ProcedureHandle, timeout: Optional[float] = None) -> Any:
|
|
366
|
+
"""
|
|
367
|
+
Wait for procedure completion.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
handle: Procedure handle
|
|
371
|
+
timeout: Optional timeout in seconds
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
Procedure result
|
|
375
|
+
|
|
376
|
+
Raises:
|
|
377
|
+
ProcedureExecutionError: If procedure failed
|
|
378
|
+
TimeoutError: If timeout exceeded
|
|
379
|
+
"""
|
|
380
|
+
logger.debug(f"Waiting for procedure {handle.procedure_id}")
|
|
381
|
+
|
|
382
|
+
# Wait for thread to complete
|
|
383
|
+
if handle.thread:
|
|
384
|
+
handle.thread.join(timeout=timeout)
|
|
385
|
+
|
|
386
|
+
# Check if still running (timeout)
|
|
387
|
+
if handle.thread.is_alive():
|
|
388
|
+
raise TimeoutError(f"Procedure {handle.name} timed out after {timeout}s")
|
|
389
|
+
|
|
390
|
+
# Check final status
|
|
391
|
+
with self._lock:
|
|
392
|
+
if handle.status == "failed":
|
|
393
|
+
raise ProcedureExecutionError(f"Procedure {handle.name} failed: {handle.error}")
|
|
394
|
+
elif handle.status == "completed":
|
|
395
|
+
return handle.result
|
|
396
|
+
else:
|
|
397
|
+
raise ProcedureExecutionError(
|
|
398
|
+
f"Procedure {handle.name} in unexpected state: {handle.status}"
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
def inject(self, handle: ProcedureHandle, message: str):
|
|
402
|
+
"""
|
|
403
|
+
Inject guidance message into running procedure.
|
|
404
|
+
|
|
405
|
+
Args:
|
|
406
|
+
handle: Procedure handle
|
|
407
|
+
message: Message to inject
|
|
408
|
+
|
|
409
|
+
Note: This is a placeholder - full implementation requires
|
|
410
|
+
communication channel with running procedure.
|
|
411
|
+
"""
|
|
412
|
+
logger.warning(f"Procedure.inject() not fully implemented - message ignored: {message}")
|
|
413
|
+
# TODO: Implement message injection mechanism
|
|
414
|
+
|
|
415
|
+
def cancel(self, handle: ProcedureHandle):
|
|
416
|
+
"""
|
|
417
|
+
Cancel running procedure.
|
|
418
|
+
|
|
419
|
+
Args:
|
|
420
|
+
handle: Procedure handle
|
|
421
|
+
|
|
422
|
+
Note: Python threads cannot be forcefully cancelled,
|
|
423
|
+
so this just marks the status.
|
|
424
|
+
"""
|
|
425
|
+
logger.info(f"Cancelling procedure {handle.procedure_id}")
|
|
426
|
+
|
|
427
|
+
with self._lock:
|
|
428
|
+
handle.status = "cancelled"
|
|
429
|
+
handle.completed_at = datetime.now()
|
|
430
|
+
|
|
431
|
+
# Note: Thread will continue running but result will be ignored
|
|
432
|
+
|
|
433
|
+
def wait_any(self, handles: List[ProcedureHandle]) -> ProcedureHandle:
|
|
434
|
+
"""
|
|
435
|
+
Wait for first completion.
|
|
436
|
+
|
|
437
|
+
Args:
|
|
438
|
+
handles: List of procedure handles
|
|
439
|
+
|
|
440
|
+
Returns:
|
|
441
|
+
First completed handle
|
|
442
|
+
"""
|
|
443
|
+
logger.debug(f"Waiting for any of {len(handles)} procedures")
|
|
444
|
+
|
|
445
|
+
while True:
|
|
446
|
+
# Check if any completed
|
|
447
|
+
with self._lock:
|
|
448
|
+
for handle in handles:
|
|
449
|
+
if handle.status in ("completed", "failed", "cancelled"):
|
|
450
|
+
return handle
|
|
451
|
+
|
|
452
|
+
# Sleep briefly before checking again
|
|
453
|
+
import time
|
|
454
|
+
|
|
455
|
+
time.sleep(0.1)
|
|
456
|
+
|
|
457
|
+
def wait_all(self, handles: List[ProcedureHandle]) -> List[Any]:
|
|
458
|
+
"""
|
|
459
|
+
Wait for all completions.
|
|
460
|
+
|
|
461
|
+
Args:
|
|
462
|
+
handles: List of procedure handles
|
|
463
|
+
|
|
464
|
+
Returns:
|
|
465
|
+
List of results
|
|
466
|
+
"""
|
|
467
|
+
logger.debug(f"Waiting for all {len(handles)} procedures")
|
|
468
|
+
|
|
469
|
+
results = []
|
|
470
|
+
for handle in handles:
|
|
471
|
+
result = self.wait(handle)
|
|
472
|
+
results.append(result)
|
|
473
|
+
|
|
474
|
+
return results
|
|
475
|
+
|
|
476
|
+
def is_complete(self, handle: ProcedureHandle) -> bool:
|
|
477
|
+
"""
|
|
478
|
+
Check if procedure is complete.
|
|
479
|
+
|
|
480
|
+
Args:
|
|
481
|
+
handle: Procedure handle
|
|
482
|
+
|
|
483
|
+
Returns:
|
|
484
|
+
True if completed (success or failure)
|
|
485
|
+
"""
|
|
486
|
+
with self._lock:
|
|
487
|
+
return handle.status in ("completed", "failed", "cancelled")
|
|
488
|
+
|
|
489
|
+
def all_complete(self, handles: List[ProcedureHandle]) -> bool:
|
|
490
|
+
"""
|
|
491
|
+
Check if all procedures are complete.
|
|
492
|
+
|
|
493
|
+
Args:
|
|
494
|
+
handles: List of procedure handles
|
|
495
|
+
|
|
496
|
+
Returns:
|
|
497
|
+
True if all completed
|
|
498
|
+
"""
|
|
499
|
+
return all(self.is_complete(handle) for handle in handles)
|
|
500
|
+
|
|
501
|
+
def _load_procedure_source(self, name: str) -> str:
|
|
502
|
+
"""
|
|
503
|
+
Load procedure source code by name.
|
|
504
|
+
|
|
505
|
+
Args:
|
|
506
|
+
name: Procedure name or file path
|
|
507
|
+
|
|
508
|
+
Returns:
|
|
509
|
+
Procedure source code
|
|
510
|
+
|
|
511
|
+
Raises:
|
|
512
|
+
FileNotFoundError: If procedure file not found
|
|
513
|
+
"""
|
|
514
|
+
from pathlib import Path
|
|
515
|
+
|
|
516
|
+
search_paths: list[Path] = []
|
|
517
|
+
seen: set[Path] = set()
|
|
518
|
+
|
|
519
|
+
def add_path(path: Path) -> None:
|
|
520
|
+
normalized = path.resolve() if path.is_absolute() else path
|
|
521
|
+
if normalized in seen:
|
|
522
|
+
return
|
|
523
|
+
seen.add(normalized)
|
|
524
|
+
search_paths.append(path)
|
|
525
|
+
|
|
526
|
+
name_path = Path(name)
|
|
527
|
+
|
|
528
|
+
def add_candidates(base: Path | None, rel: Path) -> None:
|
|
529
|
+
candidate = (base / rel) if base is not None else rel
|
|
530
|
+
add_path(candidate)
|
|
531
|
+
if candidate.suffix != ".tac":
|
|
532
|
+
add_path(Path(str(candidate) + ".tac"))
|
|
533
|
+
|
|
534
|
+
# Absolute path: try as-is.
|
|
535
|
+
if name_path.is_absolute():
|
|
536
|
+
add_candidates(None, name_path)
|
|
537
|
+
else:
|
|
538
|
+
# Relative to current working directory (CLI usage).
|
|
539
|
+
add_candidates(None, name_path)
|
|
540
|
+
|
|
541
|
+
# Relative to the current .tac file directory and its parents (BDD/temp cwd usage).
|
|
542
|
+
current_tac_file = getattr(self.execution_context, "current_tac_file", None)
|
|
543
|
+
if current_tac_file:
|
|
544
|
+
current_dir = Path(current_tac_file).parent
|
|
545
|
+
add_candidates(current_dir, name_path)
|
|
546
|
+
|
|
547
|
+
# Also try resolving from parent directories (helps when callers pass paths
|
|
548
|
+
# relative to project root, but cwd is not the project root).
|
|
549
|
+
for parent in list(current_dir.parents)[:5]:
|
|
550
|
+
add_candidates(parent, name_path)
|
|
551
|
+
|
|
552
|
+
# Fallback: examples directory relative to repo root in common layouts.
|
|
553
|
+
add_candidates(None, Path("examples") / name_path)
|
|
554
|
+
|
|
555
|
+
for path in search_paths:
|
|
556
|
+
try:
|
|
557
|
+
if path.exists() and path.is_file():
|
|
558
|
+
logger.debug(f"Loading procedure from: {path}")
|
|
559
|
+
return path.read_text()
|
|
560
|
+
except Exception:
|
|
561
|
+
continue
|
|
562
|
+
|
|
563
|
+
searched = [str(p) for p in search_paths]
|
|
564
|
+
raise FileNotFoundError(f"Procedure '{name}' not found. Searched: {searched}")
|