vamp-cli 0.1.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 (57) hide show
  1. vamp_cli/__init__.py +0 -0
  2. vamp_cli/agent/__init__.py +0 -0
  3. vamp_cli/agent/context_sync.py +72 -0
  4. vamp_cli/agent/graph.py +159 -0
  5. vamp_cli/agent/instructions.py +18 -0
  6. vamp_cli/agent/nodes.py +375 -0
  7. vamp_cli/agent/roles.py +8 -0
  8. vamp_cli/agent/runtime/__init__.py +0 -0
  9. vamp_cli/agent/runtime/runtime.py +133 -0
  10. vamp_cli/agent/state.py +55 -0
  11. vamp_cli/agent/status.py +17 -0
  12. vamp_cli/config/__init__.py +0 -0
  13. vamp_cli/config/loader.py +152 -0
  14. vamp_cli/config/schema.py +82 -0
  15. vamp_cli/llm/__init__.py +0 -0
  16. vamp_cli/llm/adapter.py +52 -0
  17. vamp_cli/llm/adapter_registry.py +26 -0
  18. vamp_cli/llm/debug_callbacks.py +18 -0
  19. vamp_cli/llm/router.py +247 -0
  20. vamp_cli/llm/usage.py +170 -0
  21. vamp_cli/logging_setup/logging_setup.py +37 -0
  22. vamp_cli/main.py +1023 -0
  23. vamp_cli/persistence/__init__.py +29 -0
  24. vamp_cli/persistence/compaction.py +372 -0
  25. vamp_cli/persistence/emitter.py +371 -0
  26. vamp_cli/persistence/event_log.py +303 -0
  27. vamp_cli/persistence/fork.py +81 -0
  28. vamp_cli/persistence/loader.py +616 -0
  29. vamp_cli/persistence/transcript.py +160 -0
  30. vamp_cli/prompts/prompts.py +182 -0
  31. vamp_cli/sandbox/__init__.py +0 -0
  32. vamp_cli/sandbox/base.py +22 -0
  33. vamp_cli/sandbox/config.py +16 -0
  34. vamp_cli/sandbox/docker.py +293 -0
  35. vamp_cli/sandbox/manager.py +31 -0
  36. vamp_cli/sandbox/result.py +14 -0
  37. vamp_cli/sandbox/terminal.py +151 -0
  38. vamp_cli/tools/__init__.py +0 -0
  39. vamp_cli/tools/base.py +44 -0
  40. vamp_cli/tools/diagnostic.py +120 -0
  41. vamp_cli/tools/execute_cmd.py +16 -0
  42. vamp_cli/tools/files.py +652 -0
  43. vamp_cli/tools/registry.py +163 -0
  44. vamp_cli/tools/tools_classes.py +622 -0
  45. vamp_cli/utils/__init__.py +0 -0
  46. vamp_cli/utils/error_codes.py +7 -0
  47. vamp_cli/utils/ignore.py +36 -0
  48. vamp_cli/utils/sha_256.py +32 -0
  49. vamp_cli/utils/token_tracker.py +31 -0
  50. vamp_cli/workspace/__init__.py +0 -0
  51. vamp_cli/workspace/workspace.py +16 -0
  52. vamp_cli-0.1.0.dist-info/METADATA +317 -0
  53. vamp_cli-0.1.0.dist-info/RECORD +57 -0
  54. vamp_cli-0.1.0.dist-info/WHEEL +5 -0
  55. vamp_cli-0.1.0.dist-info/entry_points.txt +2 -0
  56. vamp_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  57. vamp_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,160 @@
