agent-skill-lab 0.1.0__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_lab/__init__.py +3 -0
- agent_skill_lab/assertions.py +168 -0
- agent_skill_lab/cli.py +216 -0
- agent_skill_lab/config.py +258 -0
- agent_skill_lab/reporter.py +194 -0
- agent_skill_lab/runner.py +365 -0
- agent_skill_lab/scaffold.py +107 -0
- agent_skill_lab/scoring.py +253 -0
- agent_skill_lab/storage.py +101 -0
- agent_skill_lab/validator.py +215 -0
- agent_skill_lab-0.1.0.dist-info/METADATA +278 -0
- agent_skill_lab-0.1.0.dist-info/RECORD +15 -0
- agent_skill_lab-0.1.0.dist-info/WHEEL +4 -0
- agent_skill_lab-0.1.0.dist-info/entry_points.txt +2 -0
- agent_skill_lab-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Deterministic checks run against the agent's workspace after a task.
|
|
2
|
+
|
|
3
|
+
Everything here is deliberately non-LLM: an assertion either passes or it does
|
|
4
|
+
not, and the same workspace always yields the same verdict.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .config import Assertion
|
|
15
|
+
|
|
16
|
+
KNOWN_TYPES = {"command", "file_exists", "file_absent", "file_contains", "file_changed", "file_unchanged"}
|
|
17
|
+
|
|
18
|
+
# Guards state a constraint that already holds before the agent runs and must
|
|
19
|
+
# still hold afterwards ("don't touch the tests"). Unlike the other types they
|
|
20
|
+
# are expected to pass on a pristine fixture, so a dry run must not call them
|
|
21
|
+
# trivial.
|
|
22
|
+
GUARD_TYPES = {"file_unchanged", "file_absent"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class AssertionResult:
|
|
27
|
+
label: str
|
|
28
|
+
passed: bool
|
|
29
|
+
weight: float
|
|
30
|
+
required: bool
|
|
31
|
+
detail: str = ""
|
|
32
|
+
kind: str = ""
|
|
33
|
+
guard: bool = False
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def is_guard(self) -> bool:
|
|
37
|
+
return self.guard
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _confine(workdir: Path, rel: str) -> Path | None:
|
|
41
|
+
"""Resolve `rel` inside `workdir`, refusing anything that escapes it.
|
|
42
|
+
|
|
43
|
+
A task.yaml is authored by the skill author, not by whoever runs the
|
|
44
|
+
benchmark. Without this, `path: ../../../.ssh/id_rsa` in a downloaded skill
|
|
45
|
+
lets its assertions read host files (leaking one bit per pattern), and an
|
|
46
|
+
absolute path escapes the sandbox entirely.
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
target = (workdir / rel).resolve()
|
|
50
|
+
target.relative_to(workdir.resolve())
|
|
51
|
+
except (ValueError, OSError):
|
|
52
|
+
return None
|
|
53
|
+
return target
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _git_changed(workdir: Path, rel: str) -> bool | None:
|
|
57
|
+
"""True/False if git can answer, None if the workspace is not a git repo."""
|
|
58
|
+
try:
|
|
59
|
+
proc = subprocess.run(
|
|
60
|
+
["git", "status", "--porcelain", "--", rel],
|
|
61
|
+
cwd=workdir,
|
|
62
|
+
capture_output=True,
|
|
63
|
+
text=True,
|
|
64
|
+
timeout=30,
|
|
65
|
+
)
|
|
66
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
67
|
+
return None
|
|
68
|
+
if proc.returncode != 0:
|
|
69
|
+
return None
|
|
70
|
+
return bool(proc.stdout.strip())
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def check(assertion: Assertion, workdir: Path, *, timeout: int = 300) -> AssertionResult:
|
|
74
|
+
spec = assertion.spec
|
|
75
|
+
kind = assertion.type
|
|
76
|
+
label = assertion.label()
|
|
77
|
+
|
|
78
|
+
# A guard asserts that something stays absent: file_absent, file_unchanged,
|
|
79
|
+
# or a file_contains looking for a bad pattern (expect: false). All of these
|
|
80
|
+
# legitimately hold on the pristine fixture, so a dry run must not call them
|
|
81
|
+
# trivial.
|
|
82
|
+
is_guard = kind in GUARD_TYPES or (kind == "file_contains" and not spec.get("expect", True))
|
|
83
|
+
|
|
84
|
+
def result(passed: bool, detail: str = "") -> AssertionResult:
|
|
85
|
+
return AssertionResult(
|
|
86
|
+
label, passed, assertion.weight, assertion.required, detail, kind, is_guard
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if kind == "command":
|
|
90
|
+
command = spec.get("run")
|
|
91
|
+
if not command:
|
|
92
|
+
return result(False, "assertion has no 'run' command")
|
|
93
|
+
expected = int(spec.get("expect_exit", 0))
|
|
94
|
+
try:
|
|
95
|
+
proc = subprocess.run(
|
|
96
|
+
command,
|
|
97
|
+
shell=True,
|
|
98
|
+
cwd=workdir,
|
|
99
|
+
capture_output=True,
|
|
100
|
+
text=True,
|
|
101
|
+
timeout=int(spec.get("timeout_seconds", timeout)),
|
|
102
|
+
)
|
|
103
|
+
except subprocess.TimeoutExpired:
|
|
104
|
+
return result(False, "command timed out")
|
|
105
|
+
except OSError as exc:
|
|
106
|
+
return result(False, f"command failed to start: {exc}")
|
|
107
|
+
tail = (proc.stdout + proc.stderr).strip().splitlines()
|
|
108
|
+
detail = f"exit {proc.returncode} (expected {expected})"
|
|
109
|
+
if proc.returncode != expected and tail:
|
|
110
|
+
detail += " | " + tail[-1][:160]
|
|
111
|
+
return result(proc.returncode == expected, detail)
|
|
112
|
+
|
|
113
|
+
rel = spec.get("path")
|
|
114
|
+
if not rel:
|
|
115
|
+
return result(False, f"'{kind}' assertion has no 'path'")
|
|
116
|
+
target = _confine(workdir, rel)
|
|
117
|
+
if target is None:
|
|
118
|
+
return result(False, f"path '{rel}' escapes the workspace and is refused")
|
|
119
|
+
|
|
120
|
+
if kind == "file_exists":
|
|
121
|
+
return result(target.is_file(), "" if target.is_file() else "file not found")
|
|
122
|
+
|
|
123
|
+
if kind == "file_absent":
|
|
124
|
+
return result(not target.exists(), "" if not target.exists() else "file still present")
|
|
125
|
+
|
|
126
|
+
if kind == "file_contains":
|
|
127
|
+
pattern = spec.get("pattern")
|
|
128
|
+
if not pattern:
|
|
129
|
+
return result(False, "file_contains needs a 'pattern'")
|
|
130
|
+
if not target.is_file():
|
|
131
|
+
return result(False, "file not found")
|
|
132
|
+
text = target.read_text(encoding="utf-8", errors="replace")
|
|
133
|
+
flags = 0 if spec.get("case_sensitive", True) else re.IGNORECASE
|
|
134
|
+
found = re.search(str(pattern), text, flags) is not None
|
|
135
|
+
want = bool(spec.get("expect", True))
|
|
136
|
+
return result(found == want, "" if found == want else f"pattern {'not ' if want else ''}found")
|
|
137
|
+
|
|
138
|
+
if kind in {"file_changed", "file_unchanged"}:
|
|
139
|
+
# Use the path relative to workdir so git sees a repo-internal target;
|
|
140
|
+
# `target` is already confined above.
|
|
141
|
+
changed = _git_changed(workdir, target.relative_to(workdir.resolve()).as_posix())
|
|
142
|
+
if changed is None:
|
|
143
|
+
return result(False, "cannot inspect changes: workspace is not a git repo")
|
|
144
|
+
want_changed = kind == "file_changed"
|
|
145
|
+
ok = changed == want_changed
|
|
146
|
+
return result(ok, "" if ok else ("file was modified" if want_changed is False else "file was not modified"))
|
|
147
|
+
|
|
148
|
+
return result(False, f"unknown assertion type '{kind}'")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def run_all(assertions: list[Assertion], workdir: Path, *, timeout: int = 300) -> list[AssertionResult]:
|
|
152
|
+
return [check(a, workdir, timeout=timeout) for a in assertions]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def score(results: list[AssertionResult]) -> float:
|
|
156
|
+
"""Weighted fraction of assertions that passed (0.0 - 1.0)."""
|
|
157
|
+
total = sum(r.weight for r in results)
|
|
158
|
+
if total <= 0:
|
|
159
|
+
return 0.0
|
|
160
|
+
return sum(r.weight for r in results if r.passed) / total
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def succeeded(results: list[AssertionResult]) -> bool:
|
|
164
|
+
"""A task counts as solved only when every required assertion passed."""
|
|
165
|
+
required = [r for r in results if r.required]
|
|
166
|
+
if not required:
|
|
167
|
+
return False
|
|
168
|
+
return all(r.passed for r in required)
|
agent_skill_lab/cli.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Command line entry point: agent-skill {init,validate,test,report}."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import __version__, reporter, scaffold, storage
|
|
10
|
+
from .config import ConfigError, load_skill, load_suite
|
|
11
|
+
from .runner import AgentError, check_agent_ready, dry_run, estimate, run_suite
|
|
12
|
+
from .scoring import Summary, summarise
|
|
13
|
+
from .validator import validate
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _out(message: str = "") -> None:
|
|
17
|
+
print(message, flush=True)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
21
|
+
target = Path(args.path)
|
|
22
|
+
_out(f"Scaffolding skill in {target.resolve()}")
|
|
23
|
+
for line in scaffold.init_skill(target, force=args.force):
|
|
24
|
+
_out(line)
|
|
25
|
+
_out()
|
|
26
|
+
_out("Next: fill in SKILL.md, then replace the TODOs in evals/task-001.yaml.")
|
|
27
|
+
_out("`agent-skill validate` fails while placeholders remain - that is on purpose.")
|
|
28
|
+
return 0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def cmd_validate(args: argparse.Namespace) -> int:
|
|
32
|
+
report = validate(Path(args.path), include_suite=not args.skill_only)
|
|
33
|
+
_out(reporter.render_validation(report, f"VALIDATE {Path(args.path).resolve()}"))
|
|
34
|
+
return 0 if report.passed else 1
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def cmd_test(args: argparse.Namespace) -> int:
|
|
38
|
+
skill_dir = Path(args.path)
|
|
39
|
+
|
|
40
|
+
report = validate(skill_dir)
|
|
41
|
+
if not report.passed:
|
|
42
|
+
_out(reporter.render_validation(report, f"VALIDATE {skill_dir.resolve()}"))
|
|
43
|
+
_out()
|
|
44
|
+
_out("Fix the errors above before benchmarking. Nothing was run.")
|
|
45
|
+
return 1
|
|
46
|
+
if report.warnings and not args.quiet:
|
|
47
|
+
for warning in report.warnings:
|
|
48
|
+
_out(f" [warn] {warning.message}")
|
|
49
|
+
_out()
|
|
50
|
+
|
|
51
|
+
suite = load_suite(
|
|
52
|
+
skill_dir,
|
|
53
|
+
repeats=args.repeats,
|
|
54
|
+
model=args.model,
|
|
55
|
+
only_tasks=args.task or None,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if args.dry_run:
|
|
59
|
+
_out(reporter.render_dry_run(dry_run(suite)))
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
if args.estimate:
|
|
63
|
+
_out(reporter.render_estimate(estimate(suite), model=suite.agent.model))
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
not_ready = check_agent_ready(suite.agent)
|
|
67
|
+
if not_ready:
|
|
68
|
+
_out(f"CANNOT RUN: {not_ready}")
|
|
69
|
+
return 2
|
|
70
|
+
|
|
71
|
+
est = estimate(suite)
|
|
72
|
+
_out(reporter.render_estimate(est, model=suite.agent.model))
|
|
73
|
+
|
|
74
|
+
total = len(suite.tasks) * suite.repeats * 2
|
|
75
|
+
_out(f"Skill: {suite.skill.name} ({suite.skill.fingerprint()})")
|
|
76
|
+
_out(f"Model: {suite.agent.model}")
|
|
77
|
+
_out(f"Plan: {len(suite.tasks)} task(s) x {suite.repeats} repeat(s) x 2 conditions = {total} agent runs")
|
|
78
|
+
_out("Each run spends real tokens. Ctrl-C to abort.")
|
|
79
|
+
_out()
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
results = run_suite(suite, on_event=_out, keep_workspace=args.keep_workspace)
|
|
83
|
+
except AgentError as exc:
|
|
84
|
+
_out()
|
|
85
|
+
_out(f"AGENT ERROR: {exc}")
|
|
86
|
+
_out("Nothing usable was measured. Check `claude -p 'hi' --output-format json` works first.")
|
|
87
|
+
return 2
|
|
88
|
+
except KeyboardInterrupt:
|
|
89
|
+
_out("\nAborted.")
|
|
90
|
+
return 130
|
|
91
|
+
|
|
92
|
+
summary = summarise(results)
|
|
93
|
+
path = storage.save_run(
|
|
94
|
+
skill_dir,
|
|
95
|
+
skill_name=suite.skill.name,
|
|
96
|
+
fingerprint=suite.skill.fingerprint(),
|
|
97
|
+
model=suite.agent.model,
|
|
98
|
+
results=results,
|
|
99
|
+
summary=summary,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
_out(
|
|
103
|
+
reporter.render_summary(
|
|
104
|
+
summary,
|
|
105
|
+
skill_name=suite.skill.name,
|
|
106
|
+
model=suite.agent.model,
|
|
107
|
+
fingerprint=suite.skill.fingerprint(),
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
_out(f"Saved: {path}")
|
|
111
|
+
|
|
112
|
+
success = summary.deltas["success"]
|
|
113
|
+
if args.strict and not (success.conclusive and success.improved):
|
|
114
|
+
_out("--strict: no conclusive improvement in task success.")
|
|
115
|
+
return 1
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def cmd_report(args: argparse.Namespace) -> int:
|
|
120
|
+
skill_dir = Path(args.path)
|
|
121
|
+
record = storage.latest_run(skill_dir)
|
|
122
|
+
if record is None:
|
|
123
|
+
_out(f"No recorded runs in {storage.runs_dir(skill_dir)}. Run `agent-skill test` first.")
|
|
124
|
+
return 1
|
|
125
|
+
|
|
126
|
+
summary = Summary(**{**record["summary"], "deltas": {}})
|
|
127
|
+
from .scoring import Delta # local import keeps the module import graph flat
|
|
128
|
+
|
|
129
|
+
summary.deltas = {k: Delta(**v) for k, v in record["summary"]["deltas"].items()}
|
|
130
|
+
|
|
131
|
+
_out(
|
|
132
|
+
reporter.render_summary(
|
|
133
|
+
summary,
|
|
134
|
+
skill_name=record["skill"],
|
|
135
|
+
model=record["model"],
|
|
136
|
+
fingerprint=record["fingerprint"],
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
history = storage.load_history(skill_dir)
|
|
141
|
+
if len(history) > 1 and args.history:
|
|
142
|
+
_out("HISTORY")
|
|
143
|
+
_out(f" {'timestamp':<18}{'fingerprint':<18}{'success w/':>11}{'tokens w/':>12}")
|
|
144
|
+
for entry in history[-10:]:
|
|
145
|
+
_out(
|
|
146
|
+
f" {entry['timestamp']:<18}{entry['fingerprint']:<18}"
|
|
147
|
+
f"{(entry.get('with_success') or 0) * 100:>10.0f}%"
|
|
148
|
+
f"{(entry.get('with_tokens') or 0):>12,.0f}"
|
|
149
|
+
)
|
|
150
|
+
_out()
|
|
151
|
+
_out("Fingerprint changes mean the skill content changed between runs.")
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
156
|
+
parser = argparse.ArgumentParser(
|
|
157
|
+
prog="agent-skill",
|
|
158
|
+
description="Reproducible A/B testing for Agent Skills.",
|
|
159
|
+
)
|
|
160
|
+
parser.add_argument("--version", action="version", version=f"agent-skill-lab {__version__}")
|
|
161
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
162
|
+
|
|
163
|
+
init = subparsers.add_parser("init", help="scaffold a skill and eval suite")
|
|
164
|
+
init.add_argument("path", nargs="?", default=".", help="target directory")
|
|
165
|
+
init.add_argument("--force", action="store_true", help="overwrite existing files")
|
|
166
|
+
init.set_defaults(func=cmd_init)
|
|
167
|
+
|
|
168
|
+
validate_cmd = subparsers.add_parser("validate", help="check a skill and its eval suite")
|
|
169
|
+
validate_cmd.add_argument("path", nargs="?", default=".", help="skill directory")
|
|
170
|
+
validate_cmd.add_argument("--skill-only", action="store_true", help="skip eval suite checks")
|
|
171
|
+
validate_cmd.set_defaults(func=cmd_validate)
|
|
172
|
+
|
|
173
|
+
test = subparsers.add_parser("test", help="run the A/B benchmark")
|
|
174
|
+
test.add_argument("path", nargs="?", default=".", help="skill directory")
|
|
175
|
+
test.add_argument("-n", "--repeats", type=int, help="runs per condition per task")
|
|
176
|
+
test.add_argument("--model", help="override the model from evals.yaml")
|
|
177
|
+
test.add_argument("--task", action="append", help="only run this task id (repeatable)")
|
|
178
|
+
test.add_argument(
|
|
179
|
+
"--dry-run",
|
|
180
|
+
action="store_true",
|
|
181
|
+
help="check assertions against the pristine fixture without spawning an agent",
|
|
182
|
+
)
|
|
183
|
+
test.add_argument(
|
|
184
|
+
"--estimate",
|
|
185
|
+
action="store_true",
|
|
186
|
+
help="project cost and time, then exit without spawning an agent",
|
|
187
|
+
)
|
|
188
|
+
test.add_argument("--keep-workspace", action="store_true", help="do not delete run workspaces")
|
|
189
|
+
test.add_argument("--strict", action="store_true", help="exit 1 unless success improved conclusively")
|
|
190
|
+
test.add_argument("--quiet", action="store_true", help="hide validation warnings")
|
|
191
|
+
test.set_defaults(func=cmd_test)
|
|
192
|
+
|
|
193
|
+
report = subparsers.add_parser("report", help="re-render the most recent run")
|
|
194
|
+
report.add_argument("path", nargs="?", default=".", help="skill directory")
|
|
195
|
+
report.add_argument("--history", action="store_true", help="also list previous invocations")
|
|
196
|
+
report.set_defaults(func=cmd_report)
|
|
197
|
+
|
|
198
|
+
return parser
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def main(argv: list[str] | None = None) -> int:
|
|
202
|
+
try:
|
|
203
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
204
|
+
except (AttributeError, ValueError):
|
|
205
|
+
pass
|
|
206
|
+
|
|
207
|
+
args = build_parser().parse_args(argv)
|
|
208
|
+
try:
|
|
209
|
+
return args.func(args)
|
|
210
|
+
except ConfigError as exc:
|
|
211
|
+
_out(f"CONFIG ERROR: {exc}")
|
|
212
|
+
return 1
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
if __name__ == "__main__":
|
|
216
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Loading and parsing of skills and eval suites."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
FRONTMATTER_RE = re.compile(r"\A---[ \t]*\r?\n(.*?)\r?\n---[ \t]*\r?\n?(.*)\Z", re.S)
|
|
14
|
+
|
|
15
|
+
# Directories inside a skill that must never be copied into the agent's
|
|
16
|
+
# workspace: the eval suite contains the assertions, and leaking them would let
|
|
17
|
+
# the agent optimise for the grader instead of the task.
|
|
18
|
+
SKILL_COPY_EXCLUDE = {"evals", ".agent-skill", ".git", "__pycache__"}
|
|
19
|
+
|
|
20
|
+
# A sentinel rather than a word like "TODO": eval prompts legitimately say
|
|
21
|
+
# things like "implement the TODO comments in src/", and flagging those would
|
|
22
|
+
# block real suites.
|
|
23
|
+
PLACEHOLDER_SENTINEL = "REPLACE-ME"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ConfigError(Exception):
|
|
27
|
+
"""Raised when a skill or eval suite cannot be loaded."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Skill:
|
|
32
|
+
dir: Path
|
|
33
|
+
frontmatter: dict[str, Any]
|
|
34
|
+
body: str
|
|
35
|
+
raw: str
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def name(self) -> str:
|
|
39
|
+
return str(self.frontmatter.get("name") or self.dir.name)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def description(self) -> str:
|
|
43
|
+
return str(self.frontmatter.get("description") or "")
|
|
44
|
+
|
|
45
|
+
def fingerprint(self) -> str:
|
|
46
|
+
"""Content hash of SKILL.md plus every bundled resource file.
|
|
47
|
+
|
|
48
|
+
Used to tell whether a recorded run still describes the current skill.
|
|
49
|
+
"""
|
|
50
|
+
h = hashlib.sha256()
|
|
51
|
+
for path in sorted(iter_skill_files(self.dir)):
|
|
52
|
+
h.update(path.relative_to(self.dir).as_posix().encode())
|
|
53
|
+
h.update(path.read_bytes())
|
|
54
|
+
return h.hexdigest()[:16]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def iter_skill_files(skill_dir: Path):
|
|
58
|
+
for path in sorted(skill_dir.rglob("*")):
|
|
59
|
+
if not path.is_file():
|
|
60
|
+
continue
|
|
61
|
+
rel = path.relative_to(skill_dir)
|
|
62
|
+
if set(rel.parts) & SKILL_COPY_EXCLUDE:
|
|
63
|
+
continue
|
|
64
|
+
yield path
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class Assertion:
|
|
69
|
+
type: str
|
|
70
|
+
weight: float = 1.0
|
|
71
|
+
required: bool = True
|
|
72
|
+
spec: dict[str, Any] = field(default_factory=dict)
|
|
73
|
+
|
|
74
|
+
def label(self) -> str:
|
|
75
|
+
if self.type == "command":
|
|
76
|
+
return f"command: {self.spec.get('run', '')}"
|
|
77
|
+
target = self.spec.get("path", "")
|
|
78
|
+
extra = self.spec.get("pattern", "")
|
|
79
|
+
return f"{self.type}: {target}" + (f" ~ /{extra}/" if extra else "")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class Task:
|
|
84
|
+
id: str
|
|
85
|
+
name: str
|
|
86
|
+
prompt: str
|
|
87
|
+
path: Path
|
|
88
|
+
fixture: Path | None
|
|
89
|
+
assertions: list[Assertion]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class AgentConfig:
|
|
94
|
+
provider: str = "claude-code"
|
|
95
|
+
model: str = "sonnet"
|
|
96
|
+
timeout_seconds: int = 600
|
|
97
|
+
setting_sources: str = "project"
|
|
98
|
+
# "default": the agent keeps whatever skills the Claude Code install bundles
|
|
99
|
+
# (they appear in both conditions, so they cancel out of the delta).
|
|
100
|
+
# "bare": run with --bare for a clean-room baseline with no bundled/plugin
|
|
101
|
+
# skills. Requires ANTHROPIC_API_KEY, since --bare never reads OAuth.
|
|
102
|
+
isolation: str = "default"
|
|
103
|
+
extra_args: list[str] = field(default_factory=list)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def model_is_pinned(self) -> bool:
|
|
107
|
+
# A pinned id looks like claude-sonnet-4-5-20250929; aliases are bare
|
|
108
|
+
# words such as "sonnet" or "opus" and float across model releases.
|
|
109
|
+
return bool(re.search(r"\d{6,}", self.model))
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def is_bare(self) -> bool:
|
|
113
|
+
return self.isolation == "bare"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class Suite:
|
|
118
|
+
skill: Skill
|
|
119
|
+
evals_dir: Path
|
|
120
|
+
agent: AgentConfig
|
|
121
|
+
repeats: int
|
|
122
|
+
parallel: int
|
|
123
|
+
tasks: list[Task]
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def state_dir(self) -> Path:
|
|
127
|
+
return self.skill.dir / ".agent-skill"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def parse_frontmatter(raw: str) -> tuple[dict[str, Any], str]:
|
|
131
|
+
match = FRONTMATTER_RE.match(raw.lstrip(""))
|
|
132
|
+
if not match:
|
|
133
|
+
return {}, raw
|
|
134
|
+
try:
|
|
135
|
+
data = yaml.safe_load(match.group(1)) or {}
|
|
136
|
+
except yaml.YAMLError as exc:
|
|
137
|
+
raise ConfigError(f"invalid YAML frontmatter: {exc}") from exc
|
|
138
|
+
if not isinstance(data, dict):
|
|
139
|
+
raise ConfigError("frontmatter must be a YAML mapping")
|
|
140
|
+
return data, match.group(2)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def load_skill(skill_dir: Path) -> Skill:
|
|
144
|
+
skill_dir = skill_dir.resolve()
|
|
145
|
+
md = skill_dir / "SKILL.md"
|
|
146
|
+
if not md.is_file():
|
|
147
|
+
raise ConfigError(f"no SKILL.md in {skill_dir}")
|
|
148
|
+
raw = md.read_text(encoding="utf-8")
|
|
149
|
+
frontmatter, body = parse_frontmatter(raw)
|
|
150
|
+
return Skill(dir=skill_dir, frontmatter=frontmatter, body=body, raw=raw)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _load_yaml(path: Path) -> dict[str, Any]:
|
|
154
|
+
try:
|
|
155
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
156
|
+
except yaml.YAMLError as exc:
|
|
157
|
+
raise ConfigError(f"{path.name}: invalid YAML: {exc}") from exc
|
|
158
|
+
if not isinstance(data, dict):
|
|
159
|
+
raise ConfigError(f"{path.name}: expected a YAML mapping at the top level")
|
|
160
|
+
return data
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def load_task(path: Path, evals_dir: Path) -> Task:
|
|
164
|
+
data = _load_yaml(path)
|
|
165
|
+
prompt = str(data.get("prompt") or "").strip()
|
|
166
|
+
if not prompt:
|
|
167
|
+
raise ConfigError(f"{path.name}: missing 'prompt'")
|
|
168
|
+
|
|
169
|
+
fixture = None
|
|
170
|
+
if data.get("fixture"):
|
|
171
|
+
fixture = (evals_dir / str(data["fixture"])).resolve()
|
|
172
|
+
|
|
173
|
+
assertions = []
|
|
174
|
+
for index, item in enumerate(data.get("assertions") or []):
|
|
175
|
+
if not isinstance(item, dict):
|
|
176
|
+
raise ConfigError(f"{path.name}: assertion #{index + 1} must be a mapping")
|
|
177
|
+
kind = item.get("type")
|
|
178
|
+
if not kind:
|
|
179
|
+
raise ConfigError(f"{path.name}: assertion #{index + 1} has no 'type'")
|
|
180
|
+
assertions.append(
|
|
181
|
+
Assertion(
|
|
182
|
+
type=str(kind),
|
|
183
|
+
weight=float(item.get("weight", 1.0)),
|
|
184
|
+
required=bool(item.get("required", True)),
|
|
185
|
+
spec=item,
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
return Task(
|
|
190
|
+
id=str(data.get("id") or path.stem),
|
|
191
|
+
name=str(data.get("name") or path.stem),
|
|
192
|
+
prompt=prompt,
|
|
193
|
+
path=path,
|
|
194
|
+
fixture=fixture,
|
|
195
|
+
assertions=assertions,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def load_suite(
|
|
200
|
+
skill_dir: Path,
|
|
201
|
+
*,
|
|
202
|
+
repeats: int | None = None,
|
|
203
|
+
model: str | None = None,
|
|
204
|
+
only_tasks: list[str] | None = None,
|
|
205
|
+
) -> Suite:
|
|
206
|
+
skill = load_skill(skill_dir)
|
|
207
|
+
evals_dir = skill.dir / "evals"
|
|
208
|
+
if not evals_dir.is_dir():
|
|
209
|
+
raise ConfigError(
|
|
210
|
+
f"no evals/ directory in {skill.dir}. Run `agent-skill init` to scaffold one."
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
config_path = evals_dir / "evals.yaml"
|
|
214
|
+
config = _load_yaml(config_path) if config_path.is_file() else {}
|
|
215
|
+
|
|
216
|
+
agent_cfg = config.get("agent") or {}
|
|
217
|
+
agent = AgentConfig(
|
|
218
|
+
provider=str(agent_cfg.get("provider", "claude-code")),
|
|
219
|
+
model=str(model or agent_cfg.get("model", "sonnet")),
|
|
220
|
+
timeout_seconds=int(agent_cfg.get("timeout_seconds", 600)),
|
|
221
|
+
setting_sources=str(agent_cfg.get("setting_sources", "project")),
|
|
222
|
+
isolation=str(agent_cfg.get("isolation", "default")),
|
|
223
|
+
extra_args=list(agent_cfg.get("extra_args") or []),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
run_cfg = config.get("run") or {}
|
|
227
|
+
listed = config.get("tasks")
|
|
228
|
+
if listed:
|
|
229
|
+
task_paths = [evals_dir / str(name) for name in listed]
|
|
230
|
+
else:
|
|
231
|
+
task_paths = sorted(evals_dir.glob("task-*.yaml"))
|
|
232
|
+
|
|
233
|
+
missing = [p for p in task_paths if not p.is_file()]
|
|
234
|
+
if missing:
|
|
235
|
+
raise ConfigError("task file not found: " + ", ".join(p.name for p in missing))
|
|
236
|
+
|
|
237
|
+
tasks = [load_task(p, evals_dir) for p in task_paths]
|
|
238
|
+
if only_tasks:
|
|
239
|
+
wanted = set(only_tasks)
|
|
240
|
+
tasks = [t for t in tasks if t.id in wanted]
|
|
241
|
+
unknown = wanted - {t.id for t in tasks}
|
|
242
|
+
if unknown:
|
|
243
|
+
raise ConfigError("unknown task id: " + ", ".join(sorted(unknown)))
|
|
244
|
+
if not tasks:
|
|
245
|
+
raise ConfigError(f"no task-*.yaml files found in {evals_dir}")
|
|
246
|
+
|
|
247
|
+
return Suite(
|
|
248
|
+
skill=skill,
|
|
249
|
+
evals_dir=evals_dir,
|
|
250
|
+
agent=agent,
|
|
251
|
+
repeats=int(repeats or run_cfg.get("repeats", 5)),
|
|
252
|
+
parallel=int(run_cfg.get("parallel", 2)),
|
|
253
|
+
tasks=tasks,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def looks_like_placeholder(text: str) -> bool:
|
|
258
|
+
return PLACEHOLDER_SENTINEL in text.upper()
|