agent-skill-eval 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_skill_eval-0.2.1.dist-info/METADATA +335 -0
- agent_skill_eval-0.2.1.dist-info/RECORD +15 -0
- agent_skill_eval-0.2.1.dist-info/WHEEL +4 -0
- agent_skill_eval-0.2.1.dist-info/entry_points.txt +2 -0
- agent_skill_eval-0.2.1.dist-info/licenses/LICENSE +21 -0
- skill_eval/__init__.py +8 -0
- skill_eval/__main__.py +3 -0
- skill_eval/cli.py +844 -0
- skill_eval/git_state.py +227 -0
- skill_eval/graders/__init__.py +810 -0
- skill_eval/harnesses/__init__.py +337 -0
- skill_eval/models.py +146 -0
- skill_eval/runner.py +424 -0
- skill_eval/skills.py +88 -0
- skill_eval/workspace.py +101 -0
skill_eval/runner.py
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import statistics
|
|
6
|
+
import subprocess
|
|
7
|
+
import uuid
|
|
8
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn
|
|
14
|
+
|
|
15
|
+
from skill_eval.git_state import capture_git_state, github_repo_slug
|
|
16
|
+
from skill_eval.graders import LLMGrader, grade_assertions
|
|
17
|
+
from skill_eval.harnesses import get_harness
|
|
18
|
+
from skill_eval.models import (
|
|
19
|
+
AgentType,
|
|
20
|
+
BenchmarkResult,
|
|
21
|
+
BenchmarkStats,
|
|
22
|
+
CleanupManifest,
|
|
23
|
+
DeltaStats,
|
|
24
|
+
EvalCase,
|
|
25
|
+
EvalSuite,
|
|
26
|
+
RunMeta,
|
|
27
|
+
StatsPair,
|
|
28
|
+
)
|
|
29
|
+
from skill_eval.skills import SkillInstaller
|
|
30
|
+
from skill_eval.workspace import WorkspaceManager
|
|
31
|
+
|
|
32
|
+
console = Console()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class EvalRunner:
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
skill_path: Path,
|
|
39
|
+
evals_path: Path,
|
|
40
|
+
workspace_base: Path,
|
|
41
|
+
agents: list[AgentType],
|
|
42
|
+
concurrency: int = 1,
|
|
43
|
+
with_baseline: bool = True,
|
|
44
|
+
grader_model: str = "deepseek/deepseek-v4-flash",
|
|
45
|
+
grader_base_url: Optional[str] = None,
|
|
46
|
+
agent_models: Optional[dict[AgentType, str]] = None,
|
|
47
|
+
source_repo: Optional[str] = None,
|
|
48
|
+
harness_base_url: Optional[str] = None,
|
|
49
|
+
agent_timeout: Optional[int] = None,
|
|
50
|
+
agent_max_retries: Optional[int] = None,
|
|
51
|
+
runs: int = 1,
|
|
52
|
+
):
|
|
53
|
+
self.skill_path = skill_path
|
|
54
|
+
self.evals_path = evals_path
|
|
55
|
+
self.workspace_base = workspace_base
|
|
56
|
+
self.agents = agents
|
|
57
|
+
self.concurrency = concurrency
|
|
58
|
+
self.with_baseline = with_baseline
|
|
59
|
+
self.grader_model = grader_model
|
|
60
|
+
self.grader_base_url = grader_base_url
|
|
61
|
+
self.agent_models = agent_models or {}
|
|
62
|
+
self.source_repo = source_repo
|
|
63
|
+
self.harness_base_url = harness_base_url
|
|
64
|
+
self.agent_timeout = agent_timeout
|
|
65
|
+
self.agent_max_retries = agent_max_retries
|
|
66
|
+
self.runs = max(1, runs)
|
|
67
|
+
|
|
68
|
+
self.run_id = uuid.uuid4().hex[:8]
|
|
69
|
+
self.suite = self._load_suite()
|
|
70
|
+
self.skill_installer = SkillInstaller(skill_path)
|
|
71
|
+
self.workspace_mgr = WorkspaceManager(workspace_base, source_repo=source_repo)
|
|
72
|
+
self.llm_grader = LLMGrader(model=grader_model, base_url=grader_base_url) if grader_model else None
|
|
73
|
+
|
|
74
|
+
def _load_suite(self) -> EvalSuite:
|
|
75
|
+
with open(self.evals_path, encoding="utf-8") as f:
|
|
76
|
+
data = json.load(f)
|
|
77
|
+
return EvalSuite(**data)
|
|
78
|
+
|
|
79
|
+
def run(self, iteration: int = 1) -> Path:
|
|
80
|
+
iteration_dir = self.workspace_base / f"{self.suite.skill_name}-workspace" / f"iteration-{iteration}"
|
|
81
|
+
iteration_dir.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
|
|
83
|
+
self._save_eval_metadata(iteration_dir)
|
|
84
|
+
|
|
85
|
+
tasks = []
|
|
86
|
+
for eval_case in self.suite.evals:
|
|
87
|
+
for agent_type in self.agents:
|
|
88
|
+
for run_index in range(1, self.runs + 1):
|
|
89
|
+
tasks.append((eval_case, agent_type, True, run_index))
|
|
90
|
+
if self.with_baseline:
|
|
91
|
+
tasks.append((eval_case, agent_type, False, run_index))
|
|
92
|
+
|
|
93
|
+
results: dict[str, dict] = {}
|
|
94
|
+
cleanup_entries: list[CleanupManifest] = []
|
|
95
|
+
|
|
96
|
+
with Progress(
|
|
97
|
+
SpinnerColumn(),
|
|
98
|
+
TextColumn("[progress.description]{task.description}"),
|
|
99
|
+
BarColumn(),
|
|
100
|
+
TaskProgressColumn(),
|
|
101
|
+
console=console,
|
|
102
|
+
) as progress:
|
|
103
|
+
task_id = progress.add_task("Running evals...", total=len(tasks))
|
|
104
|
+
|
|
105
|
+
with ThreadPoolExecutor(max_workers=self.concurrency) as executor:
|
|
106
|
+
futures = {}
|
|
107
|
+
for eval_case, agent_type, with_skill, run_index in tasks:
|
|
108
|
+
future = executor.submit(
|
|
109
|
+
self._run_single,
|
|
110
|
+
eval_case,
|
|
111
|
+
agent_type,
|
|
112
|
+
with_skill,
|
|
113
|
+
iteration_dir,
|
|
114
|
+
iteration,
|
|
115
|
+
run_index,
|
|
116
|
+
)
|
|
117
|
+
futures[future] = (eval_case, agent_type, with_skill, run_index)
|
|
118
|
+
|
|
119
|
+
for future in as_completed(futures):
|
|
120
|
+
eval_case, agent_type, with_skill, run_index = futures[future]
|
|
121
|
+
key = f"{eval_case.id}-{agent_type.value}-{'with' if with_skill else 'without'}-r{run_index}"
|
|
122
|
+
try:
|
|
123
|
+
result = future.result()
|
|
124
|
+
results[key] = result["summary"]
|
|
125
|
+
if result.get("cleanup_entry"):
|
|
126
|
+
cleanup_entries.append(result["cleanup_entry"])
|
|
127
|
+
except Exception as e:
|
|
128
|
+
console.print(f"[red]Error running {key}: {e}[/red]")
|
|
129
|
+
results[key] = {"error": str(e)}
|
|
130
|
+
progress.advance(task_id)
|
|
131
|
+
|
|
132
|
+
benchmark = self._compute_benchmark(results, iteration_dir)
|
|
133
|
+
|
|
134
|
+
if self.with_baseline:
|
|
135
|
+
for agent_type in self.agents:
|
|
136
|
+
if agent_type.value not in benchmark.deltas:
|
|
137
|
+
console.print(
|
|
138
|
+
f"[yellow]Warning: no with/without-skill delta for {agent_type.value} — "
|
|
139
|
+
f"all of its runs in one config errored[/yellow]"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
with open(iteration_dir / "benchmark.json", "w", encoding="utf-8") as f:
|
|
143
|
+
json.dump(benchmark.model_dump(), f, indent=2)
|
|
144
|
+
|
|
145
|
+
manifest = self._build_cleanup_manifest(cleanup_entries, iteration_dir)
|
|
146
|
+
with open(iteration_dir / "cleanup.json", "w", encoding="utf-8") as f:
|
|
147
|
+
json.dump(manifest.model_dump(exclude_none=True), f, indent=2)
|
|
148
|
+
|
|
149
|
+
console.print(f"\n[green]Results saved to {iteration_dir}[/green]")
|
|
150
|
+
return iteration_dir
|
|
151
|
+
|
|
152
|
+
def _save_eval_metadata(self, iteration_dir: Path) -> None:
|
|
153
|
+
meta = {
|
|
154
|
+
"skill_name": self.suite.skill_name,
|
|
155
|
+
"evals": [eval_case.model_dump() for eval_case in self.suite.evals],
|
|
156
|
+
"source_repo": self.source_repo,
|
|
157
|
+
}
|
|
158
|
+
with open(iteration_dir / "evals_meta.json", "w", encoding="utf-8") as f:
|
|
159
|
+
json.dump(meta, f, indent=2)
|
|
160
|
+
|
|
161
|
+
def _build_cleanup_manifest(self, entries: list[CleanupManifest], iteration_dir: Path) -> CleanupManifest:
|
|
162
|
+
all_branches: list[str] = []
|
|
163
|
+
all_prs: list[int] = []
|
|
164
|
+
for entry in entries:
|
|
165
|
+
for b in entry.remote_branches:
|
|
166
|
+
if b not in all_branches:
|
|
167
|
+
all_branches.append(b)
|
|
168
|
+
for p in entry.pr_numbers:
|
|
169
|
+
if p not in all_prs:
|
|
170
|
+
all_prs.append(p)
|
|
171
|
+
|
|
172
|
+
return CleanupManifest(
|
|
173
|
+
source_repo=self.source_repo,
|
|
174
|
+
source_repo_slug=github_repo_slug(self.source_repo),
|
|
175
|
+
remote_branches=all_branches,
|
|
176
|
+
pr_numbers=all_prs,
|
|
177
|
+
workspaces=[str(p) for p in self.workspace_mgr.workspaces],
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def _run_single(
|
|
181
|
+
self,
|
|
182
|
+
eval_case: EvalCase,
|
|
183
|
+
agent_type: AgentType,
|
|
184
|
+
with_skill: bool,
|
|
185
|
+
iteration_dir: Path,
|
|
186
|
+
iteration: int = 1,
|
|
187
|
+
run_index: int = 1,
|
|
188
|
+
) -> dict:
|
|
189
|
+
eval_slug = f"eval-{eval_case.id}"
|
|
190
|
+
config_slug = "with_skill" if with_skill else "without_skill"
|
|
191
|
+
agent_slug = agent_type.value
|
|
192
|
+
config_dir = iteration_dir / eval_slug / agent_slug / config_slug
|
|
193
|
+
if self.runs > 1:
|
|
194
|
+
config_dir = config_dir / f"run-{run_index}"
|
|
195
|
+
output_dir = config_dir / "outputs"
|
|
196
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
197
|
+
|
|
198
|
+
fixture_files = {}
|
|
199
|
+
for file_path in eval_case.files:
|
|
200
|
+
src = self.evals_path.parent / file_path
|
|
201
|
+
if src.exists():
|
|
202
|
+
fixture_files[Path(file_path).name] = src
|
|
203
|
+
|
|
204
|
+
workspace_name = f"{eval_case.id}-{agent_type.value}-{'ws' if with_skill else 'bs'}"
|
|
205
|
+
if self.runs > 1:
|
|
206
|
+
workspace_name += f"-r{run_index}"
|
|
207
|
+
workspace = self.workspace_mgr.create_workspace(workspace_name, fixture_files)
|
|
208
|
+
|
|
209
|
+
if with_skill:
|
|
210
|
+
self.skill_installer.install(workspace, agent_type)
|
|
211
|
+
|
|
212
|
+
if eval_case.stage_files and fixture_files:
|
|
213
|
+
for name in fixture_files:
|
|
214
|
+
subprocess.run(
|
|
215
|
+
["git", "add", "--", name],
|
|
216
|
+
cwd=workspace,
|
|
217
|
+
capture_output=True,
|
|
218
|
+
check=False,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
model = self.agent_models.get(agent_type)
|
|
222
|
+
harness = get_harness(
|
|
223
|
+
agent_type,
|
|
224
|
+
workspace,
|
|
225
|
+
model,
|
|
226
|
+
base_url=self.harness_base_url,
|
|
227
|
+
timeout=self.agent_timeout,
|
|
228
|
+
max_retries=self.agent_max_retries,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
prompt = self._build_prompt(eval_case, with_skill)
|
|
232
|
+
|
|
233
|
+
pre_state = capture_git_state(workspace, source_repo=self.source_repo)
|
|
234
|
+
with open(output_dir / "pre_state.json", "w", encoding="utf-8") as f:
|
|
235
|
+
json.dump(pre_state.model_dump(), f, indent=2)
|
|
236
|
+
|
|
237
|
+
agent_output, timing, stdout, stderr = harness.run(prompt, output_dir)
|
|
238
|
+
|
|
239
|
+
post_state = capture_git_state(workspace, source_repo=self.source_repo)
|
|
240
|
+
with open(output_dir / "post_state.json", "w", encoding="utf-8") as f:
|
|
241
|
+
json.dump(post_state.model_dump(), f, indent=2)
|
|
242
|
+
|
|
243
|
+
with open(output_dir / "output.txt", "w", encoding="utf-8") as f:
|
|
244
|
+
f.write(agent_output)
|
|
245
|
+
|
|
246
|
+
timing_path = output_dir.parent / "timing.json"
|
|
247
|
+
with open(timing_path, "w", encoding="utf-8") as f:
|
|
248
|
+
json.dump(timing.model_dump(), f, indent=2)
|
|
249
|
+
|
|
250
|
+
run_meta = RunMeta(
|
|
251
|
+
eval_id=eval_case.id,
|
|
252
|
+
agent=agent_type.value,
|
|
253
|
+
with_skill=with_skill,
|
|
254
|
+
iteration=iteration,
|
|
255
|
+
skill_name=self.suite.skill_name,
|
|
256
|
+
source_repo=self.source_repo,
|
|
257
|
+
run_id=self.run_id,
|
|
258
|
+
run_index=run_index,
|
|
259
|
+
)
|
|
260
|
+
meta_path = output_dir.parent / "run_meta.json"
|
|
261
|
+
with open(meta_path, "w", encoding="utf-8") as f:
|
|
262
|
+
json.dump(run_meta.model_dump(), f, indent=2)
|
|
263
|
+
|
|
264
|
+
grading = grade_assertions(
|
|
265
|
+
eval_case.assertions,
|
|
266
|
+
agent_output,
|
|
267
|
+
output_dir,
|
|
268
|
+
eval_case.expected_output,
|
|
269
|
+
self.llm_grader,
|
|
270
|
+
workspace=workspace,
|
|
271
|
+
pre_state=pre_state,
|
|
272
|
+
post_state=post_state,
|
|
273
|
+
should_trigger=eval_case.should_trigger,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
grading_path = output_dir.parent / "grading.json"
|
|
277
|
+
with open(grading_path, "w", encoding="utf-8") as f:
|
|
278
|
+
json.dump(grading.model_dump(), f, indent=2)
|
|
279
|
+
|
|
280
|
+
cleanup_entry = self._build_cleanup_entry(pre_state, post_state)
|
|
281
|
+
|
|
282
|
+
if os.environ.get("SKILL_EVAL_KEEP_WORKSPACE"):
|
|
283
|
+
console.print(f" [dim]Workspace kept: {workspace}[/dim]")
|
|
284
|
+
else:
|
|
285
|
+
self.workspace_mgr.cleanup(workspace)
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
"summary": {
|
|
289
|
+
"eval_id": eval_case.id,
|
|
290
|
+
"agent": agent_type.value,
|
|
291
|
+
"with_skill": with_skill,
|
|
292
|
+
"run_index": run_index,
|
|
293
|
+
"timing": timing.model_dump(),
|
|
294
|
+
"grading": grading.model_dump(),
|
|
295
|
+
"agent_output": agent_output[:2000],
|
|
296
|
+
},
|
|
297
|
+
"cleanup_entry": cleanup_entry,
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
def _build_prompt(self, eval_case: EvalCase, with_skill: bool) -> str:
|
|
301
|
+
"""Build the prompt sent to the agent.
|
|
302
|
+
|
|
303
|
+
With-skill mode means the skill is installed and available, NOT that
|
|
304
|
+
the prompt is rewritten to invoke it. Eval prompts decide for
|
|
305
|
+
themselves whether to mention the skill, except for the explicit
|
|
306
|
+
``force_skill_invocation`` opt-in (used by tests that need to assert
|
|
307
|
+
the skill is reachable by name).
|
|
308
|
+
"""
|
|
309
|
+
if with_skill and eval_case.force_skill_invocation:
|
|
310
|
+
return f"Use the ${self.suite.skill_name} skill. {eval_case.prompt}"
|
|
311
|
+
return eval_case.prompt
|
|
312
|
+
|
|
313
|
+
def _build_cleanup_entry(
|
|
314
|
+
self,
|
|
315
|
+
pre_state,
|
|
316
|
+
post_state,
|
|
317
|
+
) -> CleanupManifest:
|
|
318
|
+
pre_remote = set(pre_state.remote_branches)
|
|
319
|
+
post_remote = set(post_state.remote_branches)
|
|
320
|
+
new_remote_branches = sorted(post_remote - pre_remote)
|
|
321
|
+
|
|
322
|
+
pre_pr_numbers = {p.get("number") for p in pre_state.open_prs}
|
|
323
|
+
new_pr_numbers = [p.get("number") for p in post_state.open_prs if p.get("number") not in pre_pr_numbers]
|
|
324
|
+
|
|
325
|
+
return CleanupManifest(
|
|
326
|
+
source_repo=self.source_repo,
|
|
327
|
+
remote_branches=new_remote_branches,
|
|
328
|
+
pr_numbers=[n for n in new_pr_numbers if n is not None],
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
def _compute_benchmark(self, results: dict[str, dict], iteration_dir: Path) -> BenchmarkResult:
|
|
332
|
+
return compute_benchmark(results, self.agents, self.with_baseline)
|
|
333
|
+
|
|
334
|
+
def _compute_stats(self, results: list[dict]) -> BenchmarkStats:
|
|
335
|
+
return compute_stats(results)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _is_full_pass(result: dict) -> bool:
|
|
339
|
+
summary = result["grading"]["summary"]
|
|
340
|
+
return summary.get("total", 1) > 0 and summary.get("pass_rate", 0.0) >= 1.0
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def compute_stats(results: list[dict]) -> BenchmarkStats:
|
|
344
|
+
if not results:
|
|
345
|
+
return BenchmarkStats(
|
|
346
|
+
pass_rate=StatsPair(mean=0.0, stddev=0.0),
|
|
347
|
+
time_seconds=StatsPair(mean=0.0, stddev=0.0),
|
|
348
|
+
tokens=StatsPair(mean=0.0, stddev=0.0),
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
pass_rates = [r["grading"]["summary"]["pass_rate"] for r in results]
|
|
352
|
+
times = [r["timing"]["duration_ms"] / 1000.0 for r in results]
|
|
353
|
+
tokens = [r["timing"]["total_tokens"] for r in results]
|
|
354
|
+
|
|
355
|
+
full_passes = [_is_full_pass(r) for r in results]
|
|
356
|
+
|
|
357
|
+
# pass@k: group runs by eval id; an eval counts as passed if ANY of its
|
|
358
|
+
# runs fully passed. With one run per eval this equals full_pass_rate.
|
|
359
|
+
by_eval: dict[str, list[bool]] = {}
|
|
360
|
+
for r, fp in zip(results, full_passes):
|
|
361
|
+
by_eval.setdefault(str(r.get("eval_id", "?")), []).append(fp)
|
|
362
|
+
k = max(len(v) for v in by_eval.values())
|
|
363
|
+
pass_at_k = sum(1 for v in by_eval.values() if any(v)) / len(by_eval)
|
|
364
|
+
|
|
365
|
+
return BenchmarkStats(
|
|
366
|
+
pass_rate=StatsPair(
|
|
367
|
+
mean=statistics.mean(pass_rates) if pass_rates else 0.0,
|
|
368
|
+
stddev=statistics.stdev(pass_rates) if len(pass_rates) > 1 else 0.0,
|
|
369
|
+
),
|
|
370
|
+
time_seconds=StatsPair(
|
|
371
|
+
mean=statistics.mean(times) if times else 0.0,
|
|
372
|
+
stddev=statistics.stdev(times) if len(times) > 1 else 0.0,
|
|
373
|
+
),
|
|
374
|
+
tokens=StatsPair(
|
|
375
|
+
mean=statistics.mean(tokens) if tokens else 0.0,
|
|
376
|
+
stddev=statistics.stdev(tokens) if len(tokens) > 1 else 0.0,
|
|
377
|
+
),
|
|
378
|
+
full_pass_rate=sum(full_passes) / len(full_passes),
|
|
379
|
+
pass_at_k=pass_at_k,
|
|
380
|
+
k=k,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def compute_benchmark(results: dict[str, dict], agents: list[AgentType], with_baseline: bool) -> BenchmarkResult:
|
|
385
|
+
"""Compute the benchmark summary from a results dict.
|
|
386
|
+
|
|
387
|
+
Module-level so it can be called from the ``grade`` command without a
|
|
388
|
+
full ``EvalRunner`` instance. Computes a with/without-skill delta for
|
|
389
|
+
EVERY agent (``deltas``); ``delta`` is kept for backward compatibility
|
|
390
|
+
and is only set when exactly one agent was run.
|
|
391
|
+
"""
|
|
392
|
+
run_summary = {}
|
|
393
|
+
deltas: dict[str, DeltaStats] = {}
|
|
394
|
+
|
|
395
|
+
for agent_type in agents:
|
|
396
|
+
with_results = [
|
|
397
|
+
r
|
|
398
|
+
for k, r in results.items()
|
|
399
|
+
if r.get("agent") == agent_type.value and r.get("with_skill") and "error" not in r
|
|
400
|
+
]
|
|
401
|
+
without_results = [
|
|
402
|
+
r
|
|
403
|
+
for k, r in results.items()
|
|
404
|
+
if r.get("agent") == agent_type.value and not r.get("with_skill") and "error" not in r
|
|
405
|
+
]
|
|
406
|
+
|
|
407
|
+
with_stats = compute_stats(with_results)
|
|
408
|
+
without_stats = compute_stats(without_results)
|
|
409
|
+
|
|
410
|
+
run_summary[f"{agent_type.value}_with_skill"] = with_stats
|
|
411
|
+
if with_baseline:
|
|
412
|
+
run_summary[f"{agent_type.value}_without_skill"] = without_stats
|
|
413
|
+
if with_results and without_results:
|
|
414
|
+
deltas[agent_type.value] = DeltaStats(
|
|
415
|
+
pass_rate=with_stats.pass_rate.mean - without_stats.pass_rate.mean,
|
|
416
|
+
time_seconds=with_stats.time_seconds.mean - without_stats.time_seconds.mean,
|
|
417
|
+
tokens=with_stats.tokens.mean - without_stats.tokens.mean,
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
delta = None
|
|
421
|
+
if len(agents) == 1 and agents[0].value in deltas:
|
|
422
|
+
delta = deltas[agents[0].value]
|
|
423
|
+
|
|
424
|
+
return BenchmarkResult(run_summary=run_summary, deltas=deltas, delta=delta)
|
skill_eval/skills.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
from skill_eval.models import AgentType
|
|
9
|
+
|
|
10
|
+
SKILL_PATHS = {
|
|
11
|
+
AgentType.OPENCODE: [".opencode/skills"],
|
|
12
|
+
AgentType.CLAUDE_CODE: [".claude/skills"],
|
|
13
|
+
AgentType.CODEX: [".codex/skills"],
|
|
14
|
+
AgentType.FAKE: [".fake/skills"],
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SkillInstaller:
|
|
19
|
+
def __init__(self, skill_path: Path):
|
|
20
|
+
self.skill_path = skill_path
|
|
21
|
+
self.skill_name = skill_path.name
|
|
22
|
+
self.skill_md = skill_path / "SKILL.md"
|
|
23
|
+
if not self.skill_md.exists():
|
|
24
|
+
raise FileNotFoundError(f"SKILL.md not found in {skill_path}")
|
|
25
|
+
|
|
26
|
+
def frontmatter_problems(self) -> list[str]:
|
|
27
|
+
"""Return human-readable problems with the SKILL.md frontmatter.
|
|
28
|
+
|
|
29
|
+
Empty list means the frontmatter is usable. Agents rely on ``name``
|
|
30
|
+
and ``description`` to decide when to trigger a skill, so missing
|
|
31
|
+
fields cause silent misbehavior rather than errors.
|
|
32
|
+
"""
|
|
33
|
+
text = self.skill_md.read_text()
|
|
34
|
+
if not text.startswith("---"):
|
|
35
|
+
return ["SKILL.md has no YAML frontmatter block (must start with '---')"]
|
|
36
|
+
parts = text.split("---", 2)
|
|
37
|
+
if len(parts) < 3:
|
|
38
|
+
return ["SKILL.md frontmatter is not closed with '---'"]
|
|
39
|
+
try:
|
|
40
|
+
data = yaml.safe_load(parts[1])
|
|
41
|
+
except yaml.YAMLError as e:
|
|
42
|
+
return [f"SKILL.md frontmatter is not valid YAML: {e}"]
|
|
43
|
+
if not isinstance(data, dict):
|
|
44
|
+
return ["SKILL.md frontmatter is not a YAML mapping"]
|
|
45
|
+
problems = []
|
|
46
|
+
for field in ("name", "description"):
|
|
47
|
+
if not data.get(field):
|
|
48
|
+
problems.append(f"SKILL.md frontmatter is missing '{field}'")
|
|
49
|
+
if data.get("name") and data["name"] != self.skill_name:
|
|
50
|
+
problems.append(
|
|
51
|
+
f"SKILL.md frontmatter name '{data['name']}' does not match skill directory name '{self.skill_name}'"
|
|
52
|
+
)
|
|
53
|
+
return problems
|
|
54
|
+
|
|
55
|
+
def install(self, workspace: Path, agent_type: AgentType) -> Path:
|
|
56
|
+
target_dirs = SKILL_PATHS[agent_type]
|
|
57
|
+
installed_to = None
|
|
58
|
+
|
|
59
|
+
for target_dir in target_dirs:
|
|
60
|
+
dest_base = workspace / target_dir / self.skill_name
|
|
61
|
+
dest_base.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
|
|
63
|
+
shutil.copy2(self.skill_md, dest_base / "SKILL.md")
|
|
64
|
+
|
|
65
|
+
for item in self.skill_path.iterdir():
|
|
66
|
+
if item.name == "SKILL.md":
|
|
67
|
+
continue
|
|
68
|
+
if item.is_file():
|
|
69
|
+
shutil.copy2(item, dest_base / item.name)
|
|
70
|
+
elif item.is_dir() and item.name not in ("evals", "__pycache__", ".git"):
|
|
71
|
+
shutil.copytree(item, dest_base / item.name, dirs_exist_ok=True)
|
|
72
|
+
|
|
73
|
+
installed_to = dest_base
|
|
74
|
+
|
|
75
|
+
if installed_to is None:
|
|
76
|
+
raise ValueError(f"No skill paths configured for {agent_type}")
|
|
77
|
+
|
|
78
|
+
return installed_to
|
|
79
|
+
|
|
80
|
+
def uninstall(self, workspace: Path, agent_type: AgentType) -> None:
|
|
81
|
+
for target_dir in SKILL_PATHS[agent_type]:
|
|
82
|
+
dest_base = workspace / target_dir / self.skill_name
|
|
83
|
+
if dest_base.exists():
|
|
84
|
+
shutil.rmtree(dest_base)
|
|
85
|
+
|
|
86
|
+
parent = dest_base.parent
|
|
87
|
+
if parent.exists() and not any(parent.iterdir()):
|
|
88
|
+
parent.rmdir()
|
skill_eval/workspace.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class WorkspaceManager:
|
|
14
|
+
def __init__(self, base_dir: Path | None = None, source_repo: str | None = None):
|
|
15
|
+
self.base_dir = base_dir or Path(tempfile.gettempdir())
|
|
16
|
+
self.source_repo = source_repo
|
|
17
|
+
self.workspaces: list[Path] = []
|
|
18
|
+
|
|
19
|
+
def create_workspace(self, name: str, fixture_files: dict[str, Path] | None = None) -> Path:
|
|
20
|
+
workspace = self.base_dir / f"skill-eval-{name}"
|
|
21
|
+
if workspace.exists():
|
|
22
|
+
shutil.rmtree(workspace)
|
|
23
|
+
|
|
24
|
+
if self.source_repo:
|
|
25
|
+
self._clone_repo(workspace)
|
|
26
|
+
else:
|
|
27
|
+
workspace.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
self._init_git_repo(workspace)
|
|
29
|
+
|
|
30
|
+
if fixture_files:
|
|
31
|
+
for rel_path, src_path in fixture_files.items():
|
|
32
|
+
dest = workspace / rel_path
|
|
33
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
if src_path.is_dir():
|
|
35
|
+
shutil.copytree(src_path, dest, dirs_exist_ok=True)
|
|
36
|
+
else:
|
|
37
|
+
shutil.copy2(src_path, dest)
|
|
38
|
+
|
|
39
|
+
self.workspaces.append(workspace)
|
|
40
|
+
return workspace
|
|
41
|
+
|
|
42
|
+
def _clone_repo(self, workspace: Path) -> None:
|
|
43
|
+
subprocess.run(
|
|
44
|
+
["git", "clone", self.source_repo, str(workspace)],
|
|
45
|
+
capture_output=True,
|
|
46
|
+
check=True,
|
|
47
|
+
)
|
|
48
|
+
subprocess.run(
|
|
49
|
+
["git", "config", "user.email", "eval@skill-eval.local"],
|
|
50
|
+
cwd=workspace,
|
|
51
|
+
capture_output=True,
|
|
52
|
+
check=True,
|
|
53
|
+
)
|
|
54
|
+
subprocess.run(
|
|
55
|
+
["git", "config", "user.name", "Skill Eval"],
|
|
56
|
+
cwd=workspace,
|
|
57
|
+
capture_output=True,
|
|
58
|
+
check=True,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def _init_git_repo(self, workspace: Path) -> None:
|
|
62
|
+
subprocess.run(
|
|
63
|
+
["git", "init", "-b", "main"],
|
|
64
|
+
cwd=workspace,
|
|
65
|
+
capture_output=True,
|
|
66
|
+
check=True,
|
|
67
|
+
)
|
|
68
|
+
subprocess.run(
|
|
69
|
+
["git", "config", "user.email", "eval@skill-eval.local"],
|
|
70
|
+
cwd=workspace,
|
|
71
|
+
capture_output=True,
|
|
72
|
+
check=True,
|
|
73
|
+
)
|
|
74
|
+
subprocess.run(
|
|
75
|
+
["git", "config", "user.name", "Skill Eval"],
|
|
76
|
+
cwd=workspace,
|
|
77
|
+
capture_output=True,
|
|
78
|
+
check=True,
|
|
79
|
+
)
|
|
80
|
+
subprocess.run(
|
|
81
|
+
["git", "add", "."],
|
|
82
|
+
cwd=workspace,
|
|
83
|
+
capture_output=True,
|
|
84
|
+
check=True,
|
|
85
|
+
)
|
|
86
|
+
subprocess.run(
|
|
87
|
+
["git", "commit", "-m", "Initial commit", "--allow-empty"],
|
|
88
|
+
cwd=workspace,
|
|
89
|
+
capture_output=True,
|
|
90
|
+
check=True,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def cleanup(self, workspace: Path) -> None:
|
|
94
|
+
if workspace.exists():
|
|
95
|
+
shutil.rmtree(workspace)
|
|
96
|
+
if workspace in self.workspaces:
|
|
97
|
+
self.workspaces.remove(workspace)
|
|
98
|
+
|
|
99
|
+
def cleanup_all(self) -> None:
|
|
100
|
+
for ws in self.workspaces[:]:
|
|
101
|
+
self.cleanup(ws)
|