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/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
+ }