@prism-d1/cli 1.0.84 → 1.0.85
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 +61 -0
- package/dist/src/commands/bootstrapper/install-coding-agent.d.ts.map +1 -0
- package/dist/src/commands/bootstrapper/install-coding-agent.js +599 -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,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())
|