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.
- vamp_cli/__init__.py +0 -0
- vamp_cli/agent/__init__.py +0 -0
- vamp_cli/agent/context_sync.py +72 -0
- vamp_cli/agent/graph.py +159 -0
- vamp_cli/agent/instructions.py +18 -0
- vamp_cli/agent/nodes.py +375 -0
- vamp_cli/agent/roles.py +8 -0
- vamp_cli/agent/runtime/__init__.py +0 -0
- vamp_cli/agent/runtime/runtime.py +133 -0
- vamp_cli/agent/state.py +55 -0
- vamp_cli/agent/status.py +17 -0
- vamp_cli/config/__init__.py +0 -0
- vamp_cli/config/loader.py +152 -0
- vamp_cli/config/schema.py +82 -0
- vamp_cli/llm/__init__.py +0 -0
- vamp_cli/llm/adapter.py +52 -0
- vamp_cli/llm/adapter_registry.py +26 -0
- vamp_cli/llm/debug_callbacks.py +18 -0
- vamp_cli/llm/router.py +247 -0
- vamp_cli/llm/usage.py +170 -0
- vamp_cli/logging_setup/logging_setup.py +37 -0
- vamp_cli/main.py +1023 -0
- vamp_cli/persistence/__init__.py +29 -0
- vamp_cli/persistence/compaction.py +372 -0
- vamp_cli/persistence/emitter.py +371 -0
- vamp_cli/persistence/event_log.py +303 -0
- vamp_cli/persistence/fork.py +81 -0
- vamp_cli/persistence/loader.py +616 -0
- vamp_cli/persistence/transcript.py +160 -0
- vamp_cli/prompts/prompts.py +182 -0
- vamp_cli/sandbox/__init__.py +0 -0
- vamp_cli/sandbox/base.py +22 -0
- vamp_cli/sandbox/config.py +16 -0
- vamp_cli/sandbox/docker.py +293 -0
- vamp_cli/sandbox/manager.py +31 -0
- vamp_cli/sandbox/result.py +14 -0
- vamp_cli/sandbox/terminal.py +151 -0
- vamp_cli/tools/__init__.py +0 -0
- vamp_cli/tools/base.py +44 -0
- vamp_cli/tools/diagnostic.py +120 -0
- vamp_cli/tools/execute_cmd.py +16 -0
- vamp_cli/tools/files.py +652 -0
- vamp_cli/tools/registry.py +163 -0
- vamp_cli/tools/tools_classes.py +622 -0
- vamp_cli/utils/__init__.py +0 -0
- vamp_cli/utils/error_codes.py +7 -0
- vamp_cli/utils/ignore.py +36 -0
- vamp_cli/utils/sha_256.py +32 -0
- vamp_cli/utils/token_tracker.py +31 -0
- vamp_cli/workspace/__init__.py +0 -0
- vamp_cli/workspace/workspace.py +16 -0
- vamp_cli-0.1.0.dist-info/METADATA +317 -0
- vamp_cli-0.1.0.dist-info/RECORD +57 -0
- vamp_cli-0.1.0.dist-info/WHEEL +5 -0
- vamp_cli-0.1.0.dist-info/entry_points.txt +2 -0
- vamp_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|
vamp_cli/sandbox/base.py
ADDED
|
@@ -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
|