@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.
Files changed (43) hide show
  1. package/dist/assets/coding-agent/README.md +824 -0
  2. package/dist/assets/coding-agent/agent.py +381 -0
  3. package/dist/assets/coding-agent/config.py +338 -0
  4. package/dist/assets/coding-agent/deploy/Dockerfile +86 -0
  5. package/dist/assets/coding-agent/deploy/create_harness.py +144 -0
  6. package/dist/assets/coding-agent/deploy/deploy-harness.sh +201 -0
  7. package/dist/assets/coding-agent/deploy/prism-coding-agent.yml +399 -0
  8. package/dist/assets/coding-agent/eval/run_eval.py +430 -0
  9. package/dist/assets/coding-agent/eval/run_harness_eval.py +262 -0
  10. package/dist/assets/coding-agent/eval/stubs/001-tags-element-validation.json +11 -0
  11. package/dist/assets/coding-agent/eval/stubs/002-status-filter.json +11 -0
  12. package/dist/assets/coding-agent/eval/stubs/003-refuse-test-deletion.json +11 -0
  13. package/dist/assets/coding-agent/fixtures/001-tags-element-validation.json +12 -0
  14. package/dist/assets/coding-agent/fixtures/002-status-filter.json +12 -0
  15. package/dist/assets/coding-agent/fixtures/003-refuse-test-deletion.json +10 -0
  16. package/dist/assets/coding-agent/pyproject.toml +28 -0
  17. package/dist/assets/coding-agent/requirements.txt +8 -0
  18. package/dist/assets/coding-agent/system_prompt.py +290 -0
  19. package/dist/assets/coding-agent/tools/__init__.py +11 -0
  20. package/dist/assets/coding-agent/tools/create_pr.py +141 -0
  21. package/dist/assets/coding-agent/tools/git_ops.py +197 -0
  22. package/dist/assets/github-workflows/prism-agent-eval.yml +2 -1
  23. package/dist/assets/github-workflows/prism-ai-metrics.yml +12 -3
  24. package/dist/assets/github-workflows/prism-eval-gate-kiro.yml +4 -3
  25. package/dist/assets/github-workflows/prism-eval-gate.yml +3 -2
  26. package/dist/assets/gitlab-workflows/prism-agent-eval.yml +2 -1
  27. package/dist/assets/gitlab-workflows/prism-ai-metrics.yml +3 -2
  28. package/dist/assets/gitlab-workflows/prism-eval-gate.yml +2 -1
  29. package/dist/assets/infra/lib/constructs/otel-collector-construct.ts +134 -2
  30. package/dist/assets/infra/lib/dashboard-stack.ts +79 -95
  31. package/dist/assets/infra/lib/lambda/metrics-processor.ts +99 -1
  32. package/dist/assets/infra/lib/lambda/otel-receiver.ts +318 -29
  33. package/dist/assets/infra/lib/lambda/productivity-widget.ts +85 -4
  34. package/dist/assets/infra/lib/lambda/velocity-widget.ts +265 -0
  35. package/dist/assets/infra/lib/metrics-pipeline-stack.ts +5 -0
  36. package/dist/src/commands/bootstrapper/install-coding-agent.d.ts +62 -0
  37. package/dist/src/commands/bootstrapper/install-coding-agent.d.ts.map +1 -0
  38. package/dist/src/commands/bootstrapper/install-coding-agent.js +614 -0
  39. package/dist/src/commands/bootstrapper/install-coding-agent.js.map +1 -0
  40. package/dist/src/commands/bootstrapper/setup-github-oidc.d.ts.map +1 -1
  41. package/dist/src/commands/bootstrapper/setup-github-oidc.js +23 -0
  42. package/dist/src/commands/bootstrapper/setup-github-oidc.js.map +1 -1
  43. 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())