skillproof 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.
- skillproof/__init__.py +3 -0
- skillproof/__main__.py +3 -0
- skillproof/assertions/__init__.py +62 -0
- skillproof/assertions/base.py +44 -0
- skillproof/assertions/builtin.py +176 -0
- skillproof/assertions/llm_judge.py +24 -0
- skillproof/cli.py +313 -0
- skillproof/config.py +188 -0
- skillproof/engine.py +268 -0
- skillproof/errors.py +72 -0
- skillproof/estimate.py +83 -0
- skillproof/judges/__init__.py +43 -0
- skillproof/judges/anthropic_judge.py +59 -0
- skillproof/judges/base.py +69 -0
- skillproof/judges/mock.py +21 -0
- skillproof/judges/openai_judge.py +58 -0
- skillproof/parsers/__init__.py +6 -0
- skillproof/parsers/claude_code.py +144 -0
- skillproof/parsers/codex.py +112 -0
- skillproof/report/__init__.py +34 -0
- skillproof/report/badge.py +60 -0
- skillproof/report/format.py +75 -0
- skillproof/report/html_report.py +296 -0
- skillproof/report/json_report.py +9 -0
- skillproof/report/markdown.py +78 -0
- skillproof/report/summary.py +216 -0
- skillproof/report/terminal.py +89 -0
- skillproof/results.py +88 -0
- skillproof/runners/__init__.py +33 -0
- skillproof/runners/base.py +102 -0
- skillproof/runners/claude_code.py +117 -0
- skillproof/runners/codex.py +133 -0
- skillproof/runners/mock.py +91 -0
- skillproof/runners/openai_compat.py +238 -0
- skillproof/sandbox.py +85 -0
- skillproof/scaffold.py +153 -0
- skillproof/skill.py +97 -0
- skillproof/stats.py +228 -0
- skillproof-0.1.0.dist-info/METADATA +262 -0
- skillproof-0.1.0.dist-info/RECORD +43 -0
- skillproof-0.1.0.dist-info/WHEEL +4 -0
- skillproof-0.1.0.dist-info/entry_points.txt +2 -0
- skillproof-0.1.0.dist-info/licenses/LICENSE +21 -0
skillproof/config.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""The evals.yaml schema and loader.
|
|
2
|
+
|
|
3
|
+
Paths in evals.yaml are relative to the evals.yaml file. `load_config()`
|
|
4
|
+
returns a config with every path resolved to an absolute one.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Literal
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, ValidationError, model_validator
|
|
14
|
+
|
|
15
|
+
from skillproof.assertions import ASSERTION_TYPES, Assertion
|
|
16
|
+
from skillproof.errors import ConfigError
|
|
17
|
+
from skillproof.runners import RUNNERS, get_runner_class
|
|
18
|
+
|
|
19
|
+
CONFIG_NAMES = ("evals.yaml", "evals.yml")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class JudgeConfig(BaseModel):
|
|
23
|
+
"""Which model grades `llm_judge` assertions.
|
|
24
|
+
|
|
25
|
+
`auto` picks a vendor different from the agent under test (Claude agents
|
|
26
|
+
are judged by an OpenAI model and vice versa) to avoid self-grading.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
model_config = ConfigDict(extra="forbid")
|
|
30
|
+
|
|
31
|
+
provider: Literal["auto", "anthropic", "openai", "mock"] = "auto"
|
|
32
|
+
model: str | None = None # None = the provider's default judge model
|
|
33
|
+
base_url: str | None = None # for OpenAI-compatible judge endpoints
|
|
34
|
+
api_key_env: str | None = None # env var holding the key; None = provider default
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Case(BaseModel):
|
|
38
|
+
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
|
39
|
+
|
|
40
|
+
name: str = Field(min_length=1)
|
|
41
|
+
prompt: str = Field(min_length=1)
|
|
42
|
+
fixtures: Path | None = None # folder copied into each run's sandbox
|
|
43
|
+
trials: int | None = Field(default=None, ge=1, le=100) # overrides the top-level value
|
|
44
|
+
timeout: float | None = Field(default=None, gt=0) # seconds; overrides top-level
|
|
45
|
+
# false = a negative case: the skill should NOT activate for this prompt.
|
|
46
|
+
should_trigger: bool = True
|
|
47
|
+
assertions: list[Assertion] = Field(default_factory=list, alias="assert")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class EvalConfig(BaseModel):
|
|
51
|
+
model_config = ConfigDict(extra="forbid")
|
|
52
|
+
|
|
53
|
+
skill: Path = Path(".")
|
|
54
|
+
runner: str = "claude-code"
|
|
55
|
+
model: str | None = None # passed to the runner; None = the agent's default
|
|
56
|
+
runner_options: dict[str, Any] = Field(default_factory=dict)
|
|
57
|
+
trials: int = Field(default=5, ge=1, le=100)
|
|
58
|
+
timeout: float = Field(default=300, gt=0) # seconds per run
|
|
59
|
+
judge: JudgeConfig = Field(default_factory=JudgeConfig)
|
|
60
|
+
cases: list[Case] = Field(min_length=1)
|
|
61
|
+
|
|
62
|
+
_path: Path | None = PrivateAttr(default=None)
|
|
63
|
+
|
|
64
|
+
@model_validator(mode="after")
|
|
65
|
+
def _check(self) -> EvalConfig:
|
|
66
|
+
if self.runner not in RUNNERS:
|
|
67
|
+
valid = ", ".join(RUNNERS)
|
|
68
|
+
raise ValueError(f"unknown runner {self.runner!r}. Valid runners: {valid}")
|
|
69
|
+
if self.model is None and get_runner_class(self.runner).requires_model:
|
|
70
|
+
raise ValueError(f"the {self.runner} runner needs `model:` (e.g. model: gpt-6.1-sol)")
|
|
71
|
+
seen: set[str] = set()
|
|
72
|
+
for case in self.cases:
|
|
73
|
+
if case.name in seen:
|
|
74
|
+
raise ValueError(f"duplicate case name {case.name!r}; case names must be unique")
|
|
75
|
+
seen.add(case.name)
|
|
76
|
+
return self
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def path(self) -> Path | None:
|
|
80
|
+
"""The evals.yaml this config was loaded from."""
|
|
81
|
+
return self._path
|
|
82
|
+
|
|
83
|
+
def trials_for(self, case: Case) -> int:
|
|
84
|
+
return case.trials or self.trials
|
|
85
|
+
|
|
86
|
+
def timeout_for(self, case: Case) -> float:
|
|
87
|
+
return case.timeout or self.timeout
|
|
88
|
+
|
|
89
|
+
def uses_judge(self) -> bool:
|
|
90
|
+
return any(a.type == "llm_judge" for c in self.cases for a in c.assertions)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def find_config(path: Path) -> Path:
|
|
94
|
+
"""Accept either an evals.yaml file or a folder containing one."""
|
|
95
|
+
if path.is_file():
|
|
96
|
+
return path
|
|
97
|
+
if path.is_dir():
|
|
98
|
+
for name in CONFIG_NAMES:
|
|
99
|
+
if (path / name).is_file():
|
|
100
|
+
return path / name
|
|
101
|
+
raise ConfigError(
|
|
102
|
+
f"No evals.yaml in {path}",
|
|
103
|
+
hint=f"Create one with: skillproof init {path}",
|
|
104
|
+
)
|
|
105
|
+
raise ConfigError(f"Path not found: {path}", hint="Pass a skill folder or an evals.yaml file.")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def format_validation_error(exc: ValidationError, source: Path | str) -> str:
|
|
109
|
+
"""Turn pydantic errors into lines like `cases[0].assert[1]: unknown field 'vale'`."""
|
|
110
|
+
lines = []
|
|
111
|
+
for err in exc.errors():
|
|
112
|
+
loc = ""
|
|
113
|
+
for part in err["loc"]:
|
|
114
|
+
if isinstance(part, int):
|
|
115
|
+
loc += f"[{part}]"
|
|
116
|
+
elif part in ASSERTION_TYPES:
|
|
117
|
+
continue # pydantic adds the union tag to the path; it's noise for users
|
|
118
|
+
else:
|
|
119
|
+
loc += f".{part}" if loc else str(part)
|
|
120
|
+
msg = err["msg"]
|
|
121
|
+
if err["type"] == "extra_forbidden":
|
|
122
|
+
msg = f"unknown field {err['loc'][-1]!r} (typo?)"
|
|
123
|
+
elif err["type"] == "missing":
|
|
124
|
+
msg = "missing required field"
|
|
125
|
+
elif err["type"] == "union_tag_invalid":
|
|
126
|
+
msg = f"unknown assertion type {err['input'].get('type')!r}. " + (
|
|
127
|
+
"Valid types: " + ", ".join(ASSERTION_TYPES)
|
|
128
|
+
)
|
|
129
|
+
elif msg.startswith("Value error, "):
|
|
130
|
+
msg = msg.removeprefix("Value error, ")
|
|
131
|
+
lines.append(f" {loc or '(top level)'}: {msg}")
|
|
132
|
+
return f"{source} is invalid:\n" + "\n".join(lines)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def parse_config(data: Any, base_dir: Path, source: Path | str = "evals.yaml") -> EvalConfig:
|
|
136
|
+
"""Validate raw YAML data and resolve paths relative to `base_dir`."""
|
|
137
|
+
if not isinstance(data, dict):
|
|
138
|
+
raise ConfigError(
|
|
139
|
+
f"{source} must be a YAML mapping (key: value pairs).",
|
|
140
|
+
hint="Run `skillproof init` to see an example evals.yaml.",
|
|
141
|
+
)
|
|
142
|
+
try:
|
|
143
|
+
config = EvalConfig.model_validate(data)
|
|
144
|
+
except ValidationError as exc:
|
|
145
|
+
raise ConfigError(
|
|
146
|
+
format_validation_error(exc, source),
|
|
147
|
+
hint="Fix the fields above. See `skillproof init` output for a working example.",
|
|
148
|
+
) from exc
|
|
149
|
+
|
|
150
|
+
base_dir = base_dir.resolve()
|
|
151
|
+
config.skill = (base_dir / config.skill).resolve()
|
|
152
|
+
for case in config.cases:
|
|
153
|
+
if case.fixtures is None:
|
|
154
|
+
continue
|
|
155
|
+
case.fixtures = (base_dir / case.fixtures).resolve()
|
|
156
|
+
if not case.fixtures.is_dir():
|
|
157
|
+
raise ConfigError(
|
|
158
|
+
f"Case {case.name!r}: fixtures folder not found: {case.fixtures}",
|
|
159
|
+
hint="`fixtures:` is a folder, relative to evals.yaml, copied into each sandbox.",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
_validate_runner_options(config, source)
|
|
163
|
+
return config
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _validate_runner_options(config: EvalConfig, source: Path | str) -> None:
|
|
167
|
+
options_model = get_runner_class(config.runner).Options
|
|
168
|
+
try:
|
|
169
|
+
options_model.model_validate(config.runner_options)
|
|
170
|
+
except ValidationError as exc:
|
|
171
|
+
message = format_validation_error(exc, f"{source} (runner_options for {config.runner})")
|
|
172
|
+
raise ConfigError(
|
|
173
|
+
message, hint=f"Check the `runner_options` supported by the {config.runner} runner."
|
|
174
|
+
) from exc
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def load_config(path: Path) -> EvalConfig:
|
|
178
|
+
config_path = find_config(path).resolve()
|
|
179
|
+
try:
|
|
180
|
+
data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
|
181
|
+
except yaml.YAMLError as exc:
|
|
182
|
+
raise ConfigError(
|
|
183
|
+
f"{config_path} is not valid YAML: {exc}",
|
|
184
|
+
hint="Check indentation and quoting near the line mentioned above.",
|
|
185
|
+
) from exc
|
|
186
|
+
config = parse_config(data, config_path.parent, source=config_path.name)
|
|
187
|
+
config._path = config_path
|
|
188
|
+
return config
|
skillproof/engine.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Runs an eval suite: every case, N trials, with and without the skill.
|
|
2
|
+
|
|
3
|
+
Each run gets a fresh sandbox (fixtures copied in), the runner does its thing,
|
|
4
|
+
assertions are checked against the sandbox, and the transcript is saved under
|
|
5
|
+
`<out_dir>/runs/<timestamp>/` with secrets redacted.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from skillproof.assertions import CheckContext, check_all
|
|
20
|
+
from skillproof.config import Case, EvalConfig
|
|
21
|
+
from skillproof.errors import ConfigError, RunAborted, hint_for_error
|
|
22
|
+
from skillproof.judges import Judge, make_judge
|
|
23
|
+
from skillproof.judges.base import JudgeUnavailable
|
|
24
|
+
from skillproof.results import CaseResult, SuiteResult, TrialResult, Variant
|
|
25
|
+
from skillproof.runners import AgentRun, Runner, RunRequest, get_runner
|
|
26
|
+
from skillproof.sandbox import create_sandbox, redact, remove_sandbox
|
|
27
|
+
from skillproof.skill import Skill, load_skill
|
|
28
|
+
|
|
29
|
+
VARIANTS: tuple[Variant, ...] = ("with", "without")
|
|
30
|
+
# Stop early when this many runs have finished and every one of them errored.
|
|
31
|
+
FAIL_FAST_AFTER = 3
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class RunOptions:
|
|
36
|
+
parallel: int = 4
|
|
37
|
+
out_dir: Path = Path(".skillproof")
|
|
38
|
+
keep_sandboxes: bool = False
|
|
39
|
+
trials: int | None = None # overrides evals.yaml for every case
|
|
40
|
+
only_cases: list[str] | None = None # run just these case names
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Job:
|
|
45
|
+
case: Case
|
|
46
|
+
variant: Variant
|
|
47
|
+
trial: int
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def slugify(text: str) -> str:
|
|
51
|
+
return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")[:60] or "case"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def new_run_dir(out_dir: Path) -> Path:
|
|
55
|
+
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
56
|
+
run_dir = out_dir / "runs" / stamp
|
|
57
|
+
suffix = 1
|
|
58
|
+
while run_dir.exists():
|
|
59
|
+
suffix += 1
|
|
60
|
+
run_dir = out_dir / "runs" / f"{stamp}-{suffix}"
|
|
61
|
+
run_dir.mkdir(parents=True)
|
|
62
|
+
return run_dir
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def selected_cases(config: EvalConfig, only: list[str] | None) -> list[Case]:
|
|
66
|
+
if not only:
|
|
67
|
+
return list(config.cases)
|
|
68
|
+
names = {c.name for c in config.cases}
|
|
69
|
+
unknown = [n for n in only if n not in names]
|
|
70
|
+
if unknown:
|
|
71
|
+
raise ConfigError(
|
|
72
|
+
f"No case named {unknown[0]!r}",
|
|
73
|
+
hint="Available cases: " + ", ".join(repr(n) for n in names),
|
|
74
|
+
)
|
|
75
|
+
return [c for c in config.cases if c.name in only]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Engine:
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
config: EvalConfig,
|
|
82
|
+
options: RunOptions | None = None,
|
|
83
|
+
runner: Runner | None = None,
|
|
84
|
+
judge: Judge | None = None,
|
|
85
|
+
on_trial: Callable[[TrialResult], None] | None = None,
|
|
86
|
+
) -> None:
|
|
87
|
+
self.config = config
|
|
88
|
+
self.options = options or RunOptions()
|
|
89
|
+
self.runner = runner or get_runner(config.runner, config.runner_options)
|
|
90
|
+
self.judge = judge
|
|
91
|
+
self.on_trial = on_trial
|
|
92
|
+
self.cases = selected_cases(config, self.options.only_cases)
|
|
93
|
+
|
|
94
|
+
def jobs(self) -> list[Job]:
|
|
95
|
+
"""Trials are interleaved (with/without, case by case) so drift over time
|
|
96
|
+
in API latency or model behaviour hits both variants equally."""
|
|
97
|
+
jobs = []
|
|
98
|
+
max_trials = max(self.trials_for(c) for c in self.cases)
|
|
99
|
+
for trial in range(max_trials):
|
|
100
|
+
for case in self.cases:
|
|
101
|
+
if trial < self.trials_for(case):
|
|
102
|
+
jobs.extend(Job(case, variant, trial) for variant in VARIANTS)
|
|
103
|
+
return jobs
|
|
104
|
+
|
|
105
|
+
def trials_for(self, case: Case) -> int:
|
|
106
|
+
return self.options.trials or self.config.trials_for(case)
|
|
107
|
+
|
|
108
|
+
def skill_exclude(self, skill: Skill) -> list[Path]:
|
|
109
|
+
"""Eval files inside the skill folder that the agent must never see."""
|
|
110
|
+
paths = [c.fixtures for c in self.config.cases if c.fixtures is not None]
|
|
111
|
+
if self.config.path is not None:
|
|
112
|
+
paths.append(self.config.path)
|
|
113
|
+
return [p for p in paths if p.is_relative_to(skill.path)]
|
|
114
|
+
|
|
115
|
+
async def run(self) -> SuiteResult:
|
|
116
|
+
skill = load_skill(self.config.skill)
|
|
117
|
+
self.runner.check_ready()
|
|
118
|
+
if self.judge is None and self.config.uses_judge():
|
|
119
|
+
self.judge = make_judge(self.config.judge, self.runner.vendor)
|
|
120
|
+
|
|
121
|
+
run_dir = new_run_dir(self.options.out_dir)
|
|
122
|
+
suite = SuiteResult(
|
|
123
|
+
skill_name=skill.name,
|
|
124
|
+
skill_path=str(skill.path),
|
|
125
|
+
runner=self.runner.name,
|
|
126
|
+
approximate=self.runner.approximate,
|
|
127
|
+
model=self.config.model,
|
|
128
|
+
judge=self.judge.label if self.judge else None,
|
|
129
|
+
started_at=datetime.now(timezone.utc),
|
|
130
|
+
run_dir=str(run_dir),
|
|
131
|
+
)
|
|
132
|
+
exclude = self.skill_exclude(skill)
|
|
133
|
+
semaphore = asyncio.Semaphore(max(1, self.options.parallel))
|
|
134
|
+
|
|
135
|
+
trials: list[TrialResult] = []
|
|
136
|
+
stopped = False
|
|
137
|
+
|
|
138
|
+
async def limited(job: Job) -> None:
|
|
139
|
+
nonlocal stopped
|
|
140
|
+
async with semaphore:
|
|
141
|
+
if stopped: # don't start new runs once we've decided to stop
|
|
142
|
+
return
|
|
143
|
+
trials.append(await self.run_trial(job, skill, exclude, run_dir))
|
|
144
|
+
try:
|
|
145
|
+
self.stop_if_everything_fails(trials, run_dir)
|
|
146
|
+
except RunAborted:
|
|
147
|
+
stopped = True
|
|
148
|
+
raise
|
|
149
|
+
|
|
150
|
+
tasks = [asyncio.create_task(limited(job)) for job in self.jobs()]
|
|
151
|
+
try:
|
|
152
|
+
await asyncio.gather(*tasks)
|
|
153
|
+
except BaseException: # fail-fast, Ctrl-C (cancellation) or a crash
|
|
154
|
+
for task in tasks:
|
|
155
|
+
task.cancel()
|
|
156
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
157
|
+
self.finish(suite, trials, complete=False)
|
|
158
|
+
raise
|
|
159
|
+
self.finish(suite, trials, complete=True)
|
|
160
|
+
return suite
|
|
161
|
+
|
|
162
|
+
def stop_if_everything_fails(self, trials: list[TrialResult], run_dir: Path) -> None:
|
|
163
|
+
"""If the first few runs all errored, the rest will too (bad key, wrong model...).
|
|
164
|
+
Stop instead of burning time and money, and say what to fix."""
|
|
165
|
+
if len(trials) != FAIL_FAST_AFTER or not all(t.errored for t in trials):
|
|
166
|
+
return
|
|
167
|
+
error = trials[-1].run.error or "unknown error"
|
|
168
|
+
raise RunAborted(
|
|
169
|
+
f"The first {FAIL_FAST_AFTER} runs all failed, so skillproof stopped early.\n"
|
|
170
|
+
f"Last error: {error.strip()[:500]}",
|
|
171
|
+
hint=f"{hint_for_error(error)} Transcripts: {run_dir}",
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def finish(self, suite: SuiteResult, trials: list[TrialResult], complete: bool) -> None:
|
|
175
|
+
"""Group trials into cases and save results.json (also for partial runs)."""
|
|
176
|
+
suite.cases = []
|
|
177
|
+
for case in self.cases:
|
|
178
|
+
case_trials = sorted(
|
|
179
|
+
(t for t in trials if t.case == case.name), key=lambda t: (t.variant, t.trial)
|
|
180
|
+
)
|
|
181
|
+
suite.cases.append(
|
|
182
|
+
CaseResult(
|
|
183
|
+
name=case.name,
|
|
184
|
+
should_trigger=case.should_trigger,
|
|
185
|
+
has_assertions=bool(case.assertions),
|
|
186
|
+
trials=case_trials,
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
suite.complete = complete
|
|
190
|
+
suite.finished_at = datetime.now(timezone.utc)
|
|
191
|
+
(Path(suite.run_dir) / "results.json").write_text(
|
|
192
|
+
redact(suite.model_dump_json(indent=2)), encoding="utf-8"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
async def run_trial(
|
|
196
|
+
self, job: Job, skill: Skill, exclude: list[Path], run_dir: Path
|
|
197
|
+
) -> TrialResult:
|
|
198
|
+
case = job.case
|
|
199
|
+
timeout = self.config.timeout_for(case)
|
|
200
|
+
workdir = create_sandbox(case.fixtures)
|
|
201
|
+
try:
|
|
202
|
+
request = RunRequest(
|
|
203
|
+
prompt=case.prompt,
|
|
204
|
+
workdir=workdir,
|
|
205
|
+
skill=skill if job.variant == "with" else None,
|
|
206
|
+
skill_exclude=exclude,
|
|
207
|
+
model=self.config.model,
|
|
208
|
+
timeout_s=timeout,
|
|
209
|
+
case_name=case.name,
|
|
210
|
+
trial=job.trial,
|
|
211
|
+
)
|
|
212
|
+
agent_run = await self.call_runner(request, timeout)
|
|
213
|
+
assertions = []
|
|
214
|
+
if agent_run.error is None: # don't grade (or pay a judge for) a broken run
|
|
215
|
+
ctx = CheckContext(run=agent_run, workdir=workdir, judge=self.judge)
|
|
216
|
+
try:
|
|
217
|
+
assertions = await check_all(case.assertions, ctx)
|
|
218
|
+
except JudgeUnavailable as exc:
|
|
219
|
+
agent_run.error = f"llm_judge failed: {exc}"
|
|
220
|
+
result = TrialResult(
|
|
221
|
+
case=case.name,
|
|
222
|
+
variant=job.variant,
|
|
223
|
+
trial=job.trial,
|
|
224
|
+
run=agent_run,
|
|
225
|
+
assertions=assertions,
|
|
226
|
+
)
|
|
227
|
+
self.save_trial(result, workdir, run_dir)
|
|
228
|
+
finally:
|
|
229
|
+
remove_sandbox(workdir)
|
|
230
|
+
if self.on_trial is not None:
|
|
231
|
+
self.on_trial(result)
|
|
232
|
+
return result
|
|
233
|
+
|
|
234
|
+
async def call_runner(self, request: RunRequest, timeout: float) -> AgentRun:
|
|
235
|
+
"""Call the runner, enforcing the timeout and turning crashes into errors."""
|
|
236
|
+
start = time.monotonic()
|
|
237
|
+
try:
|
|
238
|
+
agent_run = await asyncio.wait_for(self.runner.run(request), timeout=timeout)
|
|
239
|
+
except asyncio.TimeoutError:
|
|
240
|
+
agent_run = AgentRun(timed_out=True)
|
|
241
|
+
except Exception as exc:
|
|
242
|
+
agent_run = AgentRun(error=f"{type(exc).__name__}: {exc}")
|
|
243
|
+
if not agent_run.duration_s:
|
|
244
|
+
agent_run.duration_s = time.monotonic() - start
|
|
245
|
+
return agent_run
|
|
246
|
+
|
|
247
|
+
def save_trial(self, result: TrialResult, workdir: Path, run_dir: Path) -> None:
|
|
248
|
+
case_dir = run_dir / slugify(result.case)
|
|
249
|
+
case_dir.mkdir(parents=True, exist_ok=True)
|
|
250
|
+
name = f"{result.variant}-{result.trial + 1}"
|
|
251
|
+
result.artifact = f"{case_dir.name}/{name}.json"
|
|
252
|
+
(case_dir / f"{name}.json").write_text(
|
|
253
|
+
redact(result.model_dump_json(indent=2)), encoding="utf-8"
|
|
254
|
+
)
|
|
255
|
+
if self.options.keep_sandboxes:
|
|
256
|
+
shutil.copytree(workdir, case_dir / f"{name}.workdir", symlinks=True)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def run_suite(
|
|
260
|
+
config: EvalConfig,
|
|
261
|
+
options: RunOptions | None = None,
|
|
262
|
+
runner: Runner | None = None,
|
|
263
|
+
judge: Judge | None = None,
|
|
264
|
+
on_trial: Callable[[TrialResult], None] | None = None,
|
|
265
|
+
) -> SuiteResult:
|
|
266
|
+
"""Synchronous entry point used by the CLI and tests."""
|
|
267
|
+
engine = Engine(config, options, runner=runner, judge=judge, on_trial=on_trial)
|
|
268
|
+
return asyncio.run(engine.run())
|
skillproof/errors.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Errors that are shown to the user.
|
|
2
|
+
|
|
3
|
+
Every error carries a `hint`: one sentence telling the user exactly what to do
|
|
4
|
+
next. The CLI prints the message and the hint and exits non-zero, without a
|
|
5
|
+
traceback.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SkillproofError(Exception):
|
|
12
|
+
def __init__(self, message: str, hint: str | None = None) -> None:
|
|
13
|
+
super().__init__(message)
|
|
14
|
+
self.message = message
|
|
15
|
+
self.hint = hint
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ConfigError(SkillproofError):
|
|
19
|
+
"""evals.yaml is missing, malformed, or points at things that don't exist."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SkillError(SkillproofError):
|
|
23
|
+
"""The skill folder or its SKILL.md is missing or malformed."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RunnerNotReady(SkillproofError):
|
|
27
|
+
"""A runner can't start: missing CLI, missing package, missing API key..."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class JudgeNotReady(SkillproofError):
|
|
31
|
+
"""An llm_judge assertion is used but no judge can be built."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RunAborted(SkillproofError):
|
|
35
|
+
"""The run stopped early: the first runs all failed, or the user hit Ctrl-C.
|
|
36
|
+
Partial results are saved before this is raised."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# (pattern, what to do) for errors agents and APIs commonly report.
|
|
40
|
+
ERROR_HINTS = [
|
|
41
|
+
(
|
|
42
|
+
r"401|unauthori[sz]ed|authenticat|invalid.{0,20}api.?key|x-api-key|not logged in|"
|
|
43
|
+
r"credentials",
|
|
44
|
+
"Your API key or login is missing or invalid. Check ANTHROPIC_API_KEY, OPENAI_API_KEY "
|
|
45
|
+
"or CODEX_API_KEY (or log in with `claude` / `codex login`).",
|
|
46
|
+
),
|
|
47
|
+
(
|
|
48
|
+
r"credit|billing|quota|insufficient",
|
|
49
|
+
"Your API account is out of credits or quota. Check billing for the provider.",
|
|
50
|
+
),
|
|
51
|
+
(
|
|
52
|
+
r"429|rate.?limit|overloaded|529|too many requests",
|
|
53
|
+
"The API is rate-limiting or overloaded. Lower --parallel (e.g. -p 1) and try again.",
|
|
54
|
+
),
|
|
55
|
+
(
|
|
56
|
+
r"model.{0,40}(not.?found|does not exist|invalid|unknown|not supported)|404",
|
|
57
|
+
"The model name looks wrong. Check `model:` in evals.yaml.",
|
|
58
|
+
),
|
|
59
|
+
(
|
|
60
|
+
r"enotfound|connection|network|name resolution|getaddrinfo|ssl",
|
|
61
|
+
"Network problem reaching the API. Check your connection, proxy and base_url.",
|
|
62
|
+
),
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def hint_for_error(text: str) -> str:
|
|
67
|
+
import re
|
|
68
|
+
|
|
69
|
+
for pattern, hint in ERROR_HINTS:
|
|
70
|
+
if re.search(pattern, text, re.IGNORECASE):
|
|
71
|
+
return hint
|
|
72
|
+
return "Open the saved transcripts for details."
|
skillproof/estimate.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Rough cost estimate before a run starts, so nobody burns $50 by accident.
|
|
2
|
+
|
|
3
|
+
Per-run cost comes from your last run of the same skill with the same runner
|
|
4
|
+
when there is one (agents like Claude Code report cost per run). Otherwise a
|
|
5
|
+
deliberately rough default per runner is used; tasks vary 10x in cost, so the
|
|
6
|
+
first estimate is a ballpark, and later ones learn from your real runs.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from skillproof.engine import Engine
|
|
15
|
+
from skillproof.results import RESULTS_FILE, SuiteResult
|
|
16
|
+
|
|
17
|
+
# USD per agent run when there's no history: ballpark for a small coding task
|
|
18
|
+
# (our example skills averaged ~$0.05 per Claude Code run on claude-opus-5-5).
|
|
19
|
+
DEFAULT_COST_PER_RUN = {
|
|
20
|
+
"claude-code": 0.10,
|
|
21
|
+
"codex": 0.15,
|
|
22
|
+
"openai-compatible": 0.02,
|
|
23
|
+
"mock": 0.0,
|
|
24
|
+
}
|
|
25
|
+
# Ask before spending more than this (override with --confirm-above).
|
|
26
|
+
CONFIRM_ABOVE_USD = 5.0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Estimate:
|
|
31
|
+
runs: int
|
|
32
|
+
judge_calls: int
|
|
33
|
+
cost_per_run: float | None # None: unknown runner and no history
|
|
34
|
+
source: str # where cost_per_run came from, shown to the user
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def total_usd(self) -> float | None:
|
|
38
|
+
return None if self.cost_per_run is None else self.runs * self.cost_per_run
|
|
39
|
+
|
|
40
|
+
def describe(self) -> str:
|
|
41
|
+
text = f"{self.runs} agent runs"
|
|
42
|
+
if self.judge_calls:
|
|
43
|
+
text += f" + {self.judge_calls} judge calls"
|
|
44
|
+
if self.total_usd is None:
|
|
45
|
+
return text + " · cost unknown"
|
|
46
|
+
if self.total_usd == 0:
|
|
47
|
+
return text + " · no API cost"
|
|
48
|
+
return (
|
|
49
|
+
f"{text} · estimated ~${self.total_usd:.2f} "
|
|
50
|
+
f"(${self.cost_per_run:.3f}/run, {self.source})"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def past_cost_per_run(out_dir: Path, skill_path: Path, runner: str) -> float | None:
|
|
55
|
+
"""Mean reported cost per run in the newest saved run of this skill and runner."""
|
|
56
|
+
for results in sorted((out_dir / "runs").glob(f"*/{RESULTS_FILE}"), reverse=True):
|
|
57
|
+
try:
|
|
58
|
+
suite = SuiteResult.model_validate_json(results.read_text(encoding="utf-8"))
|
|
59
|
+
except ValueError:
|
|
60
|
+
continue # unreadable or from an incompatible version
|
|
61
|
+
if Path(suite.skill_path) != skill_path or suite.runner != runner:
|
|
62
|
+
continue
|
|
63
|
+
costs = [
|
|
64
|
+
t.run.usage.cost_usd
|
|
65
|
+
for c in suite.cases
|
|
66
|
+
for t in c.trials
|
|
67
|
+
if t.run.usage.cost_usd is not None
|
|
68
|
+
]
|
|
69
|
+
if costs:
|
|
70
|
+
return sum(costs) / len(costs)
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def estimate(engine: Engine, out_dir: Path) -> Estimate:
|
|
75
|
+
jobs = engine.jobs()
|
|
76
|
+
judge_calls = sum(sum(a.type == "llm_judge" for a in job.case.assertions) for job in jobs)
|
|
77
|
+
runner = engine.runner.name
|
|
78
|
+
past = past_cost_per_run(out_dir, engine.config.skill, runner)
|
|
79
|
+
if past is not None:
|
|
80
|
+
return Estimate(len(jobs), judge_calls, past, "from your last run")
|
|
81
|
+
default = DEFAULT_COST_PER_RUN.get(runner)
|
|
82
|
+
source = f"rough default for {runner}" if default else "no estimate for this runner"
|
|
83
|
+
return Estimate(len(jobs), judge_calls, default, source)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Building the judge for `llm_judge` assertions.
|
|
2
|
+
|
|
3
|
+
By default the judge comes from a different vendor than the agent under test,
|
|
4
|
+
so a model never grades its own family's homework.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from skillproof.errors import JudgeNotReady
|
|
12
|
+
from skillproof.judges.base import Judge, Verdict
|
|
13
|
+
from skillproof.judges.mock import MockJudge
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from skillproof.config import JudgeConfig
|
|
17
|
+
|
|
18
|
+
# agent vendor -> judge provider used when `judge.provider: auto`
|
|
19
|
+
CROSS_VENDOR = {"anthropic": "openai", "openai": "anthropic"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def resolve_provider(config: JudgeConfig, agent_vendor: str) -> str:
|
|
23
|
+
if config.provider != "auto":
|
|
24
|
+
return config.provider
|
|
25
|
+
return CROSS_VENDOR.get(agent_vendor, "anthropic")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def make_judge(config: JudgeConfig, agent_vendor: str) -> Judge:
|
|
29
|
+
provider = resolve_provider(config, agent_vendor)
|
|
30
|
+
if provider == "mock":
|
|
31
|
+
return MockJudge()
|
|
32
|
+
if provider == "anthropic":
|
|
33
|
+
from skillproof.judges.anthropic_judge import AnthropicJudge
|
|
34
|
+
|
|
35
|
+
return AnthropicJudge(config.model, config.api_key_env, config.base_url)
|
|
36
|
+
if provider == "openai":
|
|
37
|
+
from skillproof.judges.openai_judge import OpenAIJudge
|
|
38
|
+
|
|
39
|
+
return OpenAIJudge(config.model, config.api_key_env, config.base_url)
|
|
40
|
+
raise JudgeNotReady(f"Unknown judge provider {provider!r}.", hint="Use anthropic or openai.")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
__all__ = ["Judge", "MockJudge", "Verdict", "make_judge", "resolve_provider"]
|