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.
Files changed (160) hide show
  1. tactus/__init__.py +49 -0
  2. tactus/adapters/__init__.py +9 -0
  3. tactus/adapters/broker_log.py +76 -0
  4. tactus/adapters/cli_hitl.py +189 -0
  5. tactus/adapters/cli_log.py +223 -0
  6. tactus/adapters/cost_collector_log.py +56 -0
  7. tactus/adapters/file_storage.py +367 -0
  8. tactus/adapters/http_callback_log.py +109 -0
  9. tactus/adapters/ide_log.py +71 -0
  10. tactus/adapters/lua_tools.py +336 -0
  11. tactus/adapters/mcp.py +289 -0
  12. tactus/adapters/mcp_manager.py +196 -0
  13. tactus/adapters/memory.py +53 -0
  14. tactus/adapters/plugins.py +419 -0
  15. tactus/backends/http_backend.py +58 -0
  16. tactus/backends/model_backend.py +35 -0
  17. tactus/backends/pytorch_backend.py +110 -0
  18. tactus/broker/__init__.py +12 -0
  19. tactus/broker/client.py +247 -0
  20. tactus/broker/protocol.py +183 -0
  21. tactus/broker/server.py +1123 -0
  22. tactus/broker/stdio.py +12 -0
  23. tactus/cli/__init__.py +7 -0
  24. tactus/cli/app.py +2245 -0
  25. tactus/cli/commands/__init__.py +0 -0
  26. tactus/core/__init__.py +32 -0
  27. tactus/core/config_manager.py +790 -0
  28. tactus/core/dependencies/__init__.py +14 -0
  29. tactus/core/dependencies/registry.py +180 -0
  30. tactus/core/dsl_stubs.py +2117 -0
  31. tactus/core/exceptions.py +66 -0
  32. tactus/core/execution_context.py +480 -0
  33. tactus/core/lua_sandbox.py +508 -0
  34. tactus/core/message_history_manager.py +236 -0
  35. tactus/core/mocking.py +286 -0
  36. tactus/core/output_validator.py +291 -0
  37. tactus/core/registry.py +499 -0
  38. tactus/core/runtime.py +2907 -0
  39. tactus/core/template_resolver.py +142 -0
  40. tactus/core/yaml_parser.py +301 -0
  41. tactus/docker/Dockerfile +61 -0
  42. tactus/docker/entrypoint.sh +69 -0
  43. tactus/dspy/__init__.py +39 -0
  44. tactus/dspy/agent.py +1144 -0
  45. tactus/dspy/broker_lm.py +181 -0
  46. tactus/dspy/config.py +212 -0
  47. tactus/dspy/history.py +196 -0
  48. tactus/dspy/module.py +405 -0
  49. tactus/dspy/prediction.py +318 -0
  50. tactus/dspy/signature.py +185 -0
  51. tactus/formatting/__init__.py +7 -0
  52. tactus/formatting/formatter.py +437 -0
  53. tactus/ide/__init__.py +9 -0
  54. tactus/ide/coding_assistant.py +343 -0
  55. tactus/ide/server.py +2223 -0
  56. tactus/primitives/__init__.py +49 -0
  57. tactus/primitives/control.py +168 -0
  58. tactus/primitives/file.py +229 -0
  59. tactus/primitives/handles.py +378 -0
  60. tactus/primitives/host.py +94 -0
  61. tactus/primitives/human.py +342 -0
  62. tactus/primitives/json.py +189 -0
  63. tactus/primitives/log.py +187 -0
  64. tactus/primitives/message_history.py +157 -0
  65. tactus/primitives/model.py +163 -0
  66. tactus/primitives/procedure.py +564 -0
  67. tactus/primitives/procedure_callable.py +318 -0
  68. tactus/primitives/retry.py +155 -0
  69. tactus/primitives/session.py +152 -0
  70. tactus/primitives/state.py +182 -0
  71. tactus/primitives/step.py +209 -0
  72. tactus/primitives/system.py +93 -0
  73. tactus/primitives/tool.py +375 -0
  74. tactus/primitives/tool_handle.py +279 -0
  75. tactus/primitives/toolset.py +229 -0
  76. tactus/protocols/__init__.py +38 -0
  77. tactus/protocols/chat_recorder.py +81 -0
  78. tactus/protocols/config.py +97 -0
  79. tactus/protocols/cost.py +31 -0
  80. tactus/protocols/hitl.py +71 -0
  81. tactus/protocols/log_handler.py +27 -0
  82. tactus/protocols/models.py +355 -0
  83. tactus/protocols/result.py +33 -0
  84. tactus/protocols/storage.py +90 -0
  85. tactus/providers/__init__.py +13 -0
  86. tactus/providers/base.py +92 -0
  87. tactus/providers/bedrock.py +117 -0
  88. tactus/providers/google.py +105 -0
  89. tactus/providers/openai.py +98 -0
  90. tactus/sandbox/__init__.py +63 -0
  91. tactus/sandbox/config.py +171 -0
  92. tactus/sandbox/container_runner.py +1099 -0
  93. tactus/sandbox/docker_manager.py +433 -0
  94. tactus/sandbox/entrypoint.py +227 -0
  95. tactus/sandbox/protocol.py +213 -0
  96. tactus/stdlib/__init__.py +10 -0
  97. tactus/stdlib/io/__init__.py +13 -0
  98. tactus/stdlib/io/csv.py +88 -0
  99. tactus/stdlib/io/excel.py +136 -0
  100. tactus/stdlib/io/file.py +90 -0
  101. tactus/stdlib/io/fs.py +154 -0
  102. tactus/stdlib/io/hdf5.py +121 -0
  103. tactus/stdlib/io/json.py +109 -0
  104. tactus/stdlib/io/parquet.py +83 -0
  105. tactus/stdlib/io/tsv.py +88 -0
  106. tactus/stdlib/loader.py +274 -0
  107. tactus/stdlib/tac/tactus/tools/done.tac +33 -0
  108. tactus/stdlib/tac/tactus/tools/log.tac +50 -0
  109. tactus/testing/README.md +273 -0
  110. tactus/testing/__init__.py +61 -0
  111. tactus/testing/behave_integration.py +380 -0
  112. tactus/testing/context.py +486 -0
  113. tactus/testing/eval_models.py +114 -0
  114. tactus/testing/evaluation_runner.py +222 -0
  115. tactus/testing/evaluators.py +634 -0
  116. tactus/testing/events.py +94 -0
  117. tactus/testing/gherkin_parser.py +134 -0
  118. tactus/testing/mock_agent.py +315 -0
  119. tactus/testing/mock_dependencies.py +234 -0
  120. tactus/testing/mock_hitl.py +171 -0
  121. tactus/testing/mock_registry.py +168 -0
  122. tactus/testing/mock_tools.py +133 -0
  123. tactus/testing/models.py +115 -0
  124. tactus/testing/pydantic_eval_runner.py +508 -0
  125. tactus/testing/steps/__init__.py +13 -0
  126. tactus/testing/steps/builtin.py +902 -0
  127. tactus/testing/steps/custom.py +69 -0
  128. tactus/testing/steps/registry.py +68 -0
  129. tactus/testing/test_runner.py +489 -0
  130. tactus/tracing/__init__.py +5 -0
  131. tactus/tracing/trace_manager.py +417 -0
  132. tactus/utils/__init__.py +1 -0
  133. tactus/utils/cost_calculator.py +72 -0
  134. tactus/utils/model_pricing.py +132 -0
  135. tactus/utils/safe_file_library.py +502 -0
  136. tactus/utils/safe_libraries.py +234 -0
  137. tactus/validation/LuaLexerBase.py +66 -0
  138. tactus/validation/LuaParserBase.py +23 -0
  139. tactus/validation/README.md +224 -0
  140. tactus/validation/__init__.py +7 -0
  141. tactus/validation/error_listener.py +21 -0
  142. tactus/validation/generated/LuaLexer.interp +231 -0
  143. tactus/validation/generated/LuaLexer.py +5548 -0
  144. tactus/validation/generated/LuaLexer.tokens +124 -0
  145. tactus/validation/generated/LuaLexerBase.py +66 -0
  146. tactus/validation/generated/LuaParser.interp +173 -0
  147. tactus/validation/generated/LuaParser.py +6439 -0
  148. tactus/validation/generated/LuaParser.tokens +124 -0
  149. tactus/validation/generated/LuaParserBase.py +23 -0
  150. tactus/validation/generated/LuaParserVisitor.py +118 -0
  151. tactus/validation/generated/__init__.py +7 -0
  152. tactus/validation/grammar/LuaLexer.g4 +123 -0
  153. tactus/validation/grammar/LuaParser.g4 +178 -0
  154. tactus/validation/semantic_visitor.py +817 -0
  155. tactus/validation/validator.py +157 -0
  156. tactus-0.31.2.dist-info/METADATA +1809 -0
  157. tactus-0.31.2.dist-info/RECORD +160 -0
  158. tactus-0.31.2.dist-info/WHEEL +4 -0
  159. tactus-0.31.2.dist-info/entry_points.txt +2 -0
  160. tactus-0.31.2.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}")