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,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())
|