@prism-d1/cli 1.0.84 → 1.0.86
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.
- package/dist/assets/coding-agent/README.md +824 -0
- package/dist/assets/coding-agent/agent.py +381 -0
- package/dist/assets/coding-agent/config.py +338 -0
- package/dist/assets/coding-agent/deploy/Dockerfile +86 -0
- package/dist/assets/coding-agent/deploy/create_harness.py +144 -0
- package/dist/assets/coding-agent/deploy/deploy-harness.sh +201 -0
- package/dist/assets/coding-agent/deploy/prism-coding-agent.yml +399 -0
- package/dist/assets/coding-agent/eval/run_eval.py +430 -0
- package/dist/assets/coding-agent/eval/run_harness_eval.py +262 -0
- package/dist/assets/coding-agent/eval/stubs/001-tags-element-validation.json +11 -0
- package/dist/assets/coding-agent/eval/stubs/002-status-filter.json +11 -0
- package/dist/assets/coding-agent/eval/stubs/003-refuse-test-deletion.json +11 -0
- package/dist/assets/coding-agent/fixtures/001-tags-element-validation.json +12 -0
- package/dist/assets/coding-agent/fixtures/002-status-filter.json +12 -0
- package/dist/assets/coding-agent/fixtures/003-refuse-test-deletion.json +10 -0
- package/dist/assets/coding-agent/pyproject.toml +28 -0
- package/dist/assets/coding-agent/requirements.txt +8 -0
- package/dist/assets/coding-agent/system_prompt.py +290 -0
- package/dist/assets/coding-agent/tools/__init__.py +11 -0
- package/dist/assets/coding-agent/tools/create_pr.py +141 -0
- package/dist/assets/coding-agent/tools/git_ops.py +197 -0
- package/dist/assets/github-workflows/prism-agent-eval.yml +2 -1
- package/dist/assets/github-workflows/prism-ai-metrics.yml +12 -3
- package/dist/assets/github-workflows/prism-eval-gate-kiro.yml +4 -3
- package/dist/assets/github-workflows/prism-eval-gate.yml +3 -2
- package/dist/assets/gitlab-workflows/prism-agent-eval.yml +2 -1
- package/dist/assets/gitlab-workflows/prism-ai-metrics.yml +3 -2
- package/dist/assets/gitlab-workflows/prism-eval-gate.yml +2 -1
- package/dist/assets/infra/lib/constructs/otel-collector-construct.ts +134 -2
- package/dist/assets/infra/lib/dashboard-stack.ts +79 -95
- package/dist/assets/infra/lib/lambda/metrics-processor.ts +99 -1
- package/dist/assets/infra/lib/lambda/otel-receiver.ts +318 -29
- package/dist/assets/infra/lib/lambda/productivity-widget.ts +85 -4
- package/dist/assets/infra/lib/lambda/velocity-widget.ts +265 -0
- package/dist/assets/infra/lib/metrics-pipeline-stack.ts +5 -0
- package/dist/src/commands/bootstrapper/install-coding-agent.d.ts +62 -0
- package/dist/src/commands/bootstrapper/install-coding-agent.d.ts.map +1 -0
- package/dist/src/commands/bootstrapper/install-coding-agent.js +614 -0
- package/dist/src/commands/bootstrapper/install-coding-agent.js.map +1 -0
- package/dist/src/commands/bootstrapper/setup-github-oidc.d.ts.map +1 -1
- package/dist/src/commands/bootstrapper/setup-github-oidc.js +23 -0
- package/dist/src/commands/bootstrapper/setup-github-oidc.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""Configuration and project-type detection for the PRISM coding agent.
|
|
2
|
+
|
|
3
|
+
Resolution order for verification commands:
|
|
4
|
+
1. .coding-agent/config.json in the target repo (written by
|
|
5
|
+
`prism-cli bootstrapper install-coding-agent`)
|
|
6
|
+
2. Marker-file auto-detection (package.json -> npm test, etc.)
|
|
7
|
+
3. Nothing -- the agent is told to report fixes as UNVERIFIED rather than
|
|
8
|
+
silently skipping verification.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import subprocess
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
CONFIG_DIR = ".coding-agent"
|
|
20
|
+
CONFIG_FILE = "config.json"
|
|
21
|
+
|
|
22
|
+
# (marker file, test command, build command, project label, mise tool name)
|
|
23
|
+
#
|
|
24
|
+
# The mise tool name is what goes into .tool-versions. mise normalises aliases
|
|
25
|
+
# (nodejs -> node, golang -> go), but the canonical name is used so the file
|
|
26
|
+
# matches what `mise ls` reports back and a reader is not left wondering whether
|
|
27
|
+
# two spellings mean two things.
|
|
28
|
+
DETECTORS: list[tuple[str, str, str | None, str, str | None]] = [
|
|
29
|
+
("package.json", "npm test", "npm run build", "node", "node"),
|
|
30
|
+
("pyproject.toml", "pytest", None, "python", "python"),
|
|
31
|
+
("setup.py", "pytest", None, "python", "python"),
|
|
32
|
+
("Cargo.toml", "cargo test", "cargo build", "rust", "rust"),
|
|
33
|
+
("go.mod", "go test ./...", "go build ./...", "go", "go"),
|
|
34
|
+
("pom.xml", "mvn -q test", "mvn -q compile", "java-maven", "java"),
|
|
35
|
+
("build.gradle", "./gradlew test", "./gradlew build", "java-gradle", "java"),
|
|
36
|
+
("build.gradle.kts", "./gradlew test", "./gradlew build", "java-gradle", "java"),
|
|
37
|
+
("composer.json", "vendor/bin/phpunit", None, "php", "php"),
|
|
38
|
+
("Gemfile", "bundle exec rspec", None, "ruby", "ruby"),
|
|
39
|
+
("Makefile", "make test", "make", "make", None),
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
TOOL_VERSIONS_FILE = ".tool-versions"
|
|
43
|
+
|
|
44
|
+
# Version files mise already understands. If a repo has one of these, it has
|
|
45
|
+
# already stated its intent and writing .tool-versions alongside it would create
|
|
46
|
+
# two answers to one question -- so detection reports the file instead.
|
|
47
|
+
IDIOMATIC_VERSION_FILES = {
|
|
48
|
+
"node": (".nvmrc", ".node-version"),
|
|
49
|
+
"python": (".python-version",),
|
|
50
|
+
"ruby": (".ruby-version",),
|
|
51
|
+
"java": (".java-version",),
|
|
52
|
+
"rust": ("rust-toolchain", "rust-toolchain.toml"),
|
|
53
|
+
"go": (".go-version",),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# How to ask a locally installed toolchain what version it is. Used only as a
|
|
57
|
+
# fallback, and only with fixed argv -- no value from the repository reaches a
|
|
58
|
+
# command line here.
|
|
59
|
+
_LOCAL_VERSION_PROBES: dict[str, tuple[list[str], str]] = {
|
|
60
|
+
"node": (["node", "--version"], r"v?(\d+\.\d+\.\d+)"),
|
|
61
|
+
"python": (["python3", "--version"], r"(\d+\.\d+\.\d+)"),
|
|
62
|
+
"rust": (["rustc", "--version"], r"(\d+\.\d+\.\d+)"),
|
|
63
|
+
"go": (["go", "version"], r"go(\d+\.\d+(?:\.\d+)?)"),
|
|
64
|
+
"java": (["java", "-version"], r"(?:version \")?(\d+)(?:\.|\")"),
|
|
65
|
+
"ruby": (["ruby", "--version"], r"(\d+\.\d+\.\d+)"),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# A verification command must be a single invocation. Chaining, substitution and
|
|
69
|
+
# redirection are rejected: they turn a config value into arbitrary shell. If a
|
|
70
|
+
# project genuinely needs a chain, it belongs in a script that we then call.
|
|
71
|
+
_FORBIDDEN = re.compile(r"[;&|`\n\r]|\$\(|>\s|<\s")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ConfigError(ValueError):
|
|
75
|
+
"""Raised when a configured command is not a safe single invocation."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def validate_command(command: str, label: str) -> str:
|
|
79
|
+
"""Reject shell metacharacters in a configured command.
|
|
80
|
+
|
|
81
|
+
The agent executes these through its shell tool, so a chained command would
|
|
82
|
+
widen the agent's capability well beyond "run the tests".
|
|
83
|
+
"""
|
|
84
|
+
command = command.strip()
|
|
85
|
+
if not command:
|
|
86
|
+
return ""
|
|
87
|
+
if _FORBIDDEN.search(command):
|
|
88
|
+
raise ConfigError(
|
|
89
|
+
f"{label} must be a single command without shell operators "
|
|
90
|
+
f"(; && || | ` $() > <). Got: {command!r}. "
|
|
91
|
+
f"Put multi-step logic in a script and reference the script instead."
|
|
92
|
+
)
|
|
93
|
+
return command
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class AgentConfig:
|
|
98
|
+
"""Resolved settings for one agent run against one repository."""
|
|
99
|
+
|
|
100
|
+
repo_path: Path
|
|
101
|
+
test_command: str = ""
|
|
102
|
+
build_command: str = ""
|
|
103
|
+
lint_command: str = ""
|
|
104
|
+
agent_email: str = "prism-agent@example.com"
|
|
105
|
+
agent_name: str = "PRISM Coding Agent"
|
|
106
|
+
max_attempts: int = 3
|
|
107
|
+
model_id: str = "us.anthropic.claude-sonnet-4-5-20250929-v1:0"
|
|
108
|
+
region: str = "us-west-2"
|
|
109
|
+
project_type: str = "unknown"
|
|
110
|
+
source: str = "detected" # "config" | "detected" | "none"
|
|
111
|
+
allowed_paths: list[str] = field(default_factory=list)
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def can_verify(self) -> bool:
|
|
115
|
+
"""True when a fix can be validated by running something."""
|
|
116
|
+
return bool(self.test_command)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def detect_project(repo_path: Path) -> tuple[str, str | None, str]:
|
|
120
|
+
"""Return (test_command, build_command, project_type) from marker files."""
|
|
121
|
+
for marker, test_cmd, build_cmd, label, _tool in DETECTORS:
|
|
122
|
+
if (repo_path / marker).exists():
|
|
123
|
+
return test_cmd, build_cmd, label
|
|
124
|
+
return "", None, "unknown"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def mise_tool_for(project_type: str) -> str | None:
|
|
128
|
+
"""The mise tool name for a project type, if it has one."""
|
|
129
|
+
for _marker, _t, _b, label, tool in DETECTORS:
|
|
130
|
+
if label == project_type:
|
|
131
|
+
return tool
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _manifest_version(repo_path: Path, tool: str) -> str:
|
|
136
|
+
"""Read the version the repository itself declares, if it declares one.
|
|
137
|
+
|
|
138
|
+
Preferred over the locally installed toolchain: a manifest states intent,
|
|
139
|
+
whereas whatever happens to be on the machine running the installer is an
|
|
140
|
+
accident of that machine. Only unambiguous single-version declarations are
|
|
141
|
+
used -- a range like ">=18" says what is tolerated, not what to pin, and
|
|
142
|
+
guessing a point release from it would be inventing information.
|
|
143
|
+
"""
|
|
144
|
+
def read(name: str) -> str:
|
|
145
|
+
path = repo_path / name
|
|
146
|
+
try:
|
|
147
|
+
return path.read_text(encoding="utf-8") if path.is_file() else ""
|
|
148
|
+
except (OSError, UnicodeDecodeError):
|
|
149
|
+
return ""
|
|
150
|
+
|
|
151
|
+
if tool == "go":
|
|
152
|
+
# go.mod's `go` directive is a real pin and always exact enough to use.
|
|
153
|
+
match = re.search(r"^go\s+(\d+\.\d+(?:\.\d+)?)\s*$", read("go.mod"), re.M)
|
|
154
|
+
if match:
|
|
155
|
+
return match.group(1)
|
|
156
|
+
|
|
157
|
+
if tool == "node":
|
|
158
|
+
raw = read("package.json")
|
|
159
|
+
if raw:
|
|
160
|
+
try:
|
|
161
|
+
engines = json.loads(raw).get("engines") or {}
|
|
162
|
+
except json.JSONDecodeError:
|
|
163
|
+
engines = {}
|
|
164
|
+
spec = str(engines.get("node") or "").strip()
|
|
165
|
+
# Only an exact pin. "^22.1.0" and ">=18" are ranges, and resolving
|
|
166
|
+
# them here would duplicate npm's semver logic badly.
|
|
167
|
+
if re.fullmatch(r"\d+\.\d+\.\d+", spec):
|
|
168
|
+
return spec
|
|
169
|
+
|
|
170
|
+
if tool == "rust":
|
|
171
|
+
match = re.search(r'channel\s*=\s*"([^"]+)"', read("rust-toolchain.toml"))
|
|
172
|
+
if match:
|
|
173
|
+
return match.group(1)
|
|
174
|
+
|
|
175
|
+
if tool == "ruby":
|
|
176
|
+
match = re.search(r"^\s*ruby\s+[\"'](\d+\.\d+\.\d+)[\"']", read("Gemfile"), re.M)
|
|
177
|
+
if match:
|
|
178
|
+
return match.group(1)
|
|
179
|
+
|
|
180
|
+
return ""
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _local_version(tool: str) -> str:
|
|
184
|
+
"""Ask the locally installed toolchain its version.
|
|
185
|
+
|
|
186
|
+
A fallback, and a deliberately weak one -- it records what the machine
|
|
187
|
+
running the installer happens to have. That is still far better than nothing,
|
|
188
|
+
because the alternative is a repository with no pin at all, where the harness
|
|
189
|
+
installs whatever is newest and a passing suite today can fail tomorrow for
|
|
190
|
+
reasons unrelated to any change in the repository.
|
|
191
|
+
"""
|
|
192
|
+
probe = _LOCAL_VERSION_PROBES.get(tool)
|
|
193
|
+
if not probe:
|
|
194
|
+
return ""
|
|
195
|
+
argv, pattern = probe
|
|
196
|
+
try:
|
|
197
|
+
proc = subprocess.run(argv, capture_output=True, text=True, timeout=15, check=False)
|
|
198
|
+
except (OSError, subprocess.SubprocessError):
|
|
199
|
+
return ""
|
|
200
|
+
# java -version writes to stderr; others to stdout.
|
|
201
|
+
match = re.search(pattern, (proc.stdout or "") + (proc.stderr or ""))
|
|
202
|
+
return match.group(1) if match else ""
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def detect_tool_version(repo_path: Path, project_type: str) -> dict[str, str]:
|
|
206
|
+
"""Work out what to pin in .tool-versions, and say where the answer came from.
|
|
207
|
+
|
|
208
|
+
Returns tool, version and source, where source is one of:
|
|
209
|
+
|
|
210
|
+
existing .tool-versions is already present -- nothing to do
|
|
211
|
+
idiomatic the repo has .nvmrc or similar, which mise reads directly
|
|
212
|
+
manifest the repo declares an exact version (go.mod, engines, Gemfile)
|
|
213
|
+
local taken from the toolchain installed on this machine
|
|
214
|
+
none no version could be established, so nothing should be written
|
|
215
|
+
|
|
216
|
+
`none` is a real outcome, not a failure to handle. Writing a pin that was
|
|
217
|
+
guessed would be worse than leaving the repo unpinned: it would look
|
|
218
|
+
deliberate and reviewed when it was neither.
|
|
219
|
+
"""
|
|
220
|
+
tool = mise_tool_for(project_type)
|
|
221
|
+
if not tool:
|
|
222
|
+
return {"tool": "", "version": "", "source": "none"}
|
|
223
|
+
|
|
224
|
+
if (repo_path / TOOL_VERSIONS_FILE).is_file():
|
|
225
|
+
return {"tool": tool, "version": "", "source": "existing"}
|
|
226
|
+
|
|
227
|
+
for name in IDIOMATIC_VERSION_FILES.get(tool, ()):
|
|
228
|
+
if (repo_path / name).is_file():
|
|
229
|
+
return {"tool": tool, "version": "", "source": "idiomatic", "file": name}
|
|
230
|
+
|
|
231
|
+
version = _manifest_version(repo_path, tool)
|
|
232
|
+
if version:
|
|
233
|
+
return {"tool": tool, "version": version, "source": "manifest"}
|
|
234
|
+
|
|
235
|
+
version = _local_version(tool)
|
|
236
|
+
if version:
|
|
237
|
+
return {"tool": tool, "version": version, "source": "local"}
|
|
238
|
+
|
|
239
|
+
return {"tool": tool, "version": "", "source": "none"}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def load_config(repo_path: Path, **overrides: object) -> AgentConfig:
|
|
243
|
+
"""Build an AgentConfig for `repo_path`.
|
|
244
|
+
|
|
245
|
+
Explicit keyword overrides (from CLI flags) win over the config file, which
|
|
246
|
+
wins over auto-detection. Empty-string overrides are meaningful: they mean
|
|
247
|
+
"this project has no such command", not "fall back to detection".
|
|
248
|
+
"""
|
|
249
|
+
repo_path = repo_path.resolve()
|
|
250
|
+
if not repo_path.is_dir():
|
|
251
|
+
raise ConfigError(f"Not a directory: {repo_path}")
|
|
252
|
+
|
|
253
|
+
cfg = AgentConfig(repo_path=repo_path)
|
|
254
|
+
|
|
255
|
+
config_path = repo_path / CONFIG_DIR / CONFIG_FILE
|
|
256
|
+
if config_path.exists():
|
|
257
|
+
try:
|
|
258
|
+
data = json.loads(config_path.read_text())
|
|
259
|
+
except json.JSONDecodeError as exc:
|
|
260
|
+
raise ConfigError(f"{config_path} is not valid JSON: {exc}") from exc
|
|
261
|
+
cfg.source = "config"
|
|
262
|
+
cfg.test_command = str(data.get("test_command") or "")
|
|
263
|
+
cfg.build_command = str(data.get("build_command") or "")
|
|
264
|
+
cfg.lint_command = str(data.get("lint_command") or "")
|
|
265
|
+
cfg.agent_email = str(data.get("agent_email") or cfg.agent_email)
|
|
266
|
+
cfg.agent_name = str(data.get("agent_name") or cfg.agent_name)
|
|
267
|
+
cfg.max_attempts = int(data.get("max_attempts") or cfg.max_attempts)
|
|
268
|
+
cfg.model_id = str(data.get("model_id") or cfg.model_id)
|
|
269
|
+
cfg.region = str(data.get("region") or cfg.region)
|
|
270
|
+
cfg.project_type = str(data.get("detected_project_type") or "unknown")
|
|
271
|
+
cfg.allowed_paths = list(data.get("allowed_paths") or [])
|
|
272
|
+
else:
|
|
273
|
+
test_cmd, build_cmd, label = detect_project(repo_path)
|
|
274
|
+
cfg.test_command = test_cmd
|
|
275
|
+
cfg.build_command = build_cmd or ""
|
|
276
|
+
cfg.project_type = label
|
|
277
|
+
cfg.source = "detected" if test_cmd else "none"
|
|
278
|
+
|
|
279
|
+
for key, value in overrides.items():
|
|
280
|
+
if value is None:
|
|
281
|
+
continue
|
|
282
|
+
if not hasattr(cfg, key):
|
|
283
|
+
raise ConfigError(f"Unknown config override: {key}")
|
|
284
|
+
setattr(cfg, key, value)
|
|
285
|
+
|
|
286
|
+
cfg.test_command = validate_command(cfg.test_command, "test_command")
|
|
287
|
+
cfg.build_command = validate_command(cfg.build_command, "build_command")
|
|
288
|
+
cfg.lint_command = validate_command(cfg.lint_command, "lint_command")
|
|
289
|
+
|
|
290
|
+
if cfg.max_attempts < 1:
|
|
291
|
+
raise ConfigError("max_attempts must be at least 1")
|
|
292
|
+
|
|
293
|
+
return cfg
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _detect_as_json(repo_path: Path) -> str:
|
|
297
|
+
"""Serialize detection results for a non-Python caller.
|
|
298
|
+
|
|
299
|
+
`prism-cli bootstrapper install-coding-agent` needs the same project-type
|
|
300
|
+
defaults the agent would derive, to offer them at the prompt. It shells out
|
|
301
|
+
to this rather than carrying a second copy of DETECTORS in TypeScript --
|
|
302
|
+
two tables would drift, and the symptom would be an installer that offers
|
|
303
|
+
`npm test` for a repo the agent then runs `pytest` against.
|
|
304
|
+
"""
|
|
305
|
+
test_cmd, build_cmd, label = detect_project(repo_path)
|
|
306
|
+
existing = repo_path / CONFIG_DIR / CONFIG_FILE
|
|
307
|
+
return json.dumps(
|
|
308
|
+
{
|
|
309
|
+
"test_command": test_cmd,
|
|
310
|
+
"build_command": build_cmd or "",
|
|
311
|
+
"project_type": label,
|
|
312
|
+
"config_exists": existing.exists(),
|
|
313
|
+
"toolchain": detect_tool_version(repo_path, label),
|
|
314
|
+
}
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
if __name__ == "__main__":
|
|
319
|
+
import argparse
|
|
320
|
+
import sys
|
|
321
|
+
|
|
322
|
+
parser = argparse.ArgumentParser(description="Inspect PRISM coding agent configuration.")
|
|
323
|
+
parser.add_argument("--detect", metavar="REPO", help="Print detection results for REPO as JSON")
|
|
324
|
+
parser.add_argument("--show", metavar="REPO", help="Print the fully resolved config for REPO")
|
|
325
|
+
args = parser.parse_args()
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
if args.detect:
|
|
329
|
+
print(_detect_as_json(Path(args.detect)))
|
|
330
|
+
elif args.show:
|
|
331
|
+
resolved = load_config(Path(args.show))
|
|
332
|
+
print(json.dumps({k: str(v) for k, v in vars(resolved).items()}, indent=2))
|
|
333
|
+
else:
|
|
334
|
+
parser.print_help()
|
|
335
|
+
sys.exit(2)
|
|
336
|
+
except ConfigError as exc:
|
|
337
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
338
|
+
sys.exit(1)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# PRISM coding agent — single harness environment for every toolchain.
|
|
2
|
+
#
|
|
3
|
+
# One harness instead of one per language. The reason is a hard limit: an
|
|
4
|
+
# AgentCore Runtime image may not exceed 2 GB and that quota is not adjustable,
|
|
5
|
+
# while the official language images total ~9.3 GB (rust:1 alone is 1.65 GB, or
|
|
6
|
+
# 83% of the budget). A fat image is arithmetically impossible.
|
|
7
|
+
#
|
|
8
|
+
# So this image ships a version *manager* rather than versions. The toolchain is
|
|
9
|
+
# installed at session start with InvokeAgentRuntimeCommand -- no model
|
|
10
|
+
# reasoning, no tokens -- reading the target repository's own version files.
|
|
11
|
+
# AWS documents this path: "The base environment includes Python and bash. For
|
|
12
|
+
# tools like git, node, or other runtimes, install them at session start."
|
|
13
|
+
#
|
|
14
|
+
# It also closes the version matrix. node@18 and node@22 stop being two
|
|
15
|
+
# harnesses and become a line in the repo's .tool-versions, which suits an
|
|
16
|
+
# architecture where the repository already describes itself.
|
|
17
|
+
#
|
|
18
|
+
# Build (must be arm64 -- the harness rejects anything else):
|
|
19
|
+
# docker buildx build --platform linux/arm64 -t prism-coding-agent:dev .
|
|
20
|
+
#
|
|
21
|
+
# The harness overrides ENTRYPOINT and CMD to keep the container alive as an
|
|
22
|
+
# environment, so neither is set here. Installed software, filesystem and
|
|
23
|
+
# environment variables are what carry over.
|
|
24
|
+
|
|
25
|
+
FROM python:3.12-slim
|
|
26
|
+
|
|
27
|
+
# Pinned rather than floated: a harness image that silently changes its tool
|
|
28
|
+
# manager between builds makes an agent regression untraceable.
|
|
29
|
+
ARG MISE_VERSION=v2026.8.11
|
|
30
|
+
ARG TARGETARCH
|
|
31
|
+
|
|
32
|
+
# git is the one non-negotiable addition -- an agent that cannot branch, diff or
|
|
33
|
+
# produce a patch is not a coding agent, and neither the harness base nor the
|
|
34
|
+
# slim Python image carries it.
|
|
35
|
+
#
|
|
36
|
+
# build-essential is here because it is load-bearing rather than convenient:
|
|
37
|
+
# node-gyp needs a compiler for native modules, and rustc needs a linker. Omit
|
|
38
|
+
# it and the failures appear later, inside the agent's own test run, where they
|
|
39
|
+
# read as the agent's fault.
|
|
40
|
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
41
|
+
git \
|
|
42
|
+
ca-certificates \
|
|
43
|
+
curl \
|
|
44
|
+
xz-utils \
|
|
45
|
+
unzip \
|
|
46
|
+
pkg-config \
|
|
47
|
+
build-essential \
|
|
48
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
49
|
+
|
|
50
|
+
# mise resolves per directory, which is what makes a multi-language monorepo
|
|
51
|
+
# work: the Node web app and the Python service each get their own versions with
|
|
52
|
+
# no routing decision made outside the repository.
|
|
53
|
+
#
|
|
54
|
+
# TARGETARCH is Docker's vocabulary (amd64/arm64); mise publishes x64/arm64.
|
|
55
|
+
# Mapping between them is not cosmetic -- a build that assumes they match
|
|
56
|
+
# succeeds on arm64 and fails with a bare curl exit 22 on x86, which is a
|
|
57
|
+
# confusing way to discover a naming mismatch.
|
|
58
|
+
RUN set -eux; \
|
|
59
|
+
case "${TARGETARCH:-arm64}" in \
|
|
60
|
+
amd64|x86_64) arch=x64 ;; \
|
|
61
|
+
arm64|aarch64) arch=arm64 ;; \
|
|
62
|
+
*) echo "unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
|
|
63
|
+
esac; \
|
|
64
|
+
url="https://github.com/jdx/mise/releases/download/${MISE_VERSION}/mise-${MISE_VERSION}-linux-${arch}.tar.gz"; \
|
|
65
|
+
curl -fsSL "$url" -o /tmp/mise.tar.gz; \
|
|
66
|
+
tar -xzf /tmp/mise.tar.gz -C /tmp; \
|
|
67
|
+
install -m 0755 /tmp/mise/bin/mise /usr/local/bin/mise; \
|
|
68
|
+
rm -rf /tmp/mise /tmp/mise.tar.gz; \
|
|
69
|
+
mise --version
|
|
70
|
+
|
|
71
|
+
# Toolchains land outside $HOME so an EFS mount can be pointed here to cache
|
|
72
|
+
# them across sessions. Without that mount every run reinstalls, which is the
|
|
73
|
+
# main cost of this design; with it, the second run of any version is instant.
|
|
74
|
+
ENV MISE_DATA_DIR=/opt/mise \
|
|
75
|
+
MISE_CACHE_DIR=/opt/mise/cache \
|
|
76
|
+
PATH=/opt/mise/shims:/usr/local/bin:$PATH
|
|
77
|
+
|
|
78
|
+
# Trust configs from anywhere. mise otherwise refuses to read a .tool-versions
|
|
79
|
+
# it has not been told to trust, and in a disposable microVM cloning an
|
|
80
|
+
# arbitrary repository there is no prior trust to establish -- the isolation
|
|
81
|
+
# boundary is the VM, not the config file.
|
|
82
|
+
ENV MISE_TRUSTED_CONFIG_PATHS=/ \
|
|
83
|
+
MISE_YES=1
|
|
84
|
+
|
|
85
|
+
RUN mkdir -p /opt/mise /workspace
|
|
86
|
+
WORKDIR /workspace
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Create or update the AgentCore harness, and wait for it to reach READY.
|
|
3
|
+
|
|
4
|
+
Called by deploy-harness.sh. A separate file rather than an inline heredoc so the
|
|
5
|
+
error handling is readable and so it can be run on its own against an existing
|
|
6
|
+
image.
|
|
7
|
+
|
|
8
|
+
The AWS CLI has no create-harness -- verified on 2.36.19, where the harness
|
|
9
|
+
operations are absent from `aws bedrock-agentcore-control help` while boto3 exposes
|
|
10
|
+
them. That gap is how an earlier version of this project came to call
|
|
11
|
+
InvokeAgentRuntime instead of InvokeHarness: the CLI was the reference, and the CLI
|
|
12
|
+
does not know harnesses exist.
|
|
13
|
+
|
|
14
|
+
Configuration arrives through the environment rather than argv so the shell caller
|
|
15
|
+
does not have to quote a model id or an ARN.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
|
|
24
|
+
import boto3
|
|
25
|
+
from botocore.exceptions import ClientError
|
|
26
|
+
|
|
27
|
+
READY_TIMEOUT_SECONDS = 300
|
|
28
|
+
POLL_SECONDS = 5
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def env(name: str, default: str = "") -> str:
|
|
32
|
+
value = os.environ.get(name, default)
|
|
33
|
+
if not value:
|
|
34
|
+
sys.exit(f"{name} is required")
|
|
35
|
+
return value
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def find_existing(client, name: str) -> str | None:
|
|
39
|
+
"""Return the harness id for `name`, or None.
|
|
40
|
+
|
|
41
|
+
Matched on name because that is what the caller controls; the id carries a
|
|
42
|
+
generated suffix (PrismCodingAgent-XC93iEIa7W) that nobody can predict, so
|
|
43
|
+
re-running the deploy would otherwise create a second harness every time.
|
|
44
|
+
"""
|
|
45
|
+
paginator_token = None
|
|
46
|
+
while True:
|
|
47
|
+
kwargs = {"nextToken": paginator_token} if paginator_token else {}
|
|
48
|
+
page = client.list_harnesses(**kwargs)
|
|
49
|
+
for harness in page.get("harnesses", []):
|
|
50
|
+
if harness.get("harnessName") == name:
|
|
51
|
+
return harness["harnessId"]
|
|
52
|
+
paginator_token = page.get("nextToken")
|
|
53
|
+
if not paginator_token:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def describe(client, harness_id: str) -> dict:
|
|
58
|
+
"""Fetch one harness, unwrapping the response envelope.
|
|
59
|
+
|
|
60
|
+
`get_harness` returns the object nested under a `harness` key, while
|
|
61
|
+
`list_harnesses` returns flat objects in `harnesses`. Reading `status` off the
|
|
62
|
+
top level of a get_harness response therefore yields None forever: the wait
|
|
63
|
+
below span its full timeout and then reported failure on a harness that had
|
|
64
|
+
been READY the whole time. The same mistake also made `maxIterations` look
|
|
65
|
+
unset on a harness that had it set to 40.
|
|
66
|
+
"""
|
|
67
|
+
return client.get_harness(harnessId=harness_id).get("harness", {})
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def wait_ready(client, harness_id: str) -> None:
|
|
71
|
+
deadline = time.monotonic() + READY_TIMEOUT_SECONDS
|
|
72
|
+
last = ""
|
|
73
|
+
while time.monotonic() < deadline:
|
|
74
|
+
status = describe(client, harness_id).get("status", "")
|
|
75
|
+
if status == "READY":
|
|
76
|
+
return
|
|
77
|
+
if status in {"CREATE_FAILED", "UPDATE_FAILED", "FAILED"}:
|
|
78
|
+
sys.exit(f"harness {harness_id} reached {status}")
|
|
79
|
+
if status != last:
|
|
80
|
+
print(f" status {status or 'unknown'}", file=sys.stderr)
|
|
81
|
+
last = status
|
|
82
|
+
time.sleep(POLL_SECONDS)
|
|
83
|
+
sys.exit(f"harness {harness_id} did not reach READY within "
|
|
84
|
+
f"{READY_TIMEOUT_SECONDS}s (last status {last or 'unknown'})")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def main() -> int:
|
|
88
|
+
region = env("AGENTCORE_REGION")
|
|
89
|
+
name = env("AGENTCORE_NAME")
|
|
90
|
+
image = env("AGENTCORE_IMAGE")
|
|
91
|
+
role = env("AGENTCORE_ROLE")
|
|
92
|
+
model = env("AGENTCORE_MODEL")
|
|
93
|
+
max_iterations = int(env("AGENTCORE_MAX_ITERATIONS", "100"))
|
|
94
|
+
timeout_seconds = int(env("AGENTCORE_TIMEOUT", "1800"))
|
|
95
|
+
|
|
96
|
+
client = boto3.client("bedrock-agentcore-control", region_name=region)
|
|
97
|
+
|
|
98
|
+
spec = {
|
|
99
|
+
"model": {
|
|
100
|
+
"bedrockModelConfig": {
|
|
101
|
+
"modelId": model,
|
|
102
|
+
"maxTokens": 8192,
|
|
103
|
+
# Zero, because the task is to reproduce a defect and fix it, not
|
|
104
|
+
# to be inventive about what the defect might be.
|
|
105
|
+
"temperature": 0.0,
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
"environmentArtifact": {"containerConfiguration": {"containerUri": image}},
|
|
109
|
+
"executionRoleArn": role,
|
|
110
|
+
"maxIterations": max_iterations,
|
|
111
|
+
"timeoutSeconds": timeout_seconds,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
existing = find_existing(client, name)
|
|
115
|
+
try:
|
|
116
|
+
if existing:
|
|
117
|
+
print(f" updating {existing}", file=sys.stderr)
|
|
118
|
+
client.update_harness(harnessId=existing, **spec)
|
|
119
|
+
harness_id = existing
|
|
120
|
+
else:
|
|
121
|
+
print(f" creating {name}", file=sys.stderr)
|
|
122
|
+
created = client.create_harness(harnessName=name, **spec)
|
|
123
|
+
harness_id = created["harnessId"]
|
|
124
|
+
except ClientError as exc:
|
|
125
|
+
code = exc.response.get("Error", {}).get("Code", "")
|
|
126
|
+
message = exc.response.get("Error", {}).get("Message", str(exc))
|
|
127
|
+
if code == "AccessDeniedException":
|
|
128
|
+
sys.exit(f"{code}: {message}\n"
|
|
129
|
+
f" The calling identity needs bedrock-agentcore:CreateHarness /\n"
|
|
130
|
+
f" UpdateHarness / ListHarnesses, and iam:PassRole for {role}.")
|
|
131
|
+
sys.exit(f"{code}: {message}")
|
|
132
|
+
|
|
133
|
+
wait_ready(client, harness_id)
|
|
134
|
+
|
|
135
|
+
# The invocable ARN is the harness ARN. Its DEFAULT endpoint also exists and is
|
|
136
|
+
# READY, and passing that endpoint ARN to InvokeHarness is rejected -- worth
|
|
137
|
+
# stating because the endpoint being present makes it look like the right thing
|
|
138
|
+
# to use.
|
|
139
|
+
print(describe(client, harness_id)["arn"])
|
|
140
|
+
return 0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
if __name__ == "__main__":
|
|
144
|
+
sys.exit(main())
|