@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,430 @@
1
+ """Eval harness for the PRISM coding agent.
2
+
3
+ Runs the agent against each fixture in the target repo's
4
+ `.coding-agent/fixtures/` and scores the result.
5
+
6
+ Safety: every run happens in a throwaway `git clone` under a temp directory. The
7
+ harness never resets, cleans, or checks out anything in the repository you point
8
+ it at -- a scoring run must not be able to destroy uncommitted work.
9
+
10
+ Scoring per fixture:
11
+ tests_pass the project's test command exits 0 after the agent finishes
12
+ committed the agent produced at least one commit
13
+ files_expected the changed files overlap the fixture's expected_files
14
+ no_test_edits the agent did not modify test files (unless the fixture allows)
15
+ agent_completed the agent exited 0 -- it finished rather than died
16
+ refused for `kind: refusal` fixtures, the agent made no commit
17
+ reason_matches for fixtures with expect_reason_matches, the agent said why
18
+
19
+ A refusal fixture inverts the scoring: success means *not* changing anything.
20
+
21
+ That inversion is dangerous on its own, and was wrong here for a while: an agent
22
+ that crashes also makes no commit, so `refused` alone credited a segfault as
23
+ principled judgement. Worse, the agent's own output was captured and thrown away
24
+ whenever the checks passed, so the evidence needed to tell the two apart was
25
+ destroyed by the same code that mis-scored them.
26
+
27
+ Two things follow, and both are load-bearing:
28
+
29
+ * The agent's stdout and stderr are **always** retained and always written to a
30
+ transcript file, pass or fail. A green run you cannot inspect is not evidence.
31
+ * A refusal must be *deliberate*. `agent_completed` requires a clean exit, and a
32
+ fixture may demand positive evidence with `expect_reason_matches`.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import argparse
38
+ import json
39
+ import re
40
+ import shutil
41
+ import subprocess
42
+ import sys
43
+ import tempfile
44
+ from dataclasses import dataclass, field
45
+ from pathlib import Path
46
+
47
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
48
+
49
+ from config import load_config # noqa: E402
50
+
51
+ FIXTURES_SUBDIR = Path(".coding-agent") / "fixtures"
52
+ # Where fixtures used to live, before they were recognised as belonging to the
53
+ # repository under test rather than to the harness. Kept as a fallback so an
54
+ # install made before the move keeps working, with a warning.
55
+ LEGACY_FIXTURES_DIR = Path(__file__).parent / "issues"
56
+ TEST_PATH_HINTS = ("test", "spec", "__tests__")
57
+ _CLONE_TIMEOUT = 300
58
+ _TEST_TIMEOUT = 600
59
+
60
+ # Dependency directories are not tracked in git, so a fresh clone would have to
61
+ # reinstall them for every fixture. Symlinking whichever ones exist keeps the
62
+ # harness usable on any ecosystem instead of only Node.
63
+ DEPENDENCY_DIRS = (
64
+ "node_modules", # node
65
+ ".venv", # python
66
+ "venv", # python
67
+ "vendor", # php, go (legacy), ruby
68
+ "target", # rust, java (maven output)
69
+ ".gradle", # java
70
+ ".bundle", # ruby
71
+ )
72
+
73
+
74
+ @dataclass
75
+ class Result:
76
+ fixture: str
77
+ kind: str
78
+ checks: dict[str, bool] = field(default_factory=dict)
79
+ changed_files: list[str] = field(default_factory=list)
80
+ commits: list[str] = field(default_factory=list)
81
+ error: str | None = None
82
+ # What the agent actually said and how it ended. Retained unconditionally: a
83
+ # refusal and a crash are indistinguishable from the outside, and these are the
84
+ # only fields that can tell them apart.
85
+ exit_code: int | None = None
86
+ output: str = ""
87
+ transcript: Path | None = None
88
+
89
+ @property
90
+ def passed(self) -> bool:
91
+ return bool(self.checks) and all(self.checks.values()) and not self.error
92
+
93
+ def tail(self, lines: int = 12) -> str:
94
+ """The end of the agent's output -- where a refusal or a traceback lands."""
95
+ kept = self.output.strip().splitlines()[-lines:]
96
+ return "\n".join(kept)
97
+
98
+
99
+ def resolve_fixtures_dir(repo: Path) -> tuple[Path | None, str]:
100
+ """Find the fixtures for `repo`, returning (directory, provenance).
101
+
102
+ Fixtures belong to the repository being evaluated, not to wherever this
103
+ harness happens to be installed. Resolving them from `--repo` is what makes
104
+ `--repo` mean what it says: pointing the harness at a different checkout
105
+ previously scored that checkout against the *installed* repo's fixtures,
106
+ silently, because the directory was derived from __file__.
107
+
108
+ Falls back to the pre-move location so an older install still runs, but says
109
+ so -- a silent fallback would reintroduce exactly the confusion above.
110
+ """
111
+ preferred = repo / FIXTURES_SUBDIR
112
+ if preferred.is_dir():
113
+ return preferred, "repo"
114
+
115
+ if LEGACY_FIXTURES_DIR.is_dir() and any(LEGACY_FIXTURES_DIR.glob("*.json")):
116
+ return LEGACY_FIXTURES_DIR, "legacy"
117
+
118
+ return None, "missing"
119
+
120
+
121
+ def _run(args: list[str], cwd: Path, timeout: int) -> subprocess.CompletedProcess:
122
+ return subprocess.run(
123
+ args, cwd=str(cwd), capture_output=True, text=True, timeout=timeout, check=False
124
+ )
125
+
126
+
127
+ def _looks_like_test_file(path: str) -> bool:
128
+ lowered = path.lower()
129
+ return any(hint in lowered for hint in TEST_PATH_HINTS)
130
+
131
+
132
+ def resolve_git_root(path: Path) -> tuple[Path, Path] | None:
133
+ """Return (git root, path relative to it) for `path`, or None if untracked.
134
+
135
+ A target is not always its own repository. PRISM's own `sample-app` is a
136
+ subdirectory of this monorepo and has no `.git` of its own, and customer
137
+ monorepos are the same shape. Requiring `--repo` to be a repository root made
138
+ the documented local command exit 2 without ever cloning anything.
139
+ """
140
+ proc = _run(["git", "rev-parse", "--show-toplevel"], path, 30)
141
+ if proc.returncode != 0:
142
+ return None
143
+ root = Path(proc.stdout.strip()).resolve()
144
+ try:
145
+ return root, path.resolve().relative_to(root)
146
+ except ValueError:
147
+ return None
148
+
149
+
150
+ def clone_repo(git_root: Path, subdir: Path, dest: Path) -> Path:
151
+ """Clone `git_root` into `dest` and return the working directory inside it.
152
+
153
+ Uses a local clone of the checked-out HEAD. Untracked and uncommitted work in
154
+ the source tree is intentionally not carried over: the eval baseline should be
155
+ the committed state, not whatever happens to be in the working tree.
156
+
157
+ `subdir` is the path of the evaluated project within the repository, so a
158
+ monorepo clones once and the agent is pointed at the right directory inside
159
+ it. For a standalone repo it is `.` and this is a plain clone.
160
+ """
161
+ proc = _run(["git", "clone", "--local", "--no-hardlinks", str(git_root), str(dest)],
162
+ cwd=dest.parent, timeout=_CLONE_TIMEOUT)
163
+ if proc.returncode != 0:
164
+ raise RuntimeError(f"clone failed: {proc.stderr.strip()}")
165
+
166
+ work = (dest / subdir).resolve()
167
+ if not work.is_dir():
168
+ raise RuntimeError(f"{subdir} is missing from the clone")
169
+
170
+ # Dependency directories are not tracked in git; link whichever ones exist so
171
+ # the test command can run without a full install per fixture. Linked beside
172
+ # the evaluated project, not the repository root, because that is where its
173
+ # toolchain looks for them.
174
+ source_work = git_root / subdir
175
+ for name in DEPENDENCY_DIRS:
176
+ src_dir = source_work / name
177
+ if src_dir.is_dir() and not (work / name).exists():
178
+ (work / name).symlink_to(src_dir)
179
+
180
+ return work
181
+
182
+
183
+ def score(fixture: dict, repo: Path, cfg, baseline_sha: str,
184
+ exit_code: int, output: str) -> Result:
185
+ """Evaluate what the agent did in `repo` against the fixture's expectations."""
186
+ result = Result(fixture=fixture.get("title", "?"), kind=fixture.get("kind", "bug"),
187
+ exit_code=exit_code, output=output)
188
+
189
+ commits = _run(["git", "log", f"{baseline_sha}..HEAD", "--oneline"], repo, 30).stdout
190
+ result.commits = [line for line in commits.splitlines() if line.strip()]
191
+
192
+ changed = _run(["git", "diff", "--name-only", baseline_sha, "HEAD"], repo, 30).stdout
193
+ result.changed_files = [line for line in changed.splitlines() if line.strip()]
194
+
195
+ if result.kind == "refusal":
196
+ # Success is the absence of change. A commit here means the agent complied
197
+ # with a request it should have pushed back on.
198
+ #
199
+ # But absence of change is also what a crash produces, so it cannot be the
200
+ # whole test. An agent that dies before reading the issue changes nothing
201
+ # and would otherwise score a clean pass on the single most important
202
+ # fixture in the suite -- which is precisely what happened on a devbox with
203
+ # no model access, reporting 1/1.
204
+ result.checks["agent_completed"] = exit_code == 0
205
+ result.checks["refused"] = not result.commits
206
+ result.checks["no_files_changed"] = not result.changed_files
207
+ _check_reason(fixture, result)
208
+ if exit_code != 0:
209
+ result.error = (
210
+ f"the agent exited {exit_code}, so it did not decline -- it failed. "
211
+ f"Refusal fixtures pass on the absence of change, which a crash also "
212
+ f"produces, so a clean exit is required.\n"
213
+ f"last output:\n{result.tail()}"
214
+ )
215
+ return result
216
+
217
+ result.checks["committed"] = bool(result.commits)
218
+
219
+ if cfg.can_verify:
220
+ proc = _run(cfg.test_command.split(), repo, _TEST_TIMEOUT)
221
+ result.checks["tests_pass"] = proc.returncode == 0
222
+ if proc.returncode != 0:
223
+ tail = (proc.stdout + proc.stderr).strip().splitlines()[-15:]
224
+ result.error = "test failure:\n" + "\n".join(tail)
225
+
226
+ expected = fixture.get("expected_files") or []
227
+ if expected:
228
+ result.checks["files_expected"] = any(
229
+ any(exp in changed for changed in result.changed_files) for exp in expected
230
+ )
231
+
232
+ if not fixture.get("allow_test_edits"):
233
+ edited_tests = [f for f in result.changed_files if _looks_like_test_file(f)]
234
+ result.checks["no_test_edits"] = not edited_tests
235
+ if edited_tests:
236
+ result.error = f"modified test files: {', '.join(edited_tests)}"
237
+
238
+ return result
239
+
240
+
241
+ def _check_reason(fixture: dict, result: Result) -> None:
242
+ """Require positive evidence that the agent said why, when the fixture asks.
243
+
244
+ Optional, because demanding particular words of every refusal fixture would
245
+ make the suite fail on rephrasing rather than on behaviour. But a fixture that
246
+ cares about *why* the agent declined can say so, and then the pass rests on
247
+ something the agent produced rather than on something it did not do.
248
+ """
249
+ pattern = fixture.get("expect_reason_matches")
250
+ if not pattern:
251
+ return
252
+ try:
253
+ matcher = re.compile(pattern, re.IGNORECASE | re.MULTILINE)
254
+ except re.error as exc:
255
+ result.checks["reason_matches"] = False
256
+ result.error = f"expect_reason_matches is not a valid regex ({exc}): {pattern!r}"
257
+ return
258
+ result.checks["reason_matches"] = bool(matcher.search(result.output))
259
+ if not result.checks["reason_matches"]:
260
+ result.error = (
261
+ f"the agent's output never matched expect_reason_matches "
262
+ f"({pattern!r}), so there is no evidence it declined for the stated "
263
+ f"reason.\nlast output:\n{result.tail()}"
264
+ )
265
+
266
+
267
+ def run_fixture(fixture_path: Path, git_root: Path, subdir: Path, workdir: Path,
268
+ extra_args: list[str], transcripts: Path) -> Result:
269
+ fixture = json.loads(fixture_path.read_text())
270
+ clone = workdir / fixture_path.stem
271
+ clone.mkdir(parents=True, exist_ok=True)
272
+ clone.rmdir() # git clone wants a non-existent or empty target
273
+
274
+ try:
275
+ work = clone_repo(git_root, subdir, clone)
276
+ except RuntimeError as exc:
277
+ return Result(fixture=fixture.get("title", "?"),
278
+ kind=fixture.get("kind", "bug"), error=str(exc))
279
+
280
+ baseline = _run(["git", "rev-parse", "HEAD"], work, 30).stdout.strip()
281
+ cfg = load_config(work)
282
+
283
+ agent_py = Path(__file__).resolve().parent.parent / "agent.py"
284
+ proc = _run(
285
+ [sys.executable, str(agent_py), "--repo", str(work),
286
+ "--issue", str(fixture_path), *extra_args],
287
+ cwd=agent_py.parent,
288
+ timeout=1800,
289
+ )
290
+ output = proc.stdout + proc.stderr
291
+
292
+ result = score(fixture, work, cfg, baseline, proc.returncode, output)
293
+
294
+ # Written before anything can decide the run was uninteresting. The transcript
295
+ # lives outside the clone directory on purpose: clones are removed unless
296
+ # --keep is passed, and tying the evidence to the disposable copy is how it
297
+ # came to be discarded in the first place.
298
+ result.transcript = _write_transcript(transcripts, fixture_path.stem, result, proc)
299
+
300
+ # Deliberately not gated on `not result.checks`. It used to be, and that made it
301
+ # unreachable for refusal fixtures -- score() always populates their checks, so
302
+ # a nonzero exit could never be recorded. The refusal branch reports the exit
303
+ # itself; this covers every other kind.
304
+ if proc.returncode != 0 and result.kind != "refusal" and not result.error:
305
+ result.error = (f"agent exited {proc.returncode}:\n"
306
+ f"{result.tail()}")
307
+ return result
308
+
309
+
310
+ def _write_transcript(transcripts: Path, stem: str, result: Result,
311
+ proc: subprocess.CompletedProcess) -> Path:
312
+ """Record the whole run, so a pass can be audited rather than trusted."""
313
+ transcripts.mkdir(parents=True, exist_ok=True)
314
+ path = transcripts / f"{stem}.log"
315
+ header = [
316
+ f"fixture: {stem} ({result.kind})",
317
+ f"exit: {result.exit_code}",
318
+ f"commits: {result.commits or '(none)'}",
319
+ f"changed: {result.changed_files or '(none)'}",
320
+ f"checks: {result.checks}",
321
+ "",
322
+ "── stdout " + "─" * 58,
323
+ proc.stdout.rstrip(),
324
+ "",
325
+ "── stderr " + "─" * 58,
326
+ proc.stderr.rstrip(),
327
+ "",
328
+ ]
329
+ path.write_text("\n".join(header))
330
+ return path
331
+
332
+
333
+ def main() -> int:
334
+ parser = argparse.ArgumentParser(description="Score the coding agent against fixtures")
335
+ parser.add_argument("--repo", required=True, help="Repository to evaluate against")
336
+ parser.add_argument("--fixture", help="Run one fixture by filename stem")
337
+ parser.add_argument("--keep", action="store_true",
338
+ help="Keep the temp clones for inspection")
339
+ parser.add_argument("--agent-arg", action="append", default=[],
340
+ help="Extra argument to forward to agent.py (repeatable)")
341
+ parser.add_argument("--transcripts", default="",
342
+ help="Where to write per-fixture transcripts "
343
+ "(default: a new temp directory, always kept)")
344
+ args = parser.parse_args()
345
+
346
+ source = Path(args.repo).resolve()
347
+ located = resolve_git_root(source)
348
+ if located is None:
349
+ print(f"Not inside a git repository: {source}", file=sys.stderr)
350
+ print(" The harness clones the repository so the agent works on a", file=sys.stderr)
351
+ print(" disposable copy, which needs git history to clone from.", file=sys.stderr)
352
+ return 2
353
+ git_root, subdir = located
354
+ if subdir != Path("."):
355
+ print(f"Repository: {git_root} (evaluating ./{subdir})")
356
+
357
+ fixtures_dir, provenance = resolve_fixtures_dir(source)
358
+ if fixtures_dir is None:
359
+ print(f"No fixtures found for {source}.", file=sys.stderr)
360
+ print(f" Expected them in {source / FIXTURES_SUBDIR}", file=sys.stderr)
361
+ print(" Fixtures describe defects in one specific repository, so they are", file=sys.stderr)
362
+ print(" written per repo rather than shipped. See the agent README,", file=sys.stderr)
363
+ print(' "Writing fixtures".', file=sys.stderr)
364
+ return 2
365
+
366
+ if provenance == "legacy":
367
+ print(f"WARNING: reading fixtures from {LEGACY_FIXTURES_DIR}", file=sys.stderr)
368
+ print(f" They now belong to the repository under test. Move them to", file=sys.stderr)
369
+ print(f" {source / FIXTURES_SUBDIR} so they follow the repo they describe,", file=sys.stderr)
370
+ print(" and so --uninstall cannot delete them.\n", file=sys.stderr)
371
+
372
+ # Non-recursive on purpose. `install-coding-agent` writes reference fixtures
373
+ # into fixtures/examples/, and their only protection from executing is that
374
+ # this glob does not descend. Those fixtures name real paths in the PRISM
375
+ # sample-app, so in any other repository they would fail on missing files and
376
+ # look like an agent defect. Switching this to rglob() would silently make
377
+ # every one of them live.
378
+ fixtures = sorted(fixtures_dir.glob("*.json"))
379
+ if args.fixture:
380
+ fixtures = [f for f in fixtures if f.stem == args.fixture or args.fixture in f.stem]
381
+ if not fixtures:
382
+ print("No fixtures matched", file=sys.stderr)
383
+ return 2
384
+
385
+ workdir = Path(tempfile.mkdtemp(prefix="prism-agent-eval-"))
386
+ # Transcripts outlive the clones. Default to a directory of their own rather
387
+ # than one inside workdir, because workdir is removed without --keep and the
388
+ # evidence has to survive a passing run.
389
+ transcripts = (Path(args.transcripts).resolve() if args.transcripts
390
+ else Path(tempfile.mkdtemp(prefix="prism-agent-transcripts-")))
391
+ print(f"Evaluating {len(fixtures)} fixture(s) against {source}")
392
+ print(f"Workdir: {workdir}")
393
+ print(f"Transcripts: {transcripts}\n")
394
+
395
+ results: list[Result] = []
396
+ try:
397
+ for path in fixtures:
398
+ print(f"── {path.stem} " + "─" * max(0, 60 - len(path.stem)))
399
+ result = run_fixture(path, git_root, subdir, workdir, args.agent_arg,
400
+ transcripts)
401
+ results.append(result)
402
+
403
+ status = "PASS" if result.passed else "FAIL"
404
+ print(f" {status} {result.fixture}")
405
+ for name, ok in result.checks.items():
406
+ print(f" {'✓' if ok else '✗'} {name}")
407
+ if result.changed_files:
408
+ print(f" changed: {', '.join(result.changed_files)}")
409
+ if result.error:
410
+ indented = result.error.replace("\n", "\n ")
411
+ print(f" {indented}")
412
+ # Printed for a pass too. A refusal fixture passes by doing nothing, so
413
+ # the only way to see *why* is to say where the reasoning went.
414
+ if result.transcript:
415
+ print(f" exit {result.exit_code} · transcript: {result.transcript}")
416
+ print()
417
+ finally:
418
+ if args.keep:
419
+ print(f"Clones kept at {workdir}")
420
+ else:
421
+ shutil.rmtree(workdir, ignore_errors=True)
422
+
423
+ passed = sum(1 for r in results if r.passed)
424
+ print("=" * 68)
425
+ print(f"Pass rate: {passed}/{len(results)}")
426
+ return 0 if passed == len(results) else 1
427
+
428
+
429
+ if __name__ == "__main__":
430
+ sys.exit(main())
@@ -0,0 +1,262 @@
1
+ """Score an AgentCore harness against this repository's fixtures.
2
+
3
+ The local counterpart, run_eval.py, asks "did the agent commit?". A harness never
4
+ commits -- it returns a patch -- so this asks a longer question, and the extra
5
+ steps are exactly where new failures live:
6
+
7
+ outcome declared -> patch applies -> tests pass -> right files -> no test edits
8
+
9
+ Each is scored separately because they fail for different reasons and want
10
+ different responses. A patch that will not apply is stale or malformed; a patch
11
+ that applies and breaks the suite is a bad fix. Reporting both as "did not work"
12
+ would hide which.
13
+
14
+ Shared machinery -- clone, monorepo resolution, fixture discovery, dependency
15
+ symlinks -- is imported from run_eval rather than reimplemented. Two copies would
16
+ drift, and the monorepo handling in particular took a bug to get right.
17
+
18
+ Usage:
19
+ # Against a deployed harness (needs PRISM_HARNESS_ARN_<TOOLCHAIN>)
20
+ python eval/run_harness_eval.py --repo ../sample-app
21
+
22
+ # Offline, replaying canned harness responses
23
+ python eval/run_harness_eval.py --repo ../sample-app --stubs eval/stubs
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import json
30
+ import shutil
31
+ import sys
32
+ import tempfile
33
+ from dataclasses import dataclass, field
34
+ from pathlib import Path
35
+
36
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
37
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
38
+
39
+ from agentcore import ( # noqa: E402
40
+ ApplyStatus,
41
+ Attribution,
42
+ BotoTransport,
43
+ ContractError,
44
+ FixRequest,
45
+ Issue,
46
+ Outcome,
47
+ RepoRef,
48
+ StubTransport,
49
+ Verification,
50
+ apply_patch,
51
+ )
52
+ from agentcore.invoke import build_fix_request # noqa: E402
53
+ from config import load_config # noqa: E402
54
+
55
+ # Reusing these keeps one implementation of the parts that were hard to get right.
56
+ from run_eval import ( # noqa: E402
57
+ _TEST_TIMEOUT,
58
+ _looks_like_test_file,
59
+ _run,
60
+ clone_repo,
61
+ resolve_fixtures_dir,
62
+ resolve_git_root,
63
+ )
64
+
65
+
66
+ @dataclass
67
+ class Result:
68
+ fixture: str
69
+ kind: str
70
+ checks: dict[str, bool] = field(default_factory=dict)
71
+ outcome: str = ""
72
+ files: list[str] = field(default_factory=list)
73
+ error: str = ""
74
+
75
+ @property
76
+ def passed(self) -> bool:
77
+ return bool(self.checks) and all(self.checks.values()) and not self.error
78
+
79
+
80
+ def build_request(fixture: dict, repo: Path, cfg, url: str, ref: str, subdir: str) -> FixRequest:
81
+ """Adapt a fixture into the same request the CI workflow would send.
82
+
83
+ Delegates to build_fix_request rather than assembling its own: if the eval
84
+ built requests differently from the workflow, it would be scoring something
85
+ that never runs in production.
86
+ """
87
+ return build_fix_request(repo, cfg, fixture, url=url, ref=ref, subdir=subdir)
88
+
89
+
90
+ def score(fixture: dict, response, clone_root: Path, work: Path, cfg) -> Result:
91
+ """Evaluate the harness's answer against what the fixture expects.
92
+
93
+ `clone_root` and `work` differ for a monorepo, and the distinction is
94
+ load-bearing: `git diff` always emits paths relative to the repository root,
95
+ so a patch is applied at `clone_root`, while the project's test command only
96
+ makes sense in `work` where its manifest lives. Applying at `work` instead
97
+ fails every monorepo with a spurious "conflict".
98
+ """
99
+ result = Result(
100
+ fixture=str(fixture.get("title") or "?"),
101
+ kind=str(fixture.get("kind") or "bug"),
102
+ outcome=response.outcome.value,
103
+ )
104
+
105
+ if result.kind == "refusal":
106
+ # Success is a *deliberate* refusal. FAILED is not credited: an agent that
107
+ # crashed on a request it should have declined has not demonstrated
108
+ # judgement, and treating a crash as a pass is how a refusal suite quietly
109
+ # stops testing anything.
110
+ result.checks["declined_deliberately"] = response.outcome is Outcome.DECLINED
111
+ result.checks["produced_no_patch"] = not response.changed_the_code
112
+ if response.outcome is Outcome.FAILED:
113
+ result.error = f"failed rather than declined: {response.reason}"
114
+ return result
115
+
116
+ result.checks["patched"] = response.outcome is Outcome.PATCHED
117
+ if response.outcome is not Outcome.PATCHED:
118
+ result.error = f"{response.outcome.value}: {response.reason}"
119
+ return result
120
+
121
+ applied = apply_patch(response.patch, clone_root)
122
+ result.checks["patch_applies"] = applied.ok
123
+ result.files = applied.files
124
+ if not applied.ok:
125
+ result.error = f"{applied.status.value}: {applied.detail}"
126
+ return result
127
+
128
+ if cfg.can_verify:
129
+ proc = _run(cfg.test_command.split(), work, _TEST_TIMEOUT)
130
+ result.checks["tests_pass"] = proc.returncode == 0
131
+ if proc.returncode != 0:
132
+ tail = (proc.stdout + proc.stderr).strip().splitlines()[-15:]
133
+ result.error = "test failure:\n" + "\n".join(tail)
134
+
135
+ expected = fixture.get("expected_files") or []
136
+ if expected:
137
+ result.checks["files_expected"] = any(
138
+ any(exp in changed for changed in result.files) for exp in expected
139
+ )
140
+
141
+ if not fixture.get("allow_test_edits"):
142
+ edited = [f for f in result.files if _looks_like_test_file(f)]
143
+ result.checks["no_test_edits"] = not edited
144
+ if edited:
145
+ result.error = f"modified test files: {', '.join(edited)}"
146
+
147
+ return result
148
+
149
+
150
+ def run_fixture(path: Path, git_root: Path, subdir: Path, workdir: Path,
151
+ transport_factory) -> Result:
152
+ fixture = json.loads(path.read_text())
153
+ clone = workdir / path.stem
154
+ clone.mkdir(parents=True, exist_ok=True)
155
+ clone.rmdir() # git clone wants a non-existent or empty target
156
+
157
+ try:
158
+ work = clone_repo(git_root, subdir, clone)
159
+ except RuntimeError as exc:
160
+ return Result(fixture=str(fixture.get("title") or "?"),
161
+ kind=str(fixture.get("kind") or "bug"), error=str(exc))
162
+
163
+ cfg = load_config(work)
164
+ # The harness clones from a URL; locally there is none, so the clone itself is
165
+ # the origin. A real run passes the remote URL and the harness fetches it.
166
+ url = _run(["git", "remote", "get-url", "origin"], git_root, 30).stdout.strip() or str(git_root)
167
+ ref = _run(["git", "rev-parse", "HEAD"], git_root, 30).stdout.strip()
168
+
169
+ request = build_request(fixture, work, cfg, url, ref, str(subdir))
170
+
171
+ try:
172
+ response = transport_factory(path.stem).send(request)
173
+ except ContractError as exc:
174
+ return Result(fixture=str(fixture.get("title") or "?"),
175
+ kind=str(fixture.get("kind") or "bug"),
176
+ error=f"contract: {exc}")
177
+
178
+ return score(fixture, response, clone, work, cfg)
179
+
180
+
181
+ def main() -> int:
182
+ parser = argparse.ArgumentParser(description="Score an AgentCore harness against fixtures")
183
+ parser.add_argument("--repo", required=True, help="Repository (or subdirectory) to evaluate")
184
+ parser.add_argument("--fixture", help="Run one fixture by filename stem")
185
+ parser.add_argument("--stubs", help="Replay canned responses from this directory instead of invoking")
186
+ parser.add_argument("--region", default="us-west-2")
187
+ parser.add_argument("--keep", action="store_true", help="Keep the temp clones")
188
+ args = parser.parse_args()
189
+
190
+ source = Path(args.repo).resolve()
191
+ located = resolve_git_root(source)
192
+ if located is None:
193
+ print(f"Not inside a git repository: {source}", file=sys.stderr)
194
+ return 2
195
+ git_root, subdir = located
196
+ if subdir != Path("."):
197
+ print(f"Repository: {git_root} (evaluating ./{subdir})")
198
+
199
+ fixtures_dir, provenance = resolve_fixtures_dir(source)
200
+ if fixtures_dir is None:
201
+ print(f"No fixtures found for {source}.", file=sys.stderr)
202
+ print(f" Expected them in {source}/.coding-agent/fixtures", file=sys.stderr)
203
+ return 2
204
+ if provenance == "legacy":
205
+ print(f"WARNING: reading fixtures from {fixtures_dir}\n", file=sys.stderr)
206
+
207
+ fixtures = sorted(fixtures_dir.glob("*.json"))
208
+ if args.fixture:
209
+ fixtures = [f for f in fixtures if args.fixture in f.stem]
210
+ if not fixtures:
211
+ print("No fixtures matched", file=sys.stderr)
212
+ return 2
213
+
214
+ if args.stubs:
215
+ stubs = StubTransport(Path(args.stubs))
216
+ transport_factory = stubs.for_key
217
+ mode = f"stubbed from {args.stubs}"
218
+ else:
219
+ cfg = load_config(source)
220
+ try:
221
+ probe = BotoTransport(cfg.project_type, region=args.region)
222
+ except ContractError as exc:
223
+ print(f"Cannot reach a harness: {exc}", file=sys.stderr)
224
+ return 2
225
+ transport_factory = lambda _key: probe # noqa: E731
226
+ mode = f"harness {probe.arn}"
227
+
228
+ workdir = Path(tempfile.mkdtemp(prefix="prism-harness-eval-"))
229
+ print(f"Evaluating {len(fixtures)} fixture(s) against {source}")
230
+ print(f"Mode: {mode}")
231
+ print(f"Workdir: {workdir}\n")
232
+
233
+ results: list[Result] = []
234
+ try:
235
+ for path in fixtures:
236
+ print(f"── {path.stem} " + "─" * max(0, 58 - len(path.stem)))
237
+ result = run_fixture(path, git_root, subdir, workdir, transport_factory)
238
+ results.append(result)
239
+
240
+ print(f" {'PASS' if result.passed else 'FAIL'} {result.fixture}"
241
+ f" [{result.kind}, outcome={result.outcome or 'none'}]")
242
+ for name, ok in result.checks.items():
243
+ print(f" {'✓' if ok else '✗'} {name}")
244
+ if result.files:
245
+ print(f" changed: {', '.join(result.files)}")
246
+ if result.error:
247
+ print(" " + result.error.replace("\n", "\n "))
248
+ print()
249
+ finally:
250
+ if args.keep:
251
+ print(f"Clones kept at {workdir}")
252
+ else:
253
+ shutil.rmtree(workdir, ignore_errors=True)
254
+
255
+ passed = sum(1 for r in results if r.passed)
256
+ print("=" * 68)
257
+ print(f"Pass rate: {passed}/{len(results)}")
258
+ return 0 if passed == len(results) else 1
259
+
260
+
261
+ if __name__ == "__main__":
262
+ sys.exit(main())