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.
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.0.dist-info/METADATA +1809 -0
  157. tactus-0.31.0.dist-info/RECORD +160 -0
  158. tactus-0.31.0.dist-info/WHEEL +4 -0
  159. tactus-0.31.0.dist-info/entry_points.txt +2 -0
  160. tactus-0.31.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,433 @@
1
+ """
2
+ Docker management utilities for sandbox execution.
3
+
4
+ Handles Docker availability detection, image building, and version management.
5
+ """
6
+
7
+ import hashlib
8
+ import logging
9
+ import shutil
10
+ import subprocess
11
+ from pathlib import Path
12
+ from typing import Tuple, Optional
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # Default image name for local sandbox
17
+ DEFAULT_IMAGE_NAME = "tactus-sandbox"
18
+ DEFAULT_IMAGE_TAG = "local"
19
+
20
+
21
+ def calculate_source_hash(tactus_root: Path) -> str:
22
+ """
23
+ Calculate hash of Tactus source files for change detection.
24
+
25
+ This enables fast, automatic rebuilds when code changes without
26
+ requiring manual version bumps or rebuild commands.
27
+
28
+ Args:
29
+ tactus_root: Root directory of the Tactus package
30
+
31
+ Returns:
32
+ Short hash (16 chars) representing the current state of source code
33
+ """
34
+ # Key paths that affect sandbox behavior
35
+ paths_to_hash = [
36
+ tactus_root / "tactus" / "dspy",
37
+ tactus_root / "tactus" / "adapters",
38
+ tactus_root / "tactus" / "core",
39
+ tactus_root / "tactus" / "primitives",
40
+ tactus_root / "tactus" / "sandbox",
41
+ tactus_root / "tactus" / "stdlib",
42
+ tactus_root / "tactus" / "docker",
43
+ tactus_root / "pyproject.toml", # Dependencies affect sandbox
44
+ ]
45
+
46
+ hasher = hashlib.sha256()
47
+
48
+ for path in sorted(paths_to_hash):
49
+ if not path.exists():
50
+ continue
51
+
52
+ if path.is_file():
53
+ # Hash file contents
54
+ hasher.update(path.read_bytes())
55
+ elif path.is_dir():
56
+ # Hash directory files (recursively), skipping caches.
57
+ for file in sorted(path.rglob("*")):
58
+ if not file.is_file():
59
+ continue
60
+ if "__pycache__" in file.parts:
61
+ continue
62
+ if file.suffix == ".pyc":
63
+ continue
64
+ if file.name == ".DS_Store":
65
+ continue
66
+
67
+ # Hash relative path + contents for reproducibility
68
+ rel_path = str(file.relative_to(tactus_root))
69
+ hasher.update(rel_path.encode())
70
+ hasher.update(file.read_bytes())
71
+
72
+ # Return short hash (16 chars is plenty for collision avoidance)
73
+ return hasher.hexdigest()[:16]
74
+
75
+
76
+ def is_docker_available() -> Tuple[bool, str]:
77
+ """
78
+ Check if Docker is available and running.
79
+
80
+ Returns:
81
+ Tuple of (available, reason) where:
82
+ - available: True if Docker is ready to use
83
+ - reason: Empty string if available, otherwise explanation of why not
84
+ """
85
+ # Check if docker CLI exists
86
+ docker_path = shutil.which("docker")
87
+ if not docker_path:
88
+ return False, "Docker CLI not found in PATH"
89
+
90
+ try:
91
+ # Check if Docker daemon is running
92
+ result = subprocess.run(
93
+ ["docker", "info"],
94
+ capture_output=True,
95
+ text=True,
96
+ timeout=10,
97
+ )
98
+ if result.returncode != 0:
99
+ # Parse common error messages
100
+ stderr = result.stderr.lower()
101
+ if "cannot connect" in stderr or "connection refused" in stderr:
102
+ return False, "Docker daemon not running"
103
+ if "permission denied" in stderr:
104
+ return (
105
+ False,
106
+ "Permission denied accessing Docker (try: sudo usermod -aG docker $USER)",
107
+ )
108
+ return False, f"Docker error: {result.stderr.strip()}"
109
+
110
+ return True, ""
111
+
112
+ except subprocess.TimeoutExpired:
113
+ return False, "Docker daemon not responding (timeout after 10s)"
114
+ except FileNotFoundError:
115
+ return False, "Docker CLI not found"
116
+ except Exception as e:
117
+ return False, f"Docker check failed: {e}"
118
+
119
+
120
+ class DockerManager:
121
+ """
122
+ Manages Docker images for sandbox execution.
123
+
124
+ Handles image building, version checking, and cleanup.
125
+ """
126
+
127
+ def __init__(
128
+ self,
129
+ image_name: str = DEFAULT_IMAGE_NAME,
130
+ image_tag: str = DEFAULT_IMAGE_TAG,
131
+ ):
132
+ """
133
+ Initialize Docker manager.
134
+
135
+ Args:
136
+ image_name: Base name for Docker images
137
+ image_tag: Tag for the image (e.g., 'local', 'v1.0.0')
138
+ """
139
+ self.image_name = image_name
140
+ self.image_tag = image_tag
141
+ self.full_image_name = f"{image_name}:{image_tag}"
142
+
143
+ def image_exists(self) -> bool:
144
+ """Check if the sandbox image exists locally."""
145
+ try:
146
+ result = subprocess.run(
147
+ ["docker", "image", "inspect", self.full_image_name],
148
+ capture_output=True,
149
+ timeout=10,
150
+ )
151
+ return result.returncode == 0
152
+ except (subprocess.TimeoutExpired, Exception):
153
+ return False
154
+
155
+ def get_image_version(self) -> Optional[str]:
156
+ """
157
+ Get the Tactus version label from the existing image.
158
+
159
+ Returns:
160
+ Version string if found, None otherwise.
161
+ """
162
+ try:
163
+ result = subprocess.run(
164
+ [
165
+ "docker",
166
+ "image",
167
+ "inspect",
168
+ "--format",
169
+ '{{index .Config.Labels "tactus.version"}}',
170
+ self.full_image_name,
171
+ ],
172
+ capture_output=True,
173
+ text=True,
174
+ timeout=10,
175
+ )
176
+ if result.returncode == 0 and result.stdout.strip():
177
+ return result.stdout.strip()
178
+ return None
179
+ except Exception:
180
+ return None
181
+
182
+ def get_image_source_hash(self) -> Optional[str]:
183
+ """
184
+ Get the source hash label from the existing image.
185
+
186
+ Returns:
187
+ Source hash string if found, None otherwise.
188
+ """
189
+ try:
190
+ result = subprocess.run(
191
+ [
192
+ "docker",
193
+ "image",
194
+ "inspect",
195
+ "--format",
196
+ '{{index .Config.Labels "tactus.source_hash"}}',
197
+ self.full_image_name,
198
+ ],
199
+ capture_output=True,
200
+ text=True,
201
+ timeout=10,
202
+ )
203
+ if result.returncode == 0 and result.stdout.strip():
204
+ return result.stdout.strip()
205
+ return None
206
+ except Exception:
207
+ return None
208
+
209
+ def needs_rebuild(self, current_version: str, current_hash: Optional[str] = None) -> bool:
210
+ """
211
+ Check if the image needs to be rebuilt.
212
+
213
+ Checks both version and source hash (if provided) to determine
214
+ if a rebuild is necessary. This enables automatic rebuilds when
215
+ code changes without requiring manual version bumps.
216
+
217
+ Args:
218
+ current_version: Current Tactus version.
219
+ current_hash: Optional source hash of current code. If provided,
220
+ will trigger rebuild when hash doesn't match.
221
+
222
+ Returns:
223
+ True if image should be rebuilt.
224
+ """
225
+ if not self.image_exists():
226
+ return True
227
+
228
+ # Check version mismatch
229
+ image_version = self.get_image_version()
230
+ if image_version is None:
231
+ return True
232
+
233
+ if image_version != current_version:
234
+ return True
235
+
236
+ # Check source hash mismatch (if hash checking is enabled)
237
+ if current_hash is not None:
238
+ image_hash = self.get_image_source_hash()
239
+ if image_hash is None:
240
+ # Old image without hash label - rebuild to add it
241
+ logger.debug("Image missing source hash label, rebuild needed")
242
+ return True
243
+
244
+ if image_hash != current_hash:
245
+ logger.debug(f"Source hash mismatch: {image_hash} != {current_hash}")
246
+ return True
247
+
248
+ return False
249
+
250
+ def build_image(
251
+ self,
252
+ dockerfile_path: Path,
253
+ context_path: Path,
254
+ version: str,
255
+ source_hash: Optional[str] = None,
256
+ verbose: bool = False,
257
+ ) -> Tuple[bool, str]:
258
+ """
259
+ Build the sandbox Docker image.
260
+
261
+ Args:
262
+ dockerfile_path: Path to the Dockerfile
263
+ context_path: Build context path (usually the Tactus package root)
264
+ version: Tactus version to label the image with
265
+ source_hash: Optional source hash to label the image with for change detection
266
+ verbose: If True, stream build output
267
+
268
+ Returns:
269
+ Tuple of (success, message)
270
+ """
271
+ if not dockerfile_path.exists():
272
+ return False, f"Dockerfile not found: {dockerfile_path}"
273
+
274
+ if not context_path.exists():
275
+ return False, f"Build context not found: {context_path}"
276
+
277
+ logger.info(f"Building sandbox image: {self.full_image_name}")
278
+
279
+ cmd = [
280
+ "docker",
281
+ "build",
282
+ "-t",
283
+ self.full_image_name,
284
+ "-f",
285
+ str(dockerfile_path),
286
+ "--label",
287
+ f"tactus.version={version}",
288
+ ]
289
+
290
+ # Add source hash label if provided
291
+ if source_hash:
292
+ cmd.extend(["--label", f"tactus.source_hash={source_hash}"])
293
+
294
+ cmd.append(str(context_path))
295
+
296
+ try:
297
+ if verbose:
298
+ # Stream output in real-time
299
+ process = subprocess.Popen(
300
+ cmd,
301
+ stdout=subprocess.PIPE,
302
+ stderr=subprocess.STDOUT,
303
+ text=True,
304
+ )
305
+ output_lines = []
306
+ for line in iter(process.stdout.readline, ""):
307
+ if line:
308
+ output_lines.append(line.rstrip())
309
+ logger.info(line.rstrip())
310
+ process.wait()
311
+ returncode = process.returncode
312
+ output = "\n".join(output_lines)
313
+ else:
314
+ result = subprocess.run(
315
+ cmd,
316
+ capture_output=True,
317
+ text=True,
318
+ timeout=600, # 10 minute timeout for builds
319
+ )
320
+ returncode = result.returncode
321
+ output = result.stderr if result.returncode != 0 else result.stdout
322
+
323
+ if returncode == 0:
324
+ logger.info(f"Successfully built: {self.full_image_name}")
325
+ return True, f"Successfully built {self.full_image_name}"
326
+ else:
327
+ return False, f"Build failed: {output}"
328
+
329
+ except subprocess.TimeoutExpired:
330
+ return False, "Build timed out after 10 minutes"
331
+ except Exception as e:
332
+ return False, f"Build failed: {e}"
333
+
334
+ def ensure_image_exists(
335
+ self,
336
+ dockerfile_path: Path,
337
+ context_path: Path,
338
+ version: str,
339
+ force_rebuild: bool = False,
340
+ ) -> Tuple[bool, str]:
341
+ """
342
+ Ensure the sandbox image exists, building if necessary.
343
+
344
+ Args:
345
+ dockerfile_path: Path to the Dockerfile
346
+ context_path: Build context path
347
+ version: Current Tactus version
348
+ force_rebuild: If True, rebuild even if image exists
349
+
350
+ Returns:
351
+ Tuple of (success, message)
352
+ """
353
+ if force_rebuild or self.needs_rebuild(version):
354
+ return self.build_image(dockerfile_path, context_path, version)
355
+
356
+ return True, f"Image {self.full_image_name} is up to date"
357
+
358
+ def remove_image(self) -> Tuple[bool, str]:
359
+ """
360
+ Remove the sandbox image.
361
+
362
+ Returns:
363
+ Tuple of (success, message)
364
+ """
365
+ if not self.image_exists():
366
+ return True, f"Image {self.full_image_name} does not exist"
367
+
368
+ try:
369
+ result = subprocess.run(
370
+ ["docker", "rmi", self.full_image_name],
371
+ capture_output=True,
372
+ text=True,
373
+ timeout=30,
374
+ )
375
+ if result.returncode == 0:
376
+ return True, f"Removed {self.full_image_name}"
377
+ else:
378
+ return False, f"Failed to remove image: {result.stderr}"
379
+ except Exception as e:
380
+ return False, f"Failed to remove image: {e}"
381
+
382
+ def cleanup_old_images(self, keep_tags: Optional[list] = None) -> int:
383
+ """
384
+ Remove old sandbox images, keeping specified tags.
385
+
386
+ Args:
387
+ keep_tags: List of tags to keep. Defaults to ['local'].
388
+
389
+ Returns:
390
+ Number of images removed.
391
+ """
392
+ if keep_tags is None:
393
+ keep_tags = [DEFAULT_IMAGE_TAG]
394
+
395
+ try:
396
+ # List all images with our base name
397
+ result = subprocess.run(
398
+ [
399
+ "docker",
400
+ "images",
401
+ "--format",
402
+ "{{.Repository}}:{{.Tag}}",
403
+ self.image_name,
404
+ ],
405
+ capture_output=True,
406
+ text=True,
407
+ timeout=10,
408
+ )
409
+ if result.returncode != 0:
410
+ return 0
411
+
412
+ removed = 0
413
+ for line in result.stdout.strip().split("\n"):
414
+ if not line:
415
+ continue
416
+ # Parse image:tag
417
+ if ":" in line:
418
+ _, tag = line.rsplit(":", 1)
419
+ if tag not in keep_tags:
420
+ rm_result = subprocess.run(
421
+ ["docker", "rmi", line],
422
+ capture_output=True,
423
+ timeout=30,
424
+ )
425
+ if rm_result.returncode == 0:
426
+ removed += 1
427
+ logger.info(f"Removed old image: {line}")
428
+
429
+ return removed
430
+
431
+ except Exception as e:
432
+ logger.warning(f"Failed to cleanup old images: {e}")
433
+ return 0
@@ -0,0 +1,227 @@
1
+ """
2
+ Container entrypoint for sandboxed procedure execution.
3
+
4
+ This module is run inside the Docker container. It:
5
+ 1. Reads an ExecutionRequest from stdin (JSON)
6
+ 2. Executes the procedure using TactusRuntime
7
+ 3. Writes an ExecutionResult to stdout (JSON with markers)
8
+
9
+ Usage:
10
+ python -m tactus.sandbox.entrypoint
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import logging
17
+ import os
18
+ import sys
19
+ import time
20
+ import traceback
21
+ from typing import Any, Dict, Optional
22
+
23
+ from tactus.sandbox.protocol import ExecutionResult
24
+
25
+ # Configure logging to stderr (stdout is reserved for result)
26
+ _LOG_LEVELS = {
27
+ "debug": logging.DEBUG,
28
+ "info": logging.INFO,
29
+ "warning": logging.WARNING,
30
+ "warn": logging.WARNING,
31
+ "error": logging.ERROR,
32
+ "critical": logging.CRITICAL,
33
+ }
34
+
35
+ _log_level_str = os.environ.get("TACTUS_LOG_LEVEL", "info").strip().lower()
36
+ _log_level = _LOG_LEVELS.get(_log_level_str, logging.INFO)
37
+
38
+ # CloudWatch-friendly, one line per record.
39
+ _log_fmt = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
40
+
41
+ logging.basicConfig(
42
+ level=_log_level,
43
+ format=_log_fmt,
44
+ stream=sys.stderr,
45
+ )
46
+ logger = logging.getLogger(__name__)
47
+
48
+ # Keep container stderr focused on procedure logs by default.
49
+ # Use `TACTUS_LOG_LEVEL=debug` to include internal runtime logs.
50
+ if _log_level > logging.DEBUG:
51
+ logging.getLogger("tactus.core").setLevel(logging.WARNING)
52
+ logging.getLogger("tactus.primitives").setLevel(logging.WARNING)
53
+ logging.getLogger("tactus.stdlib").setLevel(logging.WARNING)
54
+
55
+
56
+ def read_request_from_stdin() -> Optional[Dict[str, Any]]:
57
+ """Read the execution request from stdin as JSON."""
58
+ import json
59
+
60
+ try:
61
+ # Read exactly one JSON message (the initial ExecutionRequest).
62
+ # Keep stdin open for broker responses during execution.
63
+ input_data = sys.stdin.readline()
64
+ if not input_data.strip():
65
+ logger.error("No input received on stdin")
66
+ return None
67
+
68
+ return json.loads(input_data)
69
+ except json.JSONDecodeError as e:
70
+ logger.error(f"Failed to parse JSON from stdin: {e}")
71
+ return None
72
+
73
+
74
+ def write_result_to_stdout(result: ExecutionResult) -> None:
75
+ """Write the execution result to stdout with markers."""
76
+ from tactus.sandbox.protocol import wrap_result_for_stdout
77
+
78
+ output = wrap_result_for_stdout(result)
79
+ sys.stdout.write(output)
80
+ sys.stdout.flush()
81
+
82
+
83
+ async def execute_procedure(
84
+ source: str,
85
+ params: Dict[str, Any],
86
+ source_file_path: Optional[str] = None,
87
+ format: str = "lua",
88
+ ) -> Any:
89
+ """
90
+ Execute a procedure using TactusRuntime.
91
+
92
+ Args:
93
+ source: Procedure source code
94
+ params: Input parameters
95
+ config: Runtime configuration
96
+ mcp_servers: MCP server configurations
97
+ source_file_path: Original source file path
98
+ format: Source format ("lua" or "yaml")
99
+
100
+ Returns:
101
+ Procedure execution result
102
+ """
103
+ from tactus.core import TactusRuntime
104
+ from tactus.adapters.memory import MemoryStorage
105
+ from tactus.adapters.broker_log import BrokerLogHandler
106
+ from tactus.adapters.http_callback_log import HTTPCallbackLogHandler
107
+ from tactus.adapters.cost_collector_log import CostCollectorLogHandler
108
+
109
+ # Create a unique procedure ID
110
+ import uuid
111
+
112
+ procedure_id = str(uuid.uuid4())
113
+
114
+ # Prefer HTTP callbacks when configured (IDE streaming with container networking).
115
+ log_handler = HTTPCallbackLogHandler.from_environment()
116
+ if log_handler:
117
+ logger.info(
118
+ f"[SANDBOX] Using HTTP callback log handler: {os.environ.get('TACTUS_CALLBACK_URL')}"
119
+ )
120
+ else:
121
+ # Otherwise, try broker socket streaming (works without container networking, e.g. stdio/UDS).
122
+ log_handler = BrokerLogHandler.from_environment()
123
+ if log_handler:
124
+ logger.info(
125
+ f"[SANDBOX] Using broker log handler: {os.environ.get('TACTUS_BROKER_SOCKET')}"
126
+ )
127
+ else:
128
+ # Provide cost collection + checkpoint event handling even without IDE callbacks.
129
+ log_handler = CostCollectorLogHandler()
130
+ logger.info("[SANDBOX] No callback configured; using CostCollectorLogHandler")
131
+
132
+ # Create runtime with log handler for event streaming
133
+ runtime = TactusRuntime(
134
+ procedure_id=procedure_id,
135
+ storage_backend=MemoryStorage(),
136
+ mcp_servers=None,
137
+ external_config={},
138
+ source_file_path=source_file_path,
139
+ log_handler=log_handler, # Enable event streaming to IDE
140
+ )
141
+
142
+ # Execute procedure
143
+ result = await runtime.execute(
144
+ source=source,
145
+ context=params,
146
+ format=format,
147
+ )
148
+
149
+ return result
150
+
151
+
152
+ async def main_async() -> int:
153
+ """Main async entrypoint."""
154
+ from tactus.sandbox.protocol import (
155
+ ExecutionRequest,
156
+ ExecutionResult,
157
+ )
158
+
159
+ start_time = time.time()
160
+
161
+ # Read request from stdin
162
+ request_data = read_request_from_stdin()
163
+ if request_data is None:
164
+ result = ExecutionResult.failure(
165
+ error="Failed to read execution request from stdin",
166
+ error_type="InputError",
167
+ )
168
+ write_result_to_stdout(result)
169
+ return 1
170
+
171
+ try:
172
+ # Parse request
173
+ request = ExecutionRequest(**request_data)
174
+ logger.info(f"Executing procedure (id={request.execution_id})")
175
+
176
+ # Execute procedure
177
+ proc_result = await execute_procedure(
178
+ source=request.source,
179
+ params=request.params,
180
+ source_file_path=request.source_file_path,
181
+ format=request.format,
182
+ )
183
+
184
+ # Create success result
185
+ duration = time.time() - start_time
186
+ result = ExecutionResult.success(
187
+ result=proc_result,
188
+ duration_seconds=duration,
189
+ )
190
+
191
+ write_result_to_stdout(result)
192
+ return 0
193
+
194
+ except Exception as e:
195
+ logger.exception(f"Procedure execution failed: {e}")
196
+
197
+ duration = time.time() - start_time
198
+ result = ExecutionResult.failure(
199
+ error=str(e),
200
+ error_type=type(e).__name__,
201
+ traceback=traceback.format_exc(),
202
+ duration_seconds=duration,
203
+ )
204
+
205
+ write_result_to_stdout(result)
206
+ return 1
207
+ finally:
208
+ # Ensure stdio broker transport is closed cleanly to avoid pending-task warnings.
209
+ try:
210
+ from tactus.broker.client import close_stdio_transport
211
+
212
+ await close_stdio_transport()
213
+ except Exception:
214
+ pass
215
+
216
+
217
+ def main() -> int:
218
+ """Synchronous main entrypoint."""
219
+ try:
220
+ return asyncio.run(main_async())
221
+ except KeyboardInterrupt:
222
+ logger.info("Execution interrupted")
223
+ return 130 # Standard interrupt exit code
224
+
225
+
226
+ if __name__ == "__main__":
227
+ sys.exit(main())