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.
- agent_skill_eval-0.2.1.dist-info/METADATA +335 -0
- agent_skill_eval-0.2.1.dist-info/RECORD +15 -0
- agent_skill_eval-0.2.1.dist-info/WHEEL +4 -0
- agent_skill_eval-0.2.1.dist-info/entry_points.txt +2 -0
- agent_skill_eval-0.2.1.dist-info/licenses/LICENSE +21 -0
- skill_eval/__init__.py +8 -0
- skill_eval/__main__.py +3 -0
- skill_eval/cli.py +844 -0
- skill_eval/git_state.py +227 -0
- skill_eval/graders/__init__.py +810 -0
- skill_eval/harnesses/__init__.py +337 -0
- skill_eval/models.py +146 -0
- skill_eval/runner.py +424 -0
- skill_eval/skills.py +88 -0
- skill_eval/workspace.py +101 -0
|
@@ -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
|
+
)
|