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.
@@ -0,0 +1,3 @@
1
+ """Agent Skill Lab - reproducible A/B testing for Agent Skills."""
2
+
3
+ __version__ = "0.1.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()