skillbench-cli 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.
- skillbench/__init__.py +1 -0
- skillbench/assertions.py +250 -0
- skillbench/claude.py +277 -0
- skillbench/cli.py +323 -0
- skillbench/config.py +269 -0
- skillbench/dashboard.py +543 -0
- skillbench/progress.py +219 -0
- skillbench/runner.py +471 -0
- skillbench/scaffold.py +106 -0
- skillbench/skills.py +168 -0
- skillbench/workspace.py +124 -0
- skillbench_cli-0.1.0.dist-info/METADATA +218 -0
- skillbench_cli-0.1.0.dist-info/RECORD +16 -0
- skillbench_cli-0.1.0.dist-info/WHEEL +4 -0
- skillbench_cli-0.1.0.dist-info/entry_points.txt +2 -0
- skillbench_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
skillbench/cli.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""The `skillbench` command line: `init`, `skill` and `agent`."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
import uuid
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from dataclasses import dataclass, field, replace
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from skillbench.claude import ClaudeNotFoundError, find_claude
|
|
13
|
+
from skillbench.config import TEST_FOLDERS, ConfigError, Project, TestSpec, find_project, load_test
|
|
14
|
+
from skillbench.dashboard import INDEX_FILE_NAME, format_duration, format_optional, write_dashboard
|
|
15
|
+
from skillbench.progress import Eta, Progress, RunHeading, load_history, make_progress
|
|
16
|
+
from skillbench.runner import AskUser, RunOptions, run_test
|
|
17
|
+
from skillbench.scaffold import scaffold
|
|
18
|
+
|
|
19
|
+
_EXIT_OK = 0
|
|
20
|
+
_EXIT_RUN_FAILED = 1
|
|
21
|
+
_EXIT_USAGE_ERROR = 2
|
|
22
|
+
_SUMMARY_HEADER = ("test", "status", "duration", "turns")
|
|
23
|
+
_ANSWER_PROMPT = "your answer (empty or /done to finish): "
|
|
24
|
+
_NO_PROJECT = "no skillbench/ folder here or above; run `skillbench init` to create one"
|
|
25
|
+
_TRIGGER_PROMPT_SHOWN_CHARS = 40
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _UsageError(Exception):
|
|
29
|
+
"""A problem with how skillbench was invoked; reported with exit code 2."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main(argv: list[str] | None = None) -> int:
|
|
33
|
+
args = _parser().parse_args(argv)
|
|
34
|
+
if args.command == "init":
|
|
35
|
+
return _init_command()
|
|
36
|
+
try:
|
|
37
|
+
project = find_project(Path.cwd())
|
|
38
|
+
if project is None:
|
|
39
|
+
raise _UsageError(_NO_PROJECT)
|
|
40
|
+
specs = _load_specs(project, args.command, args.target)
|
|
41
|
+
executable = find_claude()
|
|
42
|
+
except (ConfigError, ClaudeNotFoundError, _UsageError) as error:
|
|
43
|
+
print(f"skillbench: {error}", file=sys.stderr)
|
|
44
|
+
return _EXIT_USAGE_ERROR
|
|
45
|
+
progress = make_progress()
|
|
46
|
+
options = RunOptions(
|
|
47
|
+
executable=executable,
|
|
48
|
+
eval_id=f"{datetime.now():%Y%m%d-%H%M%S}-{uuid.uuid4().hex[:6]}",
|
|
49
|
+
model_override=args.model,
|
|
50
|
+
ask_user=_terminal_asker(progress) if sys.stdin.isatty() else None,
|
|
51
|
+
progress=progress,
|
|
52
|
+
)
|
|
53
|
+
variants = [variant for spec in specs for variant in _variants(spec, args.repeat)]
|
|
54
|
+
print(f"skillbench: {_describe_plan(variants)}")
|
|
55
|
+
return _run_all(_repeats_of(variants), options, project.runs_dir, progress)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _parser() -> argparse.ArgumentParser:
|
|
59
|
+
parser = argparse.ArgumentParser(
|
|
60
|
+
prog="skillbench", description="Test agent skills and full agent loops."
|
|
61
|
+
)
|
|
62
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
63
|
+
commands.add_parser("init", help="create the skillbench/ folder with sample tests")
|
|
64
|
+
for kind, folder in TEST_FOLDERS.items():
|
|
65
|
+
test_parser = commands.add_parser(kind, help=f"run tests in skillbench/{folder}/")
|
|
66
|
+
test_parser.add_argument(
|
|
67
|
+
"target", nargs="?", help="a test or skill name, or a test file or folder"
|
|
68
|
+
)
|
|
69
|
+
test_parser.add_argument("--model", help="model to use, passed to claude as given")
|
|
70
|
+
test_parser.add_argument(
|
|
71
|
+
"--repeat", type=_positive_int, metavar="N", help="run each test N times"
|
|
72
|
+
)
|
|
73
|
+
return parser
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _positive_int(text: str) -> int:
|
|
77
|
+
if not text.isdigit() or int(text) < 1:
|
|
78
|
+
raise argparse.ArgumentTypeError("must be 1 or more")
|
|
79
|
+
return int(text)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _init_command() -> int:
|
|
83
|
+
created, existing = scaffold(Path.cwd())
|
|
84
|
+
for path in created:
|
|
85
|
+
print(f"created {path.relative_to(Path.cwd()).as_posix()}")
|
|
86
|
+
for path in existing:
|
|
87
|
+
print(f"exists {path.relative_to(Path.cwd()).as_posix()} (left as is)")
|
|
88
|
+
if created:
|
|
89
|
+
print("\nnext: edit the sample tests, then run `skillbench skill` or `skillbench agent`")
|
|
90
|
+
return _EXIT_OK
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# --- choosing tests ------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _load_specs(project: Project, kind: str, target: str | None) -> list[TestSpec]:
|
|
97
|
+
"""Every test of this kind, or those `target` picks: a path, or a test's name, file name or
|
|
98
|
+
skill."""
|
|
99
|
+
if target is not None and Path(target).exists():
|
|
100
|
+
return [load_test(path, project, kind) for path in _find_test_files(Path(target))]
|
|
101
|
+
tests_dir = project.tests_dir(kind)
|
|
102
|
+
specs = [load_test(path, project, kind) for path in _find_test_files(tests_dir)]
|
|
103
|
+
if target is None:
|
|
104
|
+
return specs
|
|
105
|
+
matching = [spec for spec in specs if _is_picked_by(spec, target)]
|
|
106
|
+
if not matching:
|
|
107
|
+
names = ", ".join(spec.name for spec in specs)
|
|
108
|
+
raise _UsageError(f"no test or skill named '{target}' in {tests_dir} (tests: {names})")
|
|
109
|
+
return matching
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _is_picked_by(spec: TestSpec, target: str) -> bool:
|
|
113
|
+
skill = spec.skill or ""
|
|
114
|
+
return target in (spec.name, spec.source_path.stem, skill, skill.rsplit(":", 1)[-1])
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _find_test_files(test_path: Path) -> list[Path]:
|
|
118
|
+
if not test_path.is_dir():
|
|
119
|
+
return [test_path]
|
|
120
|
+
test_files = sorted(test_path.rglob("*.yaml"), key=lambda p: p.relative_to(test_path).parts)
|
|
121
|
+
if not test_files:
|
|
122
|
+
raise ConfigError(f"{test_path}: no *.yaml test files found")
|
|
123
|
+
return test_files
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# --- planning runs: one per trigger prompt and repeat --------------------------------------
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True)
|
|
130
|
+
class _PlannedRun:
|
|
131
|
+
spec: TestSpec
|
|
132
|
+
repeat_index: int
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _repeats_of(variants: list[TestSpec]) -> list[_PlannedRun]:
|
|
136
|
+
return [
|
|
137
|
+
_PlannedRun(variant, index)
|
|
138
|
+
for variant in variants
|
|
139
|
+
for index in range(1, variant.repeat + 1)
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _variants(spec: TestSpec, repeat_override: int | None) -> list[TestSpec]:
|
|
144
|
+
"""The test itself (unless it only has trigger prompts), then one test per trigger prompt."""
|
|
145
|
+
if repeat_override is not None:
|
|
146
|
+
spec = replace(spec, repeat=repeat_override)
|
|
147
|
+
variants = [spec] if spec.has_forced_run else []
|
|
148
|
+
for case in spec.triggers:
|
|
149
|
+
label = "should" if case.should_invoke else "should not"
|
|
150
|
+
name = f"{spec.name} [{label}] {_shorten(case.prompt, _TRIGGER_PROMPT_SHOWN_CHARS)}"
|
|
151
|
+
variants.append(replace(spec, name=name, trigger=case, triggers=[]))
|
|
152
|
+
return variants
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _describe_plan(variants: list[TestSpec]) -> str:
|
|
156
|
+
"""e.g. `6 tests × 5 repeats = 30 runs`."""
|
|
157
|
+
repeats = {variant.repeat for variant in variants}
|
|
158
|
+
total = sum(variant.repeat for variant in variants)
|
|
159
|
+
tests = _plural(len(variants), "test")
|
|
160
|
+
if repeats == {1}:
|
|
161
|
+
return f"{tests} = {_plural(total, 'run')}"
|
|
162
|
+
if len(repeats) == 1:
|
|
163
|
+
return f"{tests} × {_plural(repeats.pop(), 'repeat')} = {_plural(total, 'run')}"
|
|
164
|
+
return f"{tests} (some repeated) = {_plural(total, 'run')}"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# --- running and reporting -----------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass
|
|
171
|
+
class _TestResult:
|
|
172
|
+
"""One test, judged from all its repeats."""
|
|
173
|
+
|
|
174
|
+
test_name: str
|
|
175
|
+
min_pass_rate: float
|
|
176
|
+
records: list[dict[str, Any]] = field(default_factory=list)
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def passes(self) -> int:
|
|
180
|
+
return sum(1 for record in self.records if record["status"] == "pass")
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def is_repeated(self) -> bool:
|
|
184
|
+
return len(self.records) > 1
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def status(self) -> str:
|
|
188
|
+
if not self.is_repeated:
|
|
189
|
+
return self.records[0]["status"]
|
|
190
|
+
if self.passes / len(self.records) >= self.min_pass_rate:
|
|
191
|
+
return "pass"
|
|
192
|
+
if all(record["status"] == "error" for record in self.records):
|
|
193
|
+
return "error"
|
|
194
|
+
return "fail"
|
|
195
|
+
|
|
196
|
+
def assertion_hits(self) -> list[tuple[str, int]]:
|
|
197
|
+
"""Each assertion, labelled `type: expected`, and in how many runs it passed."""
|
|
198
|
+
hits: Counter[str] = Counter()
|
|
199
|
+
for record in self.records:
|
|
200
|
+
for result in record["assertions"]:
|
|
201
|
+
label = f"{result['type']}: {_format_expected(result['expected'])}"
|
|
202
|
+
hits[label] += 1 if result["passed"] else 0
|
|
203
|
+
return list(hits.items())
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _run_all(
|
|
207
|
+
runs: list[_PlannedRun], options: RunOptions, runs_root: Path, progress: Progress
|
|
208
|
+
) -> int:
|
|
209
|
+
eta = Eta(load_history(runs_root), [run.spec.name for run in runs])
|
|
210
|
+
results: dict[str, _TestResult] = {}
|
|
211
|
+
for index, run in enumerate(runs):
|
|
212
|
+
eta.start_run(index)
|
|
213
|
+
progress.run_started(_heading(run, options, index, len(runs)), eta)
|
|
214
|
+
record = run_test(run.spec, runs_root, options, repeat_index=run.repeat_index)
|
|
215
|
+
eta.finish_run(record["duration_s"])
|
|
216
|
+
write_dashboard(runs_root)
|
|
217
|
+
progress.run_finished(_run_line(record))
|
|
218
|
+
result = results.setdefault(
|
|
219
|
+
run.spec.name, _TestResult(run.spec.name, run.spec.min_pass_rate)
|
|
220
|
+
)
|
|
221
|
+
result.records.append(record)
|
|
222
|
+
print(_summary(list(results.values())))
|
|
223
|
+
print(f"dashboard: {_shown_path(runs_root / INDEX_FILE_NAME)}")
|
|
224
|
+
is_all_passed = all(result.status == "pass" for result in results.values())
|
|
225
|
+
return _EXIT_OK if is_all_passed else _EXIT_RUN_FAILED
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _heading(run: _PlannedRun, options: RunOptions, index: int, total: int) -> RunHeading:
|
|
229
|
+
spec = run.spec
|
|
230
|
+
planned_turns = None
|
|
231
|
+
if not spec.is_interactive:
|
|
232
|
+
planned_turns = 1 if spec.trigger is not None else 1 + len(spec.answers)
|
|
233
|
+
return RunHeading(
|
|
234
|
+
index=index + 1,
|
|
235
|
+
total=total,
|
|
236
|
+
test_name=spec.name,
|
|
237
|
+
model=options.model_for(spec),
|
|
238
|
+
planned_turns=planned_turns,
|
|
239
|
+
repeat_index=run.repeat_index,
|
|
240
|
+
repeat_count=spec.repeat,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _terminal_asker(progress: Progress) -> AskUser:
|
|
245
|
+
"""Show what the agent ended its turn with and read the user's reply; None at end of input."""
|
|
246
|
+
|
|
247
|
+
def ask(agent_final_text: str) -> str | None:
|
|
248
|
+
progress.pause()
|
|
249
|
+
print(f"\n--- agent ---\n{agent_final_text}\n-------------")
|
|
250
|
+
try:
|
|
251
|
+
return input(_ANSWER_PROMPT)
|
|
252
|
+
except EOFError:
|
|
253
|
+
return None
|
|
254
|
+
finally:
|
|
255
|
+
progress.resume()
|
|
256
|
+
|
|
257
|
+
return ask
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _run_line(record: dict[str, Any]) -> str:
|
|
261
|
+
repeat = record["repeat_count"]
|
|
262
|
+
repeat_text = f" (repeat {record['repeat_index']}/{repeat})" if repeat > 1 else ""
|
|
263
|
+
line = f"{record['test_name']}{repeat_text}: {record['status']} in {record['duration_s']}s"
|
|
264
|
+
return f"{line}\n {record['error']}" if record["error"] else line
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _summary(results: list[_TestResult]) -> str:
|
|
268
|
+
"""The results table, then for repeated tests the pass rate and each check's hit count."""
|
|
269
|
+
table = [_SUMMARY_HEADER, *(_summary_row(result) for result in results)]
|
|
270
|
+
widths = [max(len(row[column]) for row in table) for column in range(len(_SUMMARY_HEADER))]
|
|
271
|
+
lines = [
|
|
272
|
+
" ".join(cell.ljust(width) for cell, width in zip(row, widths, strict=True))
|
|
273
|
+
for row in table
|
|
274
|
+
]
|
|
275
|
+
for result in results:
|
|
276
|
+
if result.is_repeated:
|
|
277
|
+
lines += _repeat_lines(result)
|
|
278
|
+
return "\n".join(lines)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _summary_row(result: _TestResult) -> tuple[str, ...]:
|
|
282
|
+
status = result.status
|
|
283
|
+
if result.is_repeated:
|
|
284
|
+
status = f"{status} {result.passes}/{len(result.records)}"
|
|
285
|
+
return (
|
|
286
|
+
result.test_name,
|
|
287
|
+
status,
|
|
288
|
+
format_duration(sum(record["duration_s"] for record in result.records)),
|
|
289
|
+
format_optional(result.records[-1]["trace"]["num_turns"]),
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _repeat_lines(result: _TestResult) -> list[str]:
|
|
294
|
+
runs = len(result.records)
|
|
295
|
+
header = (
|
|
296
|
+
f"\n{result.test_name}: {result.passes}/{runs} runs passed, "
|
|
297
|
+
f"needs {result.min_pass_rate:.0%} -> {result.status}"
|
|
298
|
+
)
|
|
299
|
+
return [header, *(f" {label} {hits}/{runs}" for label, hits in result.assertion_hits())]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _format_expected(expected: Any) -> str:
|
|
303
|
+
if isinstance(expected, list):
|
|
304
|
+
return " -> ".join(map(str, expected))
|
|
305
|
+
if isinstance(expected, dict):
|
|
306
|
+
return ", ".join(f"{key}={value}" for key, value in expected.items())
|
|
307
|
+
return str(expected)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _shown_path(path: Path) -> str:
|
|
311
|
+
"""Relative to where the command was run, when it's below there."""
|
|
312
|
+
try:
|
|
313
|
+
return path.relative_to(Path.cwd()).as_posix()
|
|
314
|
+
except ValueError:
|
|
315
|
+
return str(path)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _shorten(text: str, limit: int) -> str:
|
|
319
|
+
return text if len(text) <= limit else text[: limit - 3] + "..."
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _plural(count: int, noun: str) -> str:
|
|
323
|
+
return f"{count} {noun}" if count == 1 else f"{count} {noun}s"
|
skillbench/config.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""The skillbench/ project folder and its defaults, and loading test YAML files into `TestSpec`s."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from skillbench.assertions import Assertion, InvalidAssertionError, parse_assertion
|
|
11
|
+
from skillbench.workspace import Fixture, GitFixture, is_git_url
|
|
12
|
+
|
|
13
|
+
PROJECT_DIR_NAME = "skillbench"
|
|
14
|
+
CONFIG_FILE_NAME = "skillbench.yaml"
|
|
15
|
+
TEST_FOLDERS = {"skill": "skills", "agent": "agent"} # test kind -> its folder in the project
|
|
16
|
+
_DEFAULT_TIMEOUT_S = 600
|
|
17
|
+
_TRIGGER_KEYS = ("should", "should_not")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConfigError(Exception):
|
|
21
|
+
"""A test file or skillbench.yaml is unreadable or invalid; the message names the field."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Project:
|
|
26
|
+
directory: Path # the skillbench/ folder
|
|
27
|
+
defaults: dict[str, Any] # skillbench.yaml, as written
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def repo_root(self) -> Path:
|
|
31
|
+
"""The repo the project folder lives in; `skill:` and `fixture:` paths start here."""
|
|
32
|
+
return self.directory.parent
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def runs_dir(self) -> Path:
|
|
36
|
+
return self.directory / "runs"
|
|
37
|
+
|
|
38
|
+
def tests_dir(self, kind: str) -> Path:
|
|
39
|
+
return self.directory / TEST_FOLDERS[kind]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class TriggerCase:
|
|
44
|
+
"""One prompt of a trigger test, and whether the skill should fire for it."""
|
|
45
|
+
|
|
46
|
+
prompt: str
|
|
47
|
+
should_invoke: bool
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class TestSpec:
|
|
52
|
+
"""A validated test."""
|
|
53
|
+
|
|
54
|
+
source_path: Path
|
|
55
|
+
name: str
|
|
56
|
+
prompt: str
|
|
57
|
+
kind: str # "skill" or "agent": which tests folder the file is in
|
|
58
|
+
fixture: Fixture | None = None
|
|
59
|
+
model: str | None = None # from skillbench.yaml; --model overrides it
|
|
60
|
+
timeout_s: float = _DEFAULT_TIMEOUT_S
|
|
61
|
+
answers: list[str] = field(default_factory=list)
|
|
62
|
+
is_interactive: bool = False
|
|
63
|
+
assertions: list[Assertion] = field(default_factory=list)
|
|
64
|
+
skill: str | None = None # `skill:`, found in the workspace at run time
|
|
65
|
+
skill_input: str = "" # `input:`, the text after the skill's /command
|
|
66
|
+
triggers: list[TriggerCase] = field(default_factory=list)
|
|
67
|
+
trigger: TriggerCase | None = None # set on the run of one trigger prompt
|
|
68
|
+
repeat: int = 1
|
|
69
|
+
min_pass_rate: float = 1.0
|
|
70
|
+
# The skillbench folder: never copied into a workspace made from the repo around it.
|
|
71
|
+
fixture_exclude: Path | None = None
|
|
72
|
+
# Plugins found in the workspace, set at run time so the agent loads all of them.
|
|
73
|
+
plugin_dirs: list[Path] = field(default_factory=list)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def has_forced_run(self) -> bool:
|
|
77
|
+
"""False for a trigger-only skill test: one with triggers but no `input:`."""
|
|
78
|
+
return not self.triggers or bool(self.skill_input)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def find_project(start: Path) -> Project | None:
|
|
82
|
+
"""The nearest project folder at or above `start`, like git finds `.git`. Raises
|
|
83
|
+
ConfigError if its skillbench.yaml is invalid."""
|
|
84
|
+
for directory in [start, *start.parents]:
|
|
85
|
+
for candidate in (directory / PROJECT_DIR_NAME, directory):
|
|
86
|
+
config_path = candidate / CONFIG_FILE_NAME
|
|
87
|
+
if candidate.name == PROJECT_DIR_NAME and config_path.is_file():
|
|
88
|
+
defaults = _read_yaml_mapping(config_path, "defaults")
|
|
89
|
+
_validate_fields(config_path, defaults, _DEFAULT_RULES)
|
|
90
|
+
return Project(directory=candidate.resolve(), defaults=defaults)
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def load_test(test_path: Path, project: Project, kind: str) -> TestSpec:
|
|
95
|
+
"""Read and validate one test file. Raises ConfigError naming the file and field. Fields
|
|
96
|
+
the test leaves out come from skillbench.yaml; `fixture:` paths start at the repo root."""
|
|
97
|
+
raw = _read_yaml_mapping(test_path, "test fields")
|
|
98
|
+
_validate_fields(test_path, raw, _TEST_RULES)
|
|
99
|
+
_validate_skill_or_prompt(test_path, raw)
|
|
100
|
+
merged = {**project.defaults, **raw}
|
|
101
|
+
return TestSpec(
|
|
102
|
+
source_path=test_path,
|
|
103
|
+
name=raw.get("name", test_path.stem),
|
|
104
|
+
prompt=raw.get("prompt", ""),
|
|
105
|
+
kind=kind,
|
|
106
|
+
fixture=_resolve_fixture(test_path, merged.get("fixture"), project.repo_root),
|
|
107
|
+
model=project.defaults.get("model"),
|
|
108
|
+
timeout_s=merged.get("timeout_s", _DEFAULT_TIMEOUT_S),
|
|
109
|
+
answers=raw.get("answers", []),
|
|
110
|
+
is_interactive=raw.get("interactive", False),
|
|
111
|
+
assertions=_parse_assertions(test_path, raw.get("assert", [])),
|
|
112
|
+
skill=raw.get("skill"),
|
|
113
|
+
skill_input=raw.get("input", ""),
|
|
114
|
+
triggers=_trigger_cases(raw.get("triggers", {})),
|
|
115
|
+
repeat=merged.get("repeat", 1),
|
|
116
|
+
min_pass_rate=merged.get("min_pass_rate", 1.0),
|
|
117
|
+
fixture_exclude=project.directory,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _read_yaml_mapping(path: Path, what: str) -> dict[str, Any]:
|
|
122
|
+
try:
|
|
123
|
+
parsed = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
124
|
+
except OSError as error:
|
|
125
|
+
raise ConfigError(f"{path}: cannot read file ({error.strerror})") from error
|
|
126
|
+
except yaml.YAMLError as error:
|
|
127
|
+
raise ConfigError(f"{path}: not valid YAML ({error})") from error
|
|
128
|
+
if parsed is None:
|
|
129
|
+
return {}
|
|
130
|
+
if not isinstance(parsed, dict):
|
|
131
|
+
raise ConfigError(f"{path}: top level must be a mapping of {what}")
|
|
132
|
+
return parsed
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _validate_fields(path: Path, raw: dict[str, Any], rules: dict[str, "_Rule"]) -> None:
|
|
136
|
+
for key, value in raw.items():
|
|
137
|
+
if key not in rules:
|
|
138
|
+
raise ConfigError(f"{path}: unknown field '{key}' (known fields: {', '.join(rules)})")
|
|
139
|
+
is_valid, expected = rules[key]
|
|
140
|
+
if not is_valid(value):
|
|
141
|
+
raise ConfigError(f"{path}: field '{key}' must be {expected}, got {value!r}")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _validate_skill_or_prompt(test_path: Path, raw: dict[str, Any]) -> None:
|
|
145
|
+
"""A test either invokes a skill or sends a prompt."""
|
|
146
|
+
if "skill" in raw:
|
|
147
|
+
if "prompt" in raw:
|
|
148
|
+
raise ConfigError(
|
|
149
|
+
f"{test_path}: use 'input' with 'skill' (skillbench builds the /command); "
|
|
150
|
+
"'prompt' is for tests that don't force a skill"
|
|
151
|
+
)
|
|
152
|
+
return
|
|
153
|
+
for key in ("input", "triggers"):
|
|
154
|
+
if key in raw:
|
|
155
|
+
raise ConfigError(f"{test_path}: field '{key}' needs 'skill' (the skill to test)")
|
|
156
|
+
if "prompt" not in raw:
|
|
157
|
+
raise ConfigError(f"{test_path}: missing required field 'prompt' (or 'skill')")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _resolve_fixture(test_path: Path, raw_fixture: Any, repo_root: Path) -> Fixture | None:
|
|
161
|
+
if raw_fixture is None:
|
|
162
|
+
return None
|
|
163
|
+
if isinstance(raw_fixture, dict):
|
|
164
|
+
return GitFixture(url=raw_fixture["git"], ref=raw_fixture.get("ref"))
|
|
165
|
+
if is_git_url(raw_fixture):
|
|
166
|
+
return GitFixture(url=raw_fixture)
|
|
167
|
+
local = (repo_root / raw_fixture).resolve()
|
|
168
|
+
if not local.is_dir():
|
|
169
|
+
raise ConfigError(f"{test_path}: field 'fixture' directory not found: {local}")
|
|
170
|
+
return local
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _trigger_cases(raw_triggers: dict[str, list[str]]) -> list[TriggerCase]:
|
|
174
|
+
return [
|
|
175
|
+
TriggerCase(prompt=prompt, should_invoke=key == "should")
|
|
176
|
+
for key in _TRIGGER_KEYS
|
|
177
|
+
for prompt in raw_triggers.get(key, [])
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _parse_assertions(test_path: Path, entries: list[Any]) -> list[Assertion]:
|
|
182
|
+
assertions = []
|
|
183
|
+
for index, entry in enumerate(entries):
|
|
184
|
+
try:
|
|
185
|
+
assertions.append(parse_assertion(entry))
|
|
186
|
+
except InvalidAssertionError as error:
|
|
187
|
+
raise ConfigError(f"{test_path}: field 'assert[{index}]' {error}") from error
|
|
188
|
+
return assertions
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# --- field validators ----------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _is_string(value: Any) -> bool:
|
|
195
|
+
return isinstance(value, str)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _is_non_empty_string(value: Any) -> bool:
|
|
199
|
+
return isinstance(value, str) and value.strip() != ""
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _is_string_list(value: Any) -> bool:
|
|
203
|
+
return isinstance(value, list) and all(isinstance(item, str) for item in value)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _is_bool(value: Any) -> bool:
|
|
207
|
+
return isinstance(value, bool)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _is_positive_int(value: Any) -> bool:
|
|
211
|
+
return isinstance(value, int) and not isinstance(value, bool) and value > 0
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _is_positive_number(value: Any) -> bool:
|
|
215
|
+
return isinstance(value, int | float) and not isinstance(value, bool) and value > 0
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _is_rate(value: Any) -> bool:
|
|
219
|
+
return isinstance(value, int | float) and not isinstance(value, bool) and 0 <= value <= 1
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _is_list(value: Any) -> bool:
|
|
223
|
+
return isinstance(value, list)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _is_fixture(value: Any) -> bool:
|
|
227
|
+
if isinstance(value, str):
|
|
228
|
+
return value != ""
|
|
229
|
+
return (
|
|
230
|
+
isinstance(value, dict)
|
|
231
|
+
and "git" in value
|
|
232
|
+
and set(value) <= {"git", "ref"}
|
|
233
|
+
and all(map(_is_non_empty_string, value.values()))
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _is_triggers(value: Any) -> bool:
|
|
238
|
+
return (
|
|
239
|
+
isinstance(value, dict)
|
|
240
|
+
and set(value) <= set(_TRIGGER_KEYS)
|
|
241
|
+
and all(
|
|
242
|
+
_is_string_list(prompts) and all(map(_is_non_empty_string, prompts))
|
|
243
|
+
for prompts in value.values()
|
|
244
|
+
)
|
|
245
|
+
and any(value.values())
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
_Rule = tuple[Callable[[Any], bool], str]
|
|
250
|
+
|
|
251
|
+
# Fields a test and skillbench.yaml share: YAML key -> (validator, expected value).
|
|
252
|
+
_SHARED_RULES: dict[str, _Rule] = {
|
|
253
|
+
"fixture": (_is_fixture, "a path, a git URL, or {git: <url>, ref: <branch|tag|commit>}"),
|
|
254
|
+
"timeout_s": (_is_positive_number, "a positive number"),
|
|
255
|
+
"repeat": (_is_positive_int, "a positive integer"),
|
|
256
|
+
"min_pass_rate": (_is_rate, "a number from 0 to 1"),
|
|
257
|
+
}
|
|
258
|
+
_DEFAULT_RULES: dict[str, _Rule] = {"model": (_is_string, "a string"), **_SHARED_RULES}
|
|
259
|
+
_TEST_RULES: dict[str, _Rule] = {
|
|
260
|
+
"name": (_is_string, "a string"),
|
|
261
|
+
"skill": (_is_non_empty_string, "a skill name or a path to it"),
|
|
262
|
+
"input": (_is_string, "a string"),
|
|
263
|
+
"triggers": (_is_triggers, "a mapping with 'should' and/or 'should_not' lists of prompts"),
|
|
264
|
+
"prompt": (_is_string, "a string"),
|
|
265
|
+
**_SHARED_RULES,
|
|
266
|
+
"answers": (_is_string_list, "a list of strings"),
|
|
267
|
+
"interactive": (_is_bool, "true or false"),
|
|
268
|
+
"assert": (_is_list, "a list"),
|
|
269
|
+
}
|