agent-skill-eval 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,810 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import subprocess
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from openai import OpenAI, OpenAIError
11
+
12
+ from skill_eval.git_state import capture_git_state, github_repo_slug, state_diff
13
+ from skill_eval.models import AssertionResult, GitStateSnapshot, GradingResult, GradingSummary
14
+
15
+
16
+ def _is_open_pr_state(state: Optional[str]) -> bool:
17
+ """True if a PR state string represents an open PR.
18
+
19
+ ``gh pr list --state all`` returns PRs with state ``OPEN``, ``CLOSED``,
20
+ or ``MERGED``. A missing/empty state is treated as not-open so we never
21
+ falsely satisfy a PR-created assertion on a stale or partial snapshot.
22
+ """
23
+ if not state:
24
+ return False
25
+ return str(state).upper() == "OPEN"
26
+
27
+
28
+ def _fetch_pr_for_branch(branch: str, source_repo: str) -> Optional[dict]:
29
+ """Look up a PR for ``branch`` in ``source_repo`` via ``gh pr view``.
30
+
31
+ Returns the parsed JSON dict on success, or ``None`` if ``gh`` is not
32
+ available, the repo is not configured, the PR is not found, or the
33
+ output cannot be parsed. Never raises.
34
+ """
35
+ if not source_repo or not branch:
36
+ return None
37
+ slug = github_repo_slug(source_repo)
38
+ if not slug:
39
+ return None
40
+ try:
41
+ result = subprocess.run(
42
+ [
43
+ "gh",
44
+ "pr",
45
+ "view",
46
+ branch,
47
+ "--repo",
48
+ slug,
49
+ "--json",
50
+ "number,headRefName,baseRefName,url,state",
51
+ ],
52
+ capture_output=True,
53
+ text=True,
54
+ check=False,
55
+ timeout=15,
56
+ )
57
+ except (subprocess.SubprocessError, FileNotFoundError, OSError):
58
+ return None
59
+ if result.returncode != 0 or not result.stdout.strip():
60
+ return None
61
+ try:
62
+ return json.loads(result.stdout)
63
+ except json.JSONDecodeError:
64
+ return None
65
+
66
+
67
+ def _load_state(path: Path) -> Optional[GitStateSnapshot]:
68
+ if not path.exists():
69
+ return None
70
+ try:
71
+ data = json.loads(path.read_text())
72
+ return GitStateSnapshot(**data)
73
+ except (json.JSONDecodeError, ValueError):
74
+ return None
75
+
76
+
77
+ class DeterministicGrader:
78
+ """Grades assertions against the *delta* caused by the agent during a run.
79
+
80
+ Prefers persisted pre/post git state snapshots (``pre_state.json``,
81
+ ``post_state.json``) written by the runner so that regrading works even
82
+ after the workspace is deleted. Falls back to capturing live state from
83
+ the workspace when snapshots are missing.
84
+ """
85
+
86
+ def __init__(
87
+ self,
88
+ pre_state: GitStateSnapshot | None = None,
89
+ post_state: GitStateSnapshot | None = None,
90
+ ):
91
+ self.pre_state = pre_state
92
+ self.post_state = post_state
93
+
94
+ def grade(
95
+ self,
96
+ assertions: list[str],
97
+ output_dir: Path,
98
+ agent_output: str,
99
+ workspace: Path | None = None,
100
+ should_trigger: bool = True,
101
+ ) -> list[AssertionResult]:
102
+ pre, post = self._resolve_states(output_dir, workspace)
103
+ self.pre_state = pre
104
+ self.post_state = post
105
+ diff = state_diff(pre, post) if pre and post else {}
106
+
107
+ results = []
108
+ for assertion in assertions:
109
+ result = self._check_assertion(assertion, output_dir, agent_output, workspace, should_trigger, diff)
110
+ results.append(result)
111
+ return results
112
+
113
+ def _resolve_states(
114
+ self, output_dir: Path, workspace: Path | None
115
+ ) -> tuple[Optional[GitStateSnapshot], Optional[GitStateSnapshot]]:
116
+ if self.pre_state is not None and self.post_state is not None:
117
+ return self.pre_state, self.post_state
118
+
119
+ pre_path = output_dir / "pre_state.json"
120
+ post_path = output_dir / "post_state.json"
121
+ pre = _load_state(pre_path)
122
+ post = _load_state(post_path)
123
+
124
+ if pre is not None and post is not None:
125
+ return pre, post
126
+
127
+ if workspace is None:
128
+ workspace = self._resolve_workspace(output_dir, None)
129
+
130
+ try:
131
+ captured = capture_git_state(workspace)
132
+ except Exception:
133
+ return pre, post
134
+
135
+ return pre or GitStateSnapshot(), captured
136
+
137
+ def _check_assertion(
138
+ self,
139
+ assertion: str,
140
+ output_dir: Path,
141
+ agent_output: str,
142
+ workspace: Path | None,
143
+ should_trigger: bool,
144
+ diff: dict,
145
+ ) -> AssertionResult:
146
+ assertion_lower = assertion.lower()
147
+
148
+ if "branch" in assertion_lower and (
149
+ "created" in assertion_lower or "exists" in assertion_lower or "new" in assertion_lower
150
+ ):
151
+ return self._check_git_branch(assertion, diff, should_trigger)
152
+
153
+ if "commit" in assertion_lower and (
154
+ "created" in assertion_lower or "exists" in assertion_lower or "new" in assertion_lower
155
+ ):
156
+ return self._check_git_commit(assertion, diff, should_trigger)
157
+
158
+ if "push" in assertion_lower and (
159
+ "remote" in assertion_lower or "branch" in assertion_lower or "pushed" in assertion_lower
160
+ ):
161
+ return self._check_pushed(assertion, diff, agent_output, should_trigger)
162
+
163
+ # Word-boundary match: a bare "pr" substring would also match
164
+ # "prominently", "present", "approach", etc.
165
+ if re.search(r"\bprs?\b|\bpull request", assertion_lower):
166
+ return self._check_pr_created(assertion, diff, agent_output, should_trigger, output_dir)
167
+
168
+ if (
169
+ "file exists" in assertion_lower
170
+ or ("file" in assertion_lower and "exists" in assertion_lower)
171
+ or ("created" in assertion_lower and any(c in assertion_lower for c in [".", "file"]))
172
+ ):
173
+ return self._check_file_exists(assertion, output_dir, workspace)
174
+
175
+ if "ran" in assertion_lower and (
176
+ "command" in assertion_lower
177
+ or any(cmd in assertion_lower for cmd in ["npm", "git", "python", "cargo", "go"])
178
+ ):
179
+ return self._check_command_ran(assertion, output_dir)
180
+
181
+ if "contains" in assertion_lower or "includes" in assertion_lower:
182
+ return self._check_content_contains(assertion, agent_output)
183
+
184
+ if "valid json" in assertion_lower:
185
+ return self._check_valid_json(assertion, output_dir, agent_output, workspace)
186
+
187
+ return AssertionResult(
188
+ text=assertion,
189
+ passed=False,
190
+ evidence=f"Could not deterministically check: {assertion}",
191
+ method="unknown",
192
+ )
193
+
194
+ def _resolve_workspace(self, output_dir: Path, workspace: Path | None) -> Path:
195
+ if workspace:
196
+ return workspace
197
+ if "with_skill" in str(output_dir) or "without_skill" in str(output_dir):
198
+ return output_dir.parent.parent
199
+ return output_dir.parent
200
+
201
+ def _invert(self, should_trigger: bool) -> bool:
202
+ """For negative controls, branch/commit/push/pr assertions are inverted."""
203
+ return not should_trigger
204
+
205
+ def _source_repo_from_meta(self, output_dir: Path | None) -> Optional[str]:
206
+ """Read ``source_repo`` from ``run_meta.json`` adjacent to ``outputs/``.
207
+
208
+ Returns ``None`` when no metadata is available or the field is unset.
209
+ The grader uses this to optionally corroborate PR checks via
210
+ ``gh pr view <branch>`` for a real-time validation pass.
211
+ """
212
+ if output_dir is None:
213
+ return None
214
+ meta_path = output_dir.parent / "run_meta.json"
215
+ if not meta_path.exists():
216
+ return None
217
+ try:
218
+ data = json.loads(meta_path.read_text())
219
+ except (json.JSONDecodeError, OSError, ValueError):
220
+ return None
221
+ repo = data.get("source_repo")
222
+ return str(repo) if repo else None
223
+
224
+ def _check_file_exists(self, assertion: str, output_dir: Path, workspace: Path | None = None) -> AssertionResult:
225
+ ws = self._resolve_workspace(output_dir, workspace)
226
+
227
+ patterns = re.findall(r"`([^`]+)`", assertion)
228
+ if not patterns:
229
+ patterns = re.findall(r'"([^"]+)"', assertion)
230
+ if not patterns:
231
+ words = assertion.split()
232
+ for w in words:
233
+ if "." in w and "/" not in w:
234
+ patterns.append(w)
235
+
236
+ if not ws.exists():
237
+ return AssertionResult(
238
+ text=assertion,
239
+ passed=False,
240
+ evidence=f"Workspace not available. Searched for: {patterns}",
241
+ )
242
+
243
+ for pattern in patterns:
244
+ candidates = list(ws.rglob(pattern))
245
+ if candidates:
246
+ return AssertionResult(
247
+ text=assertion,
248
+ passed=True,
249
+ evidence=f"Found: {candidates[0].relative_to(ws)}",
250
+ )
251
+
252
+ return AssertionResult(
253
+ text=assertion,
254
+ passed=False,
255
+ evidence=f"File not found in workspace. Searched for: {patterns}",
256
+ )
257
+
258
+ def _check_command_ran(self, assertion: str, output_dir: Path) -> AssertionResult:
259
+ stderr_log = output_dir / "stderr.log"
260
+ stdout_log = output_dir / "stdout.log"
261
+
262
+ commands_to_check = []
263
+ for cmd in ["npm", "git", "python", "cargo", "go", "yarn", "pnpm"]:
264
+ if cmd in assertion.lower():
265
+ commands_to_check.append(cmd)
266
+
267
+ for log_file in [stderr_log, stdout_log]:
268
+ if not log_file.exists():
269
+ continue
270
+ content = log_file.read_text().lower()
271
+ for cmd in commands_to_check:
272
+ if cmd in content:
273
+ return AssertionResult(
274
+ text=assertion,
275
+ passed=True,
276
+ evidence=f"Found '{cmd}' in {log_file.name}",
277
+ )
278
+
279
+ return AssertionResult(
280
+ text=assertion,
281
+ passed=False,
282
+ evidence="Command not found in logs",
283
+ )
284
+
285
+ def _check_content_contains(self, assertion: str, agent_output: str) -> AssertionResult:
286
+ patterns = re.findall(r'"([^"]+)"', assertion)
287
+ if not patterns:
288
+ patterns = re.findall(r"`([^`]+)`", assertion)
289
+
290
+ if not patterns:
291
+ # No quoted/backticked pattern to search for: this is a prose
292
+ # assertion that happens to contain "contains"/"includes".
293
+ # Fall through to the LLM grader instead of failing it.
294
+ return AssertionResult(
295
+ text=assertion,
296
+ passed=False,
297
+ evidence=f"Could not deterministically check: {assertion}",
298
+ method="unknown",
299
+ )
300
+
301
+ output_lower = agent_output.lower()
302
+ for pattern in patterns:
303
+ if pattern.lower() in output_lower:
304
+ return AssertionResult(
305
+ text=assertion,
306
+ passed=True,
307
+ evidence=f"Found '{pattern}' in output",
308
+ )
309
+
310
+ return AssertionResult(
311
+ text=assertion,
312
+ passed=False,
313
+ evidence=f"Pattern not found in output. Searched for: {patterns}",
314
+ )
315
+
316
+ def _check_valid_json(
317
+ self, assertion: str, output_dir: Path, agent_output: str, workspace: Path | None = None
318
+ ) -> AssertionResult:
319
+ try:
320
+ json.loads(agent_output)
321
+ return AssertionResult(text=assertion, passed=True, evidence="Output is valid JSON")
322
+ except json.JSONDecodeError:
323
+ pass
324
+
325
+ ws = self._resolve_workspace(output_dir, workspace)
326
+ if not ws.exists():
327
+ return AssertionResult(text=assertion, passed=False, evidence="Workspace not available for JSON check")
328
+
329
+ for json_file in ws.rglob("*.json"):
330
+ try:
331
+ json.loads(json_file.read_text())
332
+ return AssertionResult(
333
+ text=assertion,
334
+ passed=True,
335
+ evidence=f"Found valid JSON: {json_file.relative_to(ws)}",
336
+ )
337
+ except (json.JSONDecodeError, UnicodeDecodeError):
338
+ continue
339
+
340
+ return AssertionResult(text=assertion, passed=False, evidence="No valid JSON found")
341
+
342
+ def _check_git_branch(self, assertion: str, diff: dict, should_trigger: bool) -> AssertionResult:
343
+ inverted = self._invert(should_trigger)
344
+ new_branches = diff.get("new_branches", []) or []
345
+ eval_branch = diff.get("eval_branch")
346
+ current_branch = diff.get("current_branch", "")
347
+ branch_changed = diff.get("current_branch_changed", False)
348
+
349
+ if inverted:
350
+ if new_branches:
351
+ return AssertionResult(
352
+ text=assertion,
353
+ passed=False,
354
+ evidence=(f"Skill should not have triggered, but new branches appeared: {new_branches}"),
355
+ )
356
+ if branch_changed and eval_branch:
357
+ return AssertionResult(
358
+ text=assertion,
359
+ passed=False,
360
+ evidence=(
361
+ f"Skill should not have triggered, but current branch changed to "
362
+ f"new eval branch {current_branch!r}"
363
+ ),
364
+ )
365
+ return AssertionResult(
366
+ text=assertion,
367
+ passed=True,
368
+ evidence="No new branch was created (skill did not trigger)",
369
+ )
370
+
371
+ if not new_branches:
372
+ return AssertionResult(
373
+ text=assertion,
374
+ passed=False,
375
+ evidence=(
376
+ f"No new branch appeared in this run. current_branch={current_branch!r}; no eval-created branch."
377
+ ),
378
+ )
379
+ if eval_branch is None:
380
+ return AssertionResult(
381
+ text=assertion,
382
+ passed=False,
383
+ evidence=(
384
+ f"New branches {new_branches} appeared, but none is the current/eval branch. "
385
+ f"current_branch={current_branch!r}"
386
+ ),
387
+ )
388
+ return AssertionResult(
389
+ text=assertion,
390
+ passed=True,
391
+ evidence=f"New eval branch created and checked out: {eval_branch}",
392
+ )
393
+
394
+ def _check_git_commit(self, assertion: str, diff: dict, should_trigger: bool) -> AssertionResult:
395
+ inverted = self._invert(should_trigger)
396
+ advanced = diff.get("head_advanced", False)
397
+ new_commits = diff.get("new_commits", []) or []
398
+ new_commit_shas = diff.get("new_commit_shas", []) or []
399
+ post_head = self.post_state.head_sha if self.post_state else ""
400
+
401
+ if inverted:
402
+ if new_commit_shas or new_commits:
403
+ return AssertionResult(
404
+ text=assertion,
405
+ passed=False,
406
+ evidence="Skill should not have triggered, but new commit(s) were created",
407
+ )
408
+ return AssertionResult(
409
+ text=assertion,
410
+ passed=True,
411
+ evidence="No new commit was created (skill did not trigger)",
412
+ )
413
+
414
+ if not new_commit_shas and not new_commits:
415
+ return AssertionResult(
416
+ text=assertion,
417
+ passed=False,
418
+ evidence=(
419
+ "HEAD may have moved, but no new commit was created in this run. "
420
+ f"head_advanced={advanced}, new_commits={new_commits}"
421
+ ),
422
+ )
423
+ if new_commit_shas and post_head and post_head not in new_commit_shas:
424
+ return AssertionResult(
425
+ text=assertion,
426
+ passed=False,
427
+ evidence=(
428
+ f"New commit(s) appeared ({len(new_commit_shas)}), but current HEAD "
429
+ f"({post_head[:12]}) is not one of them. The new commit is not on the "
430
+ f"current branch."
431
+ ),
432
+ )
433
+ return AssertionResult(
434
+ text=assertion,
435
+ passed=True,
436
+ evidence=f"New commit on current branch (HEAD {post_head[:12] if post_head else '?'})",
437
+ )
438
+
439
+ def _check_pushed(self, assertion: str, diff: dict, agent_output: str, should_trigger: bool) -> AssertionResult:
440
+ inverted = self._invert(should_trigger)
441
+ new_remote_branches = diff.get("new_remote_branches", []) or []
442
+ eval_branch = diff.get("eval_branch")
443
+ eval_branch_pushed = diff.get("eval_branch_pushed", False)
444
+ eval_branch_pushed_matches_head = diff.get("eval_branch_pushed_matches_head", False)
445
+ post_head = self.post_state.head_sha if self.post_state else ""
446
+
447
+ if inverted:
448
+ if new_remote_branches:
449
+ return AssertionResult(
450
+ text=assertion,
451
+ passed=False,
452
+ evidence=(
453
+ f"Skill should not have triggered, but new remote branches appeared: {new_remote_branches}"
454
+ ),
455
+ )
456
+ if eval_branch_pushed:
457
+ return AssertionResult(
458
+ text=assertion,
459
+ passed=False,
460
+ evidence="Skill should not have triggered, but eval branch was pushed",
461
+ )
462
+ return AssertionResult(
463
+ text=assertion,
464
+ passed=True,
465
+ evidence="No new remote branches (skill did not trigger)",
466
+ )
467
+
468
+ if not eval_branch:
469
+ return AssertionResult(
470
+ text=assertion,
471
+ passed=False,
472
+ evidence=("No eval-created branch in this run; cannot verify that the right branch was pushed."),
473
+ )
474
+ if not eval_branch_pushed:
475
+ return AssertionResult(
476
+ text=assertion,
477
+ passed=False,
478
+ evidence=(f"Eval branch {eval_branch!r} was not pushed. new_remote_branches={new_remote_branches}"),
479
+ )
480
+ if not eval_branch_pushed_matches_head:
481
+ return AssertionResult(
482
+ text=assertion,
483
+ passed=False,
484
+ evidence=(
485
+ f"Eval branch {eval_branch!r} was pushed, but its remote HEAD does not "
486
+ f"match the local HEAD ({post_head[:12] if post_head else '?'})."
487
+ ),
488
+ )
489
+ return AssertionResult(
490
+ text=assertion,
491
+ passed=True,
492
+ evidence=(f"Eval branch {eval_branch!r} pushed with HEAD {post_head[:12] if post_head else '?'}"),
493
+ )
494
+
495
+ def _check_pr_created(
496
+ self,
497
+ assertion: str,
498
+ diff: dict,
499
+ agent_output: str,
500
+ should_trigger: bool,
501
+ output_dir: Path | None = None,
502
+ ) -> AssertionResult:
503
+ inverted = self._invert(should_trigger)
504
+ new_prs = diff.get("new_open_prs", []) or []
505
+ eval_branch = diff.get("eval_branch")
506
+
507
+ source_repo = self._source_repo_from_meta(output_dir)
508
+ live_pr: Optional[dict] = None
509
+ if source_repo and eval_branch:
510
+ live_pr = _fetch_pr_for_branch(eval_branch, source_repo)
511
+
512
+ if inverted:
513
+ if new_prs:
514
+ return AssertionResult(
515
+ text=assertion,
516
+ passed=False,
517
+ evidence=(
518
+ f"Skill should not have triggered, but new PR(s) appeared: {[p.get('number') for p in new_prs]}"
519
+ ),
520
+ )
521
+ if live_pr and _is_open_pr_state(live_pr.get("state")):
522
+ return AssertionResult(
523
+ text=assertion,
524
+ passed=False,
525
+ evidence=(
526
+ f"Skill should not have triggered, but gh pr view reports an "
527
+ f"open PR for {eval_branch!r} (#{live_pr.get('number')})"
528
+ ),
529
+ )
530
+ return AssertionResult(
531
+ text=assertion,
532
+ passed=True,
533
+ evidence="No new PRs were opened (skill did not trigger)",
534
+ )
535
+
536
+ if not new_prs:
537
+ if live_pr and _is_open_pr_state(live_pr.get("state")):
538
+ return AssertionResult(
539
+ text=assertion,
540
+ passed=True,
541
+ evidence=(
542
+ f"State snapshot shows no new PRs, but gh pr view confirms an "
543
+ f"open PR #{live_pr.get('number')} for eval branch {eval_branch!r}"
544
+ ),
545
+ )
546
+ return AssertionResult(
547
+ text=assertion,
548
+ passed=False,
549
+ evidence="No new PRs appeared in this run (state diff shows no new PRs)",
550
+ )
551
+ if not eval_branch:
552
+ return AssertionResult(
553
+ text=assertion,
554
+ passed=False,
555
+ evidence=(
556
+ f"New PR(s) {[p.get('number') for p in new_prs]} appeared, but no "
557
+ f"eval-created branch was identified in this run; cannot verify the "
558
+ f"PR is for the eval branch."
559
+ ),
560
+ )
561
+ matching = [p for p in new_prs if p.get("headRefName") == eval_branch and _is_open_pr_state(p.get("state"))]
562
+ if not matching:
563
+ return AssertionResult(
564
+ text=assertion,
565
+ passed=False,
566
+ evidence=(
567
+ f"New PR(s) {[p.get('number') for p in new_prs]} appeared, but none "
568
+ f"have headRefName matching eval branch {eval_branch!r} with state OPEN. "
569
+ f"head refs: {[p.get('headRefName') for p in new_prs]}, "
570
+ f"states: {[p.get('state') for p in new_prs]}"
571
+ ),
572
+ )
573
+
574
+ if live_pr is not None and not _is_open_pr_state(live_pr.get("state")):
575
+ return AssertionResult(
576
+ text=assertion,
577
+ passed=False,
578
+ evidence=(
579
+ f"State snapshot shows open PR(s) {[p.get('number') for p in matching]} "
580
+ f"for eval branch {eval_branch!r}, but gh pr view reports the PR is "
581
+ f"{live_pr.get('state')!r}; the PR was likely closed/merged between "
582
+ f"snapshot and grading."
583
+ ),
584
+ )
585
+
586
+ numbers = [p.get("number") for p in matching]
587
+ evidence = f"PR(s) for eval branch {eval_branch!r}: {numbers}"
588
+ if live_pr is not None:
589
+ evidence += f" (corroborated by gh pr view: state={live_pr.get('state')!r})"
590
+ return AssertionResult(
591
+ text=assertion,
592
+ passed=True,
593
+ evidence=evidence,
594
+ )
595
+
596
+
597
+ class LLMGrader:
598
+ def __init__(self, model: str = "deepseek/deepseek-v4-flash", base_url: str | None = None):
599
+ self.model = model
600
+ self.base_url = base_url
601
+ self._client = None
602
+
603
+ @staticmethod
604
+ def has_credentials() -> bool:
605
+ """True if an API key for the grader is available in the environment."""
606
+ return bool(os.environ.get("OPENROUTER_API_KEY") or os.environ.get("OPENAI_API_KEY"))
607
+
608
+ @property
609
+ def client(self):
610
+ if self._client is None:
611
+ api_key = os.environ.get("OPENROUTER_API_KEY") or os.environ.get("OPENAI_API_KEY", "")
612
+ if not api_key:
613
+ raise OpenAIError("OPENROUTER_API_KEY or OPENAI_API_KEY not set. Set it via environment variable.")
614
+ base_url = self.base_url or os.environ.get("OPENAI_BASE_URL") or "https://openrouter.ai/api/v1"
615
+ self._client = OpenAI(
616
+ api_key=api_key,
617
+ base_url=base_url,
618
+ )
619
+ return self._client
620
+
621
+ def grade(
622
+ self,
623
+ assertions: list[str],
624
+ agent_output: str,
625
+ output_dir: Path,
626
+ expected_output: str,
627
+ workspace: Path | None = None,
628
+ ) -> list[AssertionResult]:
629
+ if not assertions:
630
+ return []
631
+
632
+ if workspace is None or not workspace.exists():
633
+ workspace = output_dir.parent.parent if "with_skill" in str(output_dir) else output_dir.parent
634
+ file_listing = self._list_workspace_files(workspace)
635
+ file_contents = self._read_workspace_files(workspace)
636
+
637
+ prompt = f"""You are grading an AI agent's output against specific assertions.
638
+
639
+ ## Expected Output
640
+ {expected_output}
641
+
642
+ ## Agent's Actual Output
643
+ {agent_output}
644
+
645
+ ## Workspace Files
646
+ {file_listing}
647
+
648
+ ## Workspace File Contents
649
+ {file_contents}
650
+
651
+ ## Assertions to Grade
652
+ {chr(10).join(f"{i + 1}. {a}" for i, a in enumerate(assertions))}
653
+
654
+ For each assertion, respond with JSON:
655
+ {{
656
+ "results": [
657
+ {{"text": "<assertion text>", "passed": true/false, "evidence": "<specific evidence from output>"}}
658
+ ]
659
+ }}
660
+
661
+ Be strict: only mark PASS if there is clear evidence. Quote the output or file contents in evidence."""
662
+
663
+ try:
664
+ response = self.client.chat.completions.create(
665
+ model=self.model,
666
+ messages=[{"role": "user", "content": prompt}],
667
+ response_format={"type": "json_object"},
668
+ temperature=0,
669
+ )
670
+ data = json.loads(response.choices[0].message.content)
671
+ results = []
672
+ returned_texts = set()
673
+ for r in data.get("results", []):
674
+ results.append(
675
+ AssertionResult(
676
+ text=r["text"],
677
+ passed=r["passed"],
678
+ evidence=r["evidence"],
679
+ method="llm",
680
+ )
681
+ )
682
+ returned_texts.add(r["text"])
683
+ for a in assertions:
684
+ if a not in returned_texts:
685
+ results.append(
686
+ AssertionResult(
687
+ text=a,
688
+ passed=False,
689
+ evidence="LLM grader did not return a result for this assertion",
690
+ method="llm",
691
+ )
692
+ )
693
+ return results
694
+ except Exception as e:
695
+ # A grader failure is not an agent failure: mark the assertions as
696
+ # skipped so they don't drag down the pass rate.
697
+ return [
698
+ AssertionResult(
699
+ text=a,
700
+ passed=False,
701
+ evidence=f"LLM grading error (assertion skipped, not failed): {e}",
702
+ method="llm",
703
+ skipped=True,
704
+ )
705
+ for a in assertions
706
+ ]
707
+
708
+ # Skill-install and VCS dirs: listing them would leak the skill text into
709
+ # the judge's context and drown out the agent's actual artifacts.
710
+ _EXCLUDED_DIRS = (".git", ".claude", ".opencode", ".codex", ".fake")
711
+
712
+ def _workspace_files(self, workspace: Path) -> list[Path]:
713
+ files = []
714
+ for root, dirs, filenames in os.walk(workspace):
715
+ # Prune in-place so excluded trees are never traversed at all
716
+ # (rglob would walk a full .git or node_modules before filtering).
717
+ dirs[:] = [d for d in dirs if d not in self._EXCLUDED_DIRS]
718
+ for filename in filenames:
719
+ files.append(Path(root) / filename)
720
+ return sorted(files)
721
+
722
+ def _list_workspace_files(self, workspace: Path) -> str:
723
+ files = []
724
+ for f in self._workspace_files(workspace):
725
+ try:
726
+ rel = f.relative_to(workspace)
727
+ size = f.stat().st_size
728
+ files.append(f"{rel} ({size} bytes)")
729
+ except Exception:
730
+ continue
731
+ return "\n".join(files[:50]) if files else "(empty workspace)"
732
+
733
+ def _read_workspace_files(self, workspace: Path, max_files: int = 10, max_bytes: int = 4096) -> str:
734
+ """Inline small text files so the judge can grade content assertions
735
+ (e.g. "RELEASE_NOTES.md groups changes by type") against the actual
736
+ artifacts, not just the agent's final message."""
737
+ sections = []
738
+ for f in self._workspace_files(workspace)[:max_files]:
739
+ try:
740
+ if f.stat().st_size > max_bytes:
741
+ continue
742
+ content = f.read_text(encoding="utf-8")
743
+ except (UnicodeDecodeError, OSError):
744
+ continue
745
+ rel = f.relative_to(workspace)
746
+ sections.append(f"### {rel}\n```\n{content}\n```")
747
+ return "\n\n".join(sections) if sections else "(no readable files)"
748
+
749
+
750
+ def grade_assertions(
751
+ assertions: list[str],
752
+ agent_output: str,
753
+ output_dir: Path,
754
+ expected_output: str,
755
+ llm_grader: LLMGrader | None = None,
756
+ workspace: Path | None = None,
757
+ pre_state: GitStateSnapshot | None = None,
758
+ post_state: GitStateSnapshot | None = None,
759
+ should_trigger: bool = True,
760
+ ) -> GradingResult:
761
+ det_grader = DeterministicGrader(pre_state=pre_state, post_state=post_state)
762
+ det_results = det_grader.grade(
763
+ assertions, output_dir, agent_output, workspace=workspace, should_trigger=should_trigger
764
+ )
765
+
766
+ undetermined = [r for r in det_results if "Could not deterministically check" in r.evidence]
767
+ determined = [r for r in det_results if "Could not deterministically check" not in r.evidence]
768
+
769
+ if undetermined and llm_grader:
770
+ undetermined_texts = [r.text for r in undetermined]
771
+ llm_results = llm_grader.grade(
772
+ undetermined_texts, agent_output, output_dir, expected_output, workspace=workspace
773
+ )
774
+ all_results = determined + llm_results
775
+ elif undetermined:
776
+ # No LLM grader available: skip these assertions instead of failing
777
+ # them, so a missing API key doesn't masquerade as agent failure.
778
+ skipped_results = [
779
+ AssertionResult(
780
+ text=r.text,
781
+ passed=False,
782
+ evidence=(
783
+ "Not deterministically checkable and no LLM grader configured "
784
+ "(set OPENROUTER_API_KEY or OPENAI_API_KEY); assertion skipped"
785
+ ),
786
+ method="skipped",
787
+ skipped=True,
788
+ )
789
+ for r in undetermined
790
+ ]
791
+ all_results = determined + skipped_results
792
+ else:
793
+ all_results = det_results
794
+
795
+ passed = sum(1 for r in all_results if r.passed)
796
+ skipped = sum(1 for r in all_results if r.skipped)
797
+ failed = sum(1 for r in all_results if not r.passed and not r.skipped)
798
+ total = len(all_results)
799
+ graded = total - skipped
800
+
801
+ return GradingResult(
802
+ assertion_results=all_results,
803
+ summary=GradingSummary(
804
+ passed=passed,
805
+ failed=failed,
806
+ total=total,
807
+ pass_rate=passed / graded if graded > 0 else 0.0,
808
+ skipped=skipped,
809
+ ),
810
+ )