1
+ """
2
+ Render a chain as a human-readable transcript using prompt_toolkit.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from prompt_toolkit import print_formatted_text
8
+ from prompt_toolkit.formatted_text import FormattedText
9
+
10
+ from vamp_cli.persistence.loader import build_shadow_map
11
+
12
+
13
+ def _truncate(text: str, limit: int = 400) -> str:
14
+ if len(text) <= limit:
15
+ return text
16
+ return text[:limit] + f" ... [{len(text) - limit} chars truncated]"
17
+
18
+
19
+ def render_transcript(
20
+ chain: list[dict],
21
+ *,
22
+ max_content_chars: int = 400,
23
+ max_reasoning_chars: int = 240,
24
+ use_color: bool = True,
25
+ ) -> FormattedText:
26
+ """
27
+ Return the chain as prompt_toolkit formatted text.
28
+
29
+ Shadowed events are skipped; summary is shown in their place.
30
+ All styling is represented by prompt_toolkit style fragments rather
31
+ than raw ANSI escape sequences.
32
+ """
33
+ shadowed, compaction_by_uuid = build_shadow_map(chain)
34
+
35
+ # prompt_toolkit style names.
36
+ DIM = "dim"
37
+ BOLD_CYAN = "bold cyan"
38
+ BOLD_GREEN = "bold green"
39
+ GREEN = "green"
40
+ YELLOW = "yellow"
41
+ RED = "red"
42
+
43
+ out: list[tuple[str, str]] = []
44
+
45
+ def add(text: str, style: str = "") -> None:
46
+ if use_color and style:
47
+ out.append((style, text))
48
+ else:
49
+ out.append(("", text))
50
+
51
+ pending_tool_calls: dict[str, dict] = {}
52
+
53
+ for ev in chain:
54
+ u = ev.get("uuid")
55
+ t = ev.get("type")
56
+ d = ev.get("data") or {}
57
+
58
+ if u in shadowed:
59
+ continue
60
+
61
+ if t in ("session_start", "session_end"):
62
+ continue
63
+
64
+ if t == "mode_switch":
65
+ add(
66
+ f"-- mode: {d.get('from')} -> {d.get('to')} --",
67
+ DIM,
68
+ )
69
+ add("\n")
70
+ continue
71
+
72
+ if t == "compaction":
73
+ summary = (d.get("summary") or "").strip()
74
+ add(
75
+ f"-- [compacted {d.get('event_count', '?')} messages] --",
76
+ YELLOW,
77
+ )
78
+ add("\n")
79
+ if summary:
80
+ add(" " + summary.replace("\n", "\n "), DIM)
81
+ add("\n")
82
+ continue
83
+
84
+ if t == "user_message":
85
+ content = d.get("content", "")
86
+ add("\n")
87
+ add("You:", BOLD_CYAN)
88
+ add("\n")
89
+ add(content)
90
+ add("\n")
91
+ continue
92
+
93
+ if t == "assistant_message":
94
+ reasoning = d.get("reasoning_content") or ""
95
+ content = d.get("content") or ""
96
+ tool_calls = d.get("tool_calls") or []
97
+
98
+ if reasoning:
99
+ add(
100
+ " (thinking) " + _truncate(reasoning, max_reasoning_chars),
101
+ DIM,
102
+ )
103
+ add("\n")
104
+
105
+ if content:
106
+ add("\n")
107
+ add("Agent:", BOLD_GREEN)
108
+ add("\n")
109
+ add(content)
110
+ add("\n")
111
+
112
+ for tc in tool_calls:
113
+ pending_tool_calls[tc.get("id") or ""] = tc
114
+ name = tc.get("name") or "?"
115
+ add(f" >> {name}(...)", DIM)
116
+ add("\n")
117
+ continue
118
+
119
+ if t == "interrupt_request":
120
+ kind = d.get("kind") or "?"
121
+ add(f"-- interrupt: {kind} requested --", YELLOW)
122
+ add("\n")
123
+ continue
124
+
125
+ if t == "interrupt_response":
126
+ verdict = "approved" if d.get("approved") else "denied"
127
+ add(
128
+ f"-- interrupt: {d.get('kind')} {verdict} --",
129
+ GREEN if d.get("approved") else RED,
130
+ )
131
+ add("\n")
132
+ continue
133
+
134
+ if t == "fork":
135
+ add(
136
+ f"-- fork: {d.get('reason', '')} -> thread {d.get('thread_id')} --",
137
+ DIM,
138
+ )
139
+ add("\n")
140
+ continue
141
+
142
+ # Remove trailing newlines
143
+ while out and out[0][1].strip() == "":
144
+ out.pop(0)
145
+ while out and out[-1][1].strip() == "":
146
+ out.pop()
147
+
148
+ return FormattedText(out)
149
+
150
+
151
+ def print_transcript(chain: list[dict], *, header: str | None = None) -> None:
152
+ """wrapper for main.py."""
153
+ if header:
154
+ print_formatted_text(FormattedText([("", f"\n{header}\n")]))
155
+
156
+ body = render_transcript(chain)
157
+ if body:
158
+ print_formatted_text(body)
159
+ else:
160
+ print_formatted_text(FormattedText([("", "(no prior messages)")]))
@@ -0,0 +1,182 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from string import Template
7
+ from typing import Any, Iterable
8
+
9
+ log = logging.getLogger(__name__)
10
+
11
+
12
+ # This file is at vamp_cli/prompts/prompts.py, so .parent == vamp_cli/prompts/
13
+ PROMPTS_DIR: Path = Path(__file__).resolve().parent
14
+
15
+
16
+ class PromptNotFoundError(FileNotFoundError):
17
+ """Raised when a prompt path cannot be resolved."""
18
+
19
+
20
+ class PromptMissingVariableError(KeyError):
21
+ """Raised when a declared template variable is not supplied."""
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Prompt:
26
+ name: str
27
+ path: Path
28
+ body: str
29
+ metadata: dict[str, Any] = field(default_factory=dict)
30
+
31
+ @property
32
+ def variables(self) -> list[str]:
33
+ return list(self.metadata.get("variables", []))
34
+
35
+ def render(self, **values: Any) -> str:
36
+ missing = [v for v in self.variables if v not in values]
37
+ if missing:
38
+ raise PromptMissingVariableError(
39
+ f"Prompt '{self.name}' missing variable(s): {', '.join(missing)}"
40
+ )
41
+ return Template(self.body).safe_substitute(values)
42
+
43
+
44
+ class PromptStore:
45
+ """
46
+ Loads prompts from `vamp_cli/prompts/`.
47
+ Accepts both `"agent.md"` and `"prompts/agent.md"` as input
48
+ """
49
+
50
+ _ROOT = PROMPTS_DIR
51
+
52
+ def __init__(self, *, hot_reload: bool = False) -> None:
53
+ self._hot_reload = hot_reload
54
+ self._cache: dict[Path, Prompt] = {}
55
+ self._mtime: dict[Path, float] = {}
56
+
57
+ # ------------------------------------------------------------------
58
+ # Public API
59
+ # ------------------------------------------------------------------
60
+ def load(self, path: str) -> str:
61
+ """Return the raw body of the prompt. What runtime.py calls."""
62
+ return self._get(path).body
63
+
64
+ def load_prompt(self, path: str) -> Prompt:
65
+ return self._get(path)
66
+
67
+ def render(self, path: str, **values: Any) -> str:
68
+ return self._get(path).render(**values)
69
+
70
+ def exists(self, path: str) -> bool:
71
+ try:
72
+ self._resolve(path)
73
+ return True
74
+ except PromptNotFoundError:
75
+ return False
76
+
77
+ def preload(self, paths: Iterable[str]) -> None:
78
+ """Fail-fast load of every referenced prompt. Call from bootstrap()."""
79
+ for path in paths:
80
+ self._get(path)
81
+ log.info("Preloaded %d prompt(s)", len(self._cache))
82
+
83
+ def reload(self) -> None:
84
+ self._cache.clear()
85
+ self._mtime.clear()
86
+
87
+ def list_available(self) -> list[str]:
88
+ """Relative paths of every .md file under vamp_cli/prompts/."""
89
+ return sorted(
90
+ p.relative_to(self._ROOT).as_posix()
91
+ for p in self._ROOT.rglob("*.md")
92
+ )
93
+
94
+ # ------------------------------------------------------------------
95
+ # Internal
96
+ # ------------------------------------------------------------------
97
+ def _get(self, path: str) -> Prompt:
98
+ resolved = self._resolve(path)
99
+
100
+ if self._hot_reload:
101
+ mtime = resolved.stat().st_mtime
102
+ if self._cache.get(resolved) is None or self._mtime.get(resolved) != mtime:
103
+ self._cache[resolved] = self._parse(resolved)
104
+ self._mtime[resolved] = mtime
105
+ return self._cache[resolved]
106
+
107
+ cached = self._cache.get(resolved)
108
+ if cached is not None:
109
+ return cached
110
+
111
+ prompt = self._parse(resolved)
112
+ self._cache[resolved] = prompt
113
+ self._mtime[resolved] = resolved.stat().st_mtime
114
+ return prompt
115
+
116
+ def _resolve(self, path: str) -> Path:
117
+ # Normalize: "prompts/agent.md" -> "agent.md"
118
+ rel = path.lstrip("/\\")
119
+ if rel.startswith("prompts/"):
120
+ rel = rel[len("prompts/"):]
121
+
122
+ candidate = (self._ROOT / rel).resolve()
123
+
124
+ # Guard against ".." escaping the prompts directory.
125
+ if self._ROOT not in candidate.parents and candidate != self._ROOT:
126
+ raise PromptNotFoundError(
127
+ f"Prompt path escapes vamp_cli/prompts/: {path}"
128
+ )
129
+
130
+ if not candidate.is_file():
131
+ raise PromptNotFoundError(
132
+ f"Prompt not found: {path} "
133
+ f"(looked in {self._ROOT}; available: {self.list_available()})"
134
+ )
135
+ return candidate
136
+
137
+ @staticmethod
138
+ def _parse(path: Path) -> Prompt:
139
+ raw = path.read_text(encoding="utf-8")
140
+ metadata: dict[str, Any] = {}
141
+ body = raw
142
+
143
+ if raw.startswith("---\n"):
144
+ end = raw.find("\n---", 4)
145
+ if end != -1:
146
+ metadata = _parse_front_matter(raw[4:end])
147
+ body = raw[end + 4:].lstrip("\n")
148
+
149
+ name = metadata.get("name") or path.stem
150
+ return Prompt(name=name, path=path, body=body, metadata=metadata)
151
+
152
+
153
+ def _parse_front_matter(text: str) -> dict[str, Any]:
154
+ """Minimal `key: value` + `key:\\n - item` parser. No pyyaml dependency."""
155
+ result: dict[str, Any] = {}
156
+ current_list: list[str] | None = None
157
+
158
+ for line in text.splitlines():
159
+ if not line.strip() or line.lstrip().startswith("#"):
160
+ continue
161
+ if line.startswith(" - ") and current_list is not None:
162
+ current_list.append(line[4:].strip())
163
+ continue
164
+ if ":" in line:
165
+ key, _, val = line.partition(":")
166
+ key, val = key.strip(), val.strip()
167
+ if not val:
168
+ current_list = []
169
+ result[key] = current_list
170
+ else:
171
+ current_list = None
172
+ result[key] = _coerce(val)
173
+ return result
174
+
175
+
176
+ def _coerce(val: str) -> Any:
177
+ if val.lower() in ("true", "false"):
178
+ return val.lower() == "true"
179
+ try:
180
+ return int(val)
181
+ except ValueError:
182
+ return val.strip("'\"")
File without changes
@@ -0,0 +1,22 @@
1
+ from abc import ABC, abstractmethod
2
+ from vamp_cli.sandbox.result import CommandResult
3
+
4
+ class Sandbox(ABC):
5
+
6
+ @abstractmethod
7
+ async def start(self) -> None:
8
+ ...
9
+
10
+ @abstractmethod
11
+ async def execute(
12
+ self,
13
+ command: str,
14
+ cwd: str = "/workspace",
15
+ timeout: int = 30,
16
+ max_output_chars: int = 12000
17
+ ) -> CommandResult:
18
+ ...
19
+
20
+ @abstractmethod
21
+ async def stop(self) -> None:
22
+ ...
@@ -0,0 +1,16 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass
5
+ class SandboxConfig:
6
+ image: str = "vamp-sandbox:latest"
7
+
8
+ cpu_limit: float = 2.0
9
+ memory_limit: str = "2g"
10
+ pids_limit: int = 256
11
+
12
+ command_timeout: int = 30
13
+
14
+ network_enabled: bool = True
15
+
16
+ max_output_bytes: int = 1_000_000
@@ -0,0 +1,293 @@
1
+ import os
2
+ import uuid
3
+ import time
4
+ import asyncio
5
+ import platform
6
+ import posixpath
7
+ from pathlib import PurePosixPath
8
+ import subprocess
9
+ from vamp_cli.sandbox.base import Sandbox
10
+ from vamp_cli.sandbox.config import SandboxConfig
11
+ from vamp_cli.sandbox.result import CommandResult
12
+ from vamp_cli.workspace.workspace import Workspace
13
+
14
+
15
+ class DockerSandbox(Sandbox):
16
+
17
+ def __init__(
18
+ self,
19
+ workspace: Workspace,
20
+ config: SandboxConfig,
21
+ ):
22
+ self.workspace = str(workspace.root)
23
+ self.config = config
24
+ self.container_name = f"vamp-sandbox-{uuid.uuid4().hex[:8]}"
25
+
26
+ async def start(self):
27
+
28
+ await ensure_sandbox_image_exists(self.config.image)
29
+
30
+ command = [
31
+ "docker",
32
+ "run",
33
+ "-d",
34
+
35
+ "--name",
36
+ self.container_name,
37
+
38
+ "--cpus",
39
+ str(self.config.cpu_limit),
40
+
41
+ "--memory",
42
+ self.config.memory_limit,
43
+
44
+ "--pids-limit",
45
+ str(self.config.pids_limit),
46
+
47
+ "-v",
48
+ f"{self.workspace}:/workspace",
49
+
50
+ "-w",
51
+ "/workspace",
52
+ ]
53
+
54
+ # Fix Linux/WSL file ownership issues
55
+ if platform.system().lower() == "linux":
56
+ # Avoid using root inside the container so host files don't lock up
57
+ command.extend(["--user", f"{os.getuid()}:{os.getgid()}"])
58
+
59
+ if not self.config.network_enabled:
60
+ command.extend([
61
+ "--network",
62
+ "none",
63
+ ])
64
+
65
+ command.extend([
66
+ self.config.image,
67
+ "sleep",
68
+ "infinity",
69
+ ])
70
+
71
+ proc = await asyncio.create_subprocess_exec(
72
+ *command,
73
+ stdout=asyncio.subprocess.PIPE,
74
+ stderr=asyncio.subprocess.PIPE,
75
+ )
76
+
77
+ _, stderr = await proc.communicate()
78
+
79
+ if proc.returncode != 0:
80
+ raise RuntimeError(
81
+ f"Docker container startup failed:\n{stderr.decode(errors='replace')}"
82
+ )
83
+
84
+ # try:
85
+ # subprocess.run(
86
+ # command,
87
+ # check=True,
88
+ # capture_output=True,
89
+ # text=True,
90
+ # )
91
+ # except subprocess.CalledProcessError as e:
92
+ # print(f"Docker container startup failed.\nError:\n{e.stderr}")
93
+
94
+
95
+ async def execute(
96
+ self,
97
+ command: str,
98
+ cwd: str = "/workspace",
99
+ timeout: int = 30,
100
+ max_output_chars: int = 12000
101
+ ) -> CommandResult:
102
+
103
+ safe_cwd = _resolve_cwd(cwd)
104
+
105
+ proc = await asyncio.create_subprocess_exec(
106
+ "docker", "exec", "-w", safe_cwd,
107
+ self.container_name, "/bin/bash", "-lc", command,
108
+ stdout=asyncio.subprocess.PIPE,
109
+ stderr=asyncio.subprocess.PIPE,
110
+ )
111
+
112
+ # docker_command = [
113
+ # "docker",
114
+ # "exec",
115
+ # "-w",
116
+ # safe_cwd,
117
+ # self.container_name,
118
+ # "/bin/bash",
119
+ # "-lc",
120
+ # command,
121
+ # ]
122
+
123
+ try:
124
+ stdout_b, stderr_b = await asyncio.wait_for(
125
+ proc.communicate(), timeout=timeout,
126
+ )
127
+
128
+ # result = subprocess.run(
129
+ # docker_command,
130
+ # capture_output=True,
131
+ # text=True,
132
+ # timeout=timeout,
133
+ # )
134
+
135
+ # return CommandResult(
136
+ # command=command,
137
+ # stdout=result.stdout,
138
+ # stderr=result.stderr,
139
+ # exit_code=result.returncode,
140
+ # )
141
+ except asyncio.TimeoutError:
142
+ try:
143
+ proc.kill()
144
+ except:
145
+ pass
146
+
147
+ try:
148
+ stdout_b, stderr_b = await proc.communicate()
149
+ except Exception:
150
+ stdout_b, stderr_b = b"", b""
151
+
152
+ return CommandResult(
153
+ command=command,
154
+ stdout=stdout_b.decode(errors="replace"),
155
+ stderr=stderr_b.decode(errors="replace"),
156
+ exit_code=-1,
157
+ timed_out=True,
158
+ )
159
+
160
+ return CommandResult(
161
+ command=command,
162
+ stdout=stdout_b.decode(errors="replace"),
163
+ stderr=stderr_b.decode(errors="replace"),
164
+ exit_code=proc.returncode or 0,
165
+ )
166
+
167
+ # except subprocess.TimeoutExpired as e:
168
+ # return CommandResult(
169
+ # command=command,
170
+ # stdout=e.stdout.decode() if isinstance(e.stdout, bytes) else e.stdout or "",
171
+ # stderr=e.stderr.decode() if isinstance(e.stderr, bytes) else e.stderr or "",
172
+ # exit_code=-1,
173
+ # timed_out=True,
174
+ # )
175
+
176
+ async def stop(self):
177
+
178
+ proc = await asyncio.create_subprocess_exec(
179
+ "docker", "rm", "-f", self.container_name,
180
+ stdout=asyncio.subprocess.DEVNULL,
181
+ stderr=asyncio.subprocess.DEVNULL,
182
+ )
183
+ await proc.wait()
184
+
185
+ # subprocess.run(
186
+ # [
187
+ # "docker",
188
+ # "rm",
189
+ # "-f",
190
+ # self.container_name,
191
+ # ],
192
+ # capture_output=True,
193
+ # text=True,
194
+ # )
195
+
196
+ def _resolve_cwd(cwd: str) -> str:
197
+
198
+ if cwd.startswith("/"):
199
+ raise ValueError(
200
+ "cwd must be relative to workspace"
201
+ )
202
+
203
+ root = PurePosixPath("/workspace")
204
+
205
+ normalized = PurePosixPath(
206
+ posixpath.normpath(
207
+ str(root / cwd)
208
+ )
209
+ )
210
+
211
+ if normalized != root and root not in normalized.parents:
212
+ raise ValueError(
213
+ "cwd escapes workspace"
214
+ )
215
+
216
+ return str(normalized)
217
+
218
+
219
+ async def ensure_sandbox_image_exists(image_tag: str = SandboxConfig.image):
220
+ """
221
+ Checks if the specified Docker image exists locally.
222
+ If it doesn't, it dynamically creates a Dockerfile with essential
223
+ coding utilities, builds the image, and cleans up the temporary file.
224
+ """
225
+ docker_file = "src/vamp_cli/sandbox/Dockerfile"
226
+
227
+ if await _docker_image_exists(image_tag):
228
+ return True
229
+ print(f"Image '{image_tag}' not found locally. Building sandbox environment...")
230
+ print("Downloading and configuring development runtimes (Python, Node, Git)...")
231
+
232
+ proc = await asyncio.create_subprocess_exec(
233
+ "docker", "build", "-t", image_tag, "-f", docker_file, ".",
234
+ stdout=asyncio.subprocess.PIPE,
235
+ stderr=asyncio.subprocess.STDOUT,
236
+ )
237
+
238
+ assert proc.stdout is not None
239
+
240
+ async for line in proc.stdout:
241
+ # Live build progress — the user sees what's happening.
242
+ print(line.decode(errors="replace"), end="", flush=True)
243
+
244
+ await proc.wait()
245
+
246
+ if proc.returncode != 0:
247
+ raise RuntimeError(
248
+ f"Failed to auto-compile the coding sandbox image (exit {proc.returncode})."
249
+ )
250
+
251
+ print(f"Successfully compiled sandbox environment: {image_tag}")
252
+
253
+ return True
254
+
255
+ # try:
256
+ # # 1. Check if the image already exists in the local Docker daemon
257
+ # subprocess.run(
258
+ # ["docker", "image", "inspect", image_tag],
259
+ # check=True,
260
+ # stdout=subprocess.DEVNULL,
261
+ # stderr=subprocess.DEVNULL
262
+ # )
263
+ # # Image exists, return immediately without doing anything
264
+ # return True
265
+
266
+ # except subprocess.CalledProcessError:
267
+ # # 2. Image does not exist -> Start the automated build process
268
+ # print(f"Image '{image_tag}' not found locally. Initiating automatic environment build...")
269
+
270
+ # try:
271
+ # print("Downloading and configuring development runtimes (Python, Node, Git)...")
272
+
273
+ # # Execute the docker build command
274
+ # # (Remove stdout=subprocess.DEVNULL if you want the user to see the progress bar)
275
+ # subprocess.run(
276
+ # ["docker", "build", "-t", image_tag, "-f", docker_file, "."],
277
+ # check=True,
278
+ # stdout=subprocess.DEVNULL
279
+ # )
280
+ # print(f"Successfully compiled sandbox environment: {image_tag}")
281
+ # return True
282
+
283
+ # except subprocess.CalledProcessError as e:
284
+ # raise RuntimeError(f"Failed to auto-compile the coding sandbox image architecture: {e}")
285
+
286
+
287
+ async def _docker_image_exists(image_tag: str) -> bool:
288
+ proc = await asyncio.create_subprocess_exec(
289
+ "docker", "image", "inspect", image_tag,
290
+ stdout=asyncio.subprocess.DEVNULL,
291
+ stderr=asyncio.subprocess.DEVNULL,
292
+ )
293
+ return await proc.wait() == 0
@@ -0,0 +1,31 @@
1
+ from vamp_cli.sandbox.base import Sandbox
2
+ from vamp_cli.sandbox.config import SandboxConfig
3
+ from vamp_cli.sandbox.docker import DockerSandbox
4
+ from vamp_cli.workspace.workspace import Workspace
5
+
6
+
7
+ class SandboxManager:
8
+
9
+ def __init__(self, config: SandboxConfig):
10
+ self.config = config
11
+ self.sandbox = None
12
+
13
+ async def create(self, workspace: Workspace) -> Sandbox:
14
+ if self.sandbox is not None:
15
+ raise RuntimeError("Sandbox already exists")
16
+
17
+ self.sandbox = DockerSandbox(
18
+ workspace=workspace,
19
+ config=self.config,
20
+ )
21
+
22
+ await self.sandbox.start()
23
+
24
+ return self.sandbox
25
+
26
+ async def destroy(self):
27
+ if self.sandbox is None:
28
+ return
29
+
30
+ await self.sandbox.stop()
31
+ self.sandbox = None