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/cli.py
ADDED
|
@@ -0,0 +1,844 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from dotenv import load_dotenv
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
load_dotenv()
|
|
15
|
+
|
|
16
|
+
from skill_eval import __version__ # noqa: E402
|
|
17
|
+
from skill_eval.graders import LLMGrader, grade_assertions # noqa: E402
|
|
18
|
+
from skill_eval.models import AgentType, CleanupManifest, EvalSuite # noqa: E402
|
|
19
|
+
from skill_eval.runner import EvalRunner # noqa: E402
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _version_callback(value: bool) -> None:
|
|
23
|
+
if value:
|
|
24
|
+
console.print(f"skill-eval {__version__}")
|
|
25
|
+
raise typer.Exit()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
app = typer.Typer(name="skill-eval", help="Evaluate agent skills across OpenCode, Claude Code, Codex, and Fake")
|
|
29
|
+
console = Console()
|
|
30
|
+
AGENT_CHOICES = ", ".join(t.value for t in AgentType)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.callback(invoke_without_command=True)
|
|
34
|
+
def main(
|
|
35
|
+
ctx: typer.Context,
|
|
36
|
+
version: Optional[bool] = typer.Option(
|
|
37
|
+
None, "--version", "-v", callback=_version_callback, is_eager=True, help="Show version and exit"
|
|
38
|
+
),
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Evaluate agent skills across OpenCode, Claude Code, Codex, and Fake."""
|
|
41
|
+
if ctx.invoked_subcommand is None:
|
|
42
|
+
typer.echo(ctx.get_help())
|
|
43
|
+
raise typer.Exit()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@app.command()
|
|
47
|
+
def run(
|
|
48
|
+
skill: Path = typer.Option(..., "--skill", "-s", help="Path to skill directory containing SKILL.md"),
|
|
49
|
+
evals: Path = typer.Option(..., "--evals", "-e", help="Path to evals.json file"),
|
|
50
|
+
agents: list[str] = typer.Option(
|
|
51
|
+
["opencode"],
|
|
52
|
+
"--agent",
|
|
53
|
+
"-a",
|
|
54
|
+
help=f"Agent(s) to evaluate: {AGENT_CHOICES}",
|
|
55
|
+
),
|
|
56
|
+
workspace: Path = typer.Option(
|
|
57
|
+
Path.cwd() / "eval-workspace",
|
|
58
|
+
"--workspace",
|
|
59
|
+
"-w",
|
|
60
|
+
help="Base directory for eval workspace",
|
|
61
|
+
),
|
|
62
|
+
iteration: int = typer.Option(1, "--iteration", "-i", help="Iteration number"),
|
|
63
|
+
concurrency: int = typer.Option(1, "--concurrency", "-c", help="Number of parallel eval runs"),
|
|
64
|
+
baseline: bool = typer.Option(True, "--baseline/--no-baseline", help="Run without-skill baseline"),
|
|
65
|
+
grader_model: str = typer.Option(
|
|
66
|
+
"deepseek/deepseek-v4-flash", "--grader-model", help="LLM model for rubric grading"
|
|
67
|
+
),
|
|
68
|
+
grader_base_url: Optional[str] = typer.Option(None, "--grader-base-url", help="Custom API base URL for grader"),
|
|
69
|
+
source_repo: Optional[str] = typer.Option(
|
|
70
|
+
None, "--source-repo", help="Git repo URL to clone as workspace (instead of fresh git init)"
|
|
71
|
+
),
|
|
72
|
+
auto_cleanup: bool = typer.Option(False, "--cleanup", help="Auto-cleanup PRs, branches, and workspaces after run"),
|
|
73
|
+
agent_models: list[str] = typer.Option(
|
|
74
|
+
[],
|
|
75
|
+
"--agent-model",
|
|
76
|
+
"-m",
|
|
77
|
+
help="Model for an agent as 'agent=model' (e.g. 'claude-code=haiku', 'codex=gpt-5-mini'). "
|
|
78
|
+
"A bare 'model' value applies to all selected agents. Repeatable.",
|
|
79
|
+
),
|
|
80
|
+
harness_base_url: Optional[str] = typer.Option(
|
|
81
|
+
None,
|
|
82
|
+
"--harness-base-url",
|
|
83
|
+
help="API base URL injected into each agent CLI's environment "
|
|
84
|
+
"(ANTHROPIC_BASE_URL for claude-code, OPENAI_BASE_URL for codex/opencode)",
|
|
85
|
+
),
|
|
86
|
+
runs: int = typer.Option(1, "--runs", "-n", help="Number of runs per (eval, agent, config) for pass@k stats"),
|
|
87
|
+
agent_timeout: Optional[int] = typer.Option(
|
|
88
|
+
None, "--timeout", help="Per-run agent timeout in seconds (default 600, env SKILL_EVAL_AGENT_TIMEOUT)"
|
|
89
|
+
),
|
|
90
|
+
agent_retries: Optional[int] = typer.Option(
|
|
91
|
+
None, "--retries", help="Retries on agent timeout/non-zero exit (default 1, env SKILL_EVAL_AGENT_RETRIES)"
|
|
92
|
+
),
|
|
93
|
+
):
|
|
94
|
+
"""Run skill evaluations."""
|
|
95
|
+
if not skill.exists():
|
|
96
|
+
console.print(f"[red]Skill path not found: {skill}[/red]")
|
|
97
|
+
raise typer.Exit(1)
|
|
98
|
+
if not evals.exists():
|
|
99
|
+
console.print(f"[red]Evals file not found: {evals}[/red]")
|
|
100
|
+
raise typer.Exit(1)
|
|
101
|
+
|
|
102
|
+
agent_types = []
|
|
103
|
+
for a in agents:
|
|
104
|
+
try:
|
|
105
|
+
agent_types.append(AgentType(a))
|
|
106
|
+
except ValueError:
|
|
107
|
+
console.print(f"[red]Unknown agent: {a}. Choose from: {AGENT_CHOICES}[/red]")
|
|
108
|
+
raise typer.Exit(1)
|
|
109
|
+
|
|
110
|
+
agent_model_map = _parse_agent_models(agent_models, agent_types)
|
|
111
|
+
|
|
112
|
+
from skill_eval.skills import SkillInstaller
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
for problem in SkillInstaller(skill).frontmatter_problems():
|
|
116
|
+
console.print(f"[yellow]Warning: {problem}[/yellow]")
|
|
117
|
+
except FileNotFoundError as e:
|
|
118
|
+
console.print(f"[red]{e}[/red]")
|
|
119
|
+
raise typer.Exit(1)
|
|
120
|
+
|
|
121
|
+
if grader_model and not LLMGrader.has_credentials():
|
|
122
|
+
console.print(
|
|
123
|
+
"[yellow]Warning: no OPENROUTER_API_KEY or OPENAI_API_KEY set. "
|
|
124
|
+
"Assertions that need LLM rubric grading will be SKIPPED (not failed).[/yellow]"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
runner = EvalRunner(
|
|
128
|
+
skill_path=skill,
|
|
129
|
+
evals_path=evals,
|
|
130
|
+
workspace_base=workspace,
|
|
131
|
+
agents=agent_types,
|
|
132
|
+
concurrency=concurrency,
|
|
133
|
+
with_baseline=baseline,
|
|
134
|
+
grader_model=grader_model,
|
|
135
|
+
grader_base_url=grader_base_url,
|
|
136
|
+
source_repo=source_repo,
|
|
137
|
+
agent_models=agent_model_map,
|
|
138
|
+
harness_base_url=harness_base_url,
|
|
139
|
+
agent_timeout=agent_timeout,
|
|
140
|
+
agent_max_retries=agent_retries,
|
|
141
|
+
runs=runs,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
result_dir = runner.run(iteration)
|
|
145
|
+
console.print(f"\n[bold green]Done! Results in: {result_dir}[/bold green]")
|
|
146
|
+
|
|
147
|
+
if auto_cleanup:
|
|
148
|
+
console.print()
|
|
149
|
+
console.print("[yellow]Running cleanup...[/yellow]")
|
|
150
|
+
_cleanup_iteration(result_dir, workspace)
|
|
151
|
+
console.print("[green]Cleanup complete![/green]")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _parse_agent_models(specs: list[str], agent_types: list[AgentType]) -> dict[AgentType, str]:
|
|
155
|
+
"""Parse repeated ``--agent-model`` values into an AgentType -> model map.
|
|
156
|
+
|
|
157
|
+
Accepts ``agent=model`` to target one agent, or a bare ``model`` that
|
|
158
|
+
applies to every selected agent.
|
|
159
|
+
"""
|
|
160
|
+
mapping: dict[AgentType, str] = {}
|
|
161
|
+
for spec in specs:
|
|
162
|
+
if "=" in spec:
|
|
163
|
+
agent_str, model = spec.split("=", 1)
|
|
164
|
+
try:
|
|
165
|
+
agent = AgentType(agent_str.strip())
|
|
166
|
+
except ValueError:
|
|
167
|
+
console.print(f"[red]Unknown agent in --agent-model '{spec}'. Choose from: {AGENT_CHOICES}[/red]")
|
|
168
|
+
raise typer.Exit(1)
|
|
169
|
+
if not model.strip():
|
|
170
|
+
console.print(f"[red]Empty model in --agent-model '{spec}'[/red]")
|
|
171
|
+
raise typer.Exit(1)
|
|
172
|
+
mapping[agent] = model.strip()
|
|
173
|
+
else:
|
|
174
|
+
if not spec.strip():
|
|
175
|
+
console.print(f"[red]Empty model in --agent-model '{spec}'[/red]")
|
|
176
|
+
raise typer.Exit(1)
|
|
177
|
+
for agent in agent_types:
|
|
178
|
+
mapping.setdefault(agent, spec.strip())
|
|
179
|
+
return mapping
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _cleanup_iteration(iteration_dir: Path, workspace_base: Path) -> None:
|
|
183
|
+
"""Clean up only artifacts recorded for this iteration's cleanup manifest.
|
|
184
|
+
|
|
185
|
+
Reads ``cleanup.json`` from the iteration dir. If the manifest is missing,
|
|
186
|
+
we do NOT touch remote state (no "delete all branches" or "close all PRs"
|
|
187
|
+
inference). We also do NOT glob-delete unrecorded ``skill-eval-*``
|
|
188
|
+
workspaces under the workspace base, because those may belong to other
|
|
189
|
+
runs or manual debugging. The only workspaces removed are those listed
|
|
190
|
+
in the manifest.
|
|
191
|
+
"""
|
|
192
|
+
manifest = _load_manifest(iteration_dir)
|
|
193
|
+
|
|
194
|
+
if manifest and (manifest.remote_branches or manifest.pr_numbers):
|
|
195
|
+
_cleanup_manifest(manifest, source_repo=manifest.source_repo)
|
|
196
|
+
elif manifest is None and iteration_dir.exists():
|
|
197
|
+
console.print(
|
|
198
|
+
f" [yellow]No cleanup.json in {iteration_dir}; skipping source repo cleanup "
|
|
199
|
+
f"and unrecorded workspace deletion[/yellow]"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
if manifest:
|
|
203
|
+
for ws_path in manifest.workspaces:
|
|
204
|
+
ws = Path(ws_path)
|
|
205
|
+
if ws.exists():
|
|
206
|
+
shutil.rmtree(ws)
|
|
207
|
+
console.print(f" [dim]Removed {ws.name}[/dim]")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _load_manifest(iteration_dir: Path) -> Optional[CleanupManifest]:
|
|
211
|
+
manifest_path = iteration_dir / "cleanup.json"
|
|
212
|
+
if not manifest_path.exists():
|
|
213
|
+
return None
|
|
214
|
+
try:
|
|
215
|
+
data = json.loads(manifest_path.read_text())
|
|
216
|
+
return CleanupManifest(**data)
|
|
217
|
+
except (json.JSONDecodeError, ValueError) as e:
|
|
218
|
+
console.print(f" [red]Could not parse cleanup.json: {e}[/red]")
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _cleanup_manifest(manifest: CleanupManifest, source_repo: Optional[str]) -> None:
|
|
223
|
+
if not source_repo or not manifest.source_repo_slug:
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
slug = manifest.source_repo_slug
|
|
227
|
+
|
|
228
|
+
for pr_number in manifest.pr_numbers:
|
|
229
|
+
result = subprocess.run(
|
|
230
|
+
["gh", "pr", "close", str(pr_number), "--repo", slug, "--delete-branch"],
|
|
231
|
+
capture_output=True,
|
|
232
|
+
text=True,
|
|
233
|
+
)
|
|
234
|
+
if result.returncode == 0:
|
|
235
|
+
console.print(f" [dim]Closed PR #{pr_number}[/dim]")
|
|
236
|
+
else:
|
|
237
|
+
console.print(
|
|
238
|
+
f" [yellow]Could not close PR #{pr_number}: {result.stderr.strip() or 'unknown error'}[/yellow]"
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
for branch in manifest.remote_branches:
|
|
242
|
+
result = subprocess.run(
|
|
243
|
+
["gh", "api", "-X", "DELETE", f"repos/{slug}/git/refs/heads/{branch}"],
|
|
244
|
+
capture_output=True,
|
|
245
|
+
text=True,
|
|
246
|
+
)
|
|
247
|
+
if result.returncode == 0:
|
|
248
|
+
console.print(f" [dim]Deleted branch {branch}[/dim]")
|
|
249
|
+
else:
|
|
250
|
+
console.print(
|
|
251
|
+
f" [yellow]Could not delete branch {branch}: {result.stderr.strip() or 'unknown error'}[/yellow]"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@app.command()
|
|
256
|
+
def cleanup(
|
|
257
|
+
iteration: Optional[Path] = typer.Option(
|
|
258
|
+
None,
|
|
259
|
+
"--iteration",
|
|
260
|
+
"-i",
|
|
261
|
+
help="Path to an iteration directory (e.g. eval-workspace/<skill>-workspace/iteration-1). "
|
|
262
|
+
"If omitted, all iteration dirs under --workspace are cleaned up.",
|
|
263
|
+
),
|
|
264
|
+
workspace: Path = typer.Option(
|
|
265
|
+
Path.cwd() / "eval-workspace",
|
|
266
|
+
"--workspace",
|
|
267
|
+
"-w",
|
|
268
|
+
help="Base directory for eval workspace (used when --iteration is omitted)",
|
|
269
|
+
),
|
|
270
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
|
|
271
|
+
):
|
|
272
|
+
"""Clean up eval artifacts from cleanup.json manifests.
|
|
273
|
+
|
|
274
|
+
This command is SAFE: it only closes PRs and deletes branches that were
|
|
275
|
+
recorded in ``cleanup.json`` by a previous ``run`` invocation. It will
|
|
276
|
+
never close unrelated PRs or delete unrelated branches.
|
|
277
|
+
"""
|
|
278
|
+
iteration_dirs: list[Path] = []
|
|
279
|
+
if iteration:
|
|
280
|
+
if not iteration.exists():
|
|
281
|
+
console.print(f"[red]Iteration directory not found: {iteration}[/red]")
|
|
282
|
+
raise typer.Exit(1)
|
|
283
|
+
iteration_dirs = [iteration]
|
|
284
|
+
else:
|
|
285
|
+
if not workspace.exists():
|
|
286
|
+
console.print(f"[yellow]Workspace directory not found: {workspace}[/yellow]")
|
|
287
|
+
return
|
|
288
|
+
iteration_dirs = sorted(p for p in workspace.glob("*/iteration-*") if p.is_dir())
|
|
289
|
+
|
|
290
|
+
if not iteration_dirs:
|
|
291
|
+
console.print("[yellow]No iteration directories found[/yellow]")
|
|
292
|
+
return
|
|
293
|
+
|
|
294
|
+
for iter_dir in iteration_dirs:
|
|
295
|
+
manifest = _load_manifest(iter_dir)
|
|
296
|
+
if manifest is None:
|
|
297
|
+
console.print(f"[yellow]{iter_dir.name}: no cleanup.json, skipping source repo cleanup[/yellow]")
|
|
298
|
+
elif manifest.remote_branches or manifest.pr_numbers:
|
|
299
|
+
console.print(
|
|
300
|
+
f"[cyan]{iter_dir.name}: cleaning {len(manifest.remote_branches)} branch(es), "
|
|
301
|
+
f"{len(manifest.pr_numbers)} PR(s) on {manifest.source_repo_slug}[/cyan]"
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
if not yes:
|
|
305
|
+
confirm = typer.confirm("Proceed?", default=False)
|
|
306
|
+
if not confirm:
|
|
307
|
+
console.print(" [dim]Skipped[/dim]")
|
|
308
|
+
continue
|
|
309
|
+
|
|
310
|
+
_cleanup_manifest(manifest, source_repo=manifest.source_repo)
|
|
311
|
+
|
|
312
|
+
if manifest and manifest.workspaces:
|
|
313
|
+
for ws_path in manifest.workspaces:
|
|
314
|
+
ws = Path(ws_path)
|
|
315
|
+
if ws.exists():
|
|
316
|
+
shutil.rmtree(ws)
|
|
317
|
+
|
|
318
|
+
console.print("[green]Cleanup complete![/green]")
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@app.command()
|
|
322
|
+
def grade(
|
|
323
|
+
workspace: Path = typer.Option(..., "--workspace", "-w", help="Path to iteration workspace"),
|
|
324
|
+
grader_model: str = typer.Option(
|
|
325
|
+
"deepseek/deepseek-v4-flash", "--grader-model", help="LLM model for rubric grading"
|
|
326
|
+
),
|
|
327
|
+
grader_base_url: Optional[str] = typer.Option(None, "--grader-base-url", help="Custom API base URL for grader"),
|
|
328
|
+
recompute_benchmark: bool = typer.Option(
|
|
329
|
+
False, "--recompute-benchmark", help="Recompute benchmark.json from updated grading.json files"
|
|
330
|
+
),
|
|
331
|
+
):
|
|
332
|
+
"""Re-grade existing eval results using saved eval metadata and state snapshots."""
|
|
333
|
+
if not workspace.exists():
|
|
334
|
+
console.print(f"[red]Workspace not found: {workspace}[/red]")
|
|
335
|
+
raise typer.Exit(1)
|
|
336
|
+
|
|
337
|
+
console.print(f"[yellow]Re-grading results in {workspace}...[/yellow]")
|
|
338
|
+
console.print(
|
|
339
|
+
"[yellow]Note: LLM-graded assertions are re-evaluated from scratch; "
|
|
340
|
+
"their verdicts may differ from the original run.[/yellow]"
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
meta_path = workspace / "evals_meta.json"
|
|
344
|
+
if not meta_path.exists():
|
|
345
|
+
console.print(
|
|
346
|
+
f"[red]No evals_meta.json in {workspace}. Cannot determine assertions. "
|
|
347
|
+
f"Re-run 'skill-eval run' to generate it.[/red]"
|
|
348
|
+
)
|
|
349
|
+
raise typer.Exit(1)
|
|
350
|
+
|
|
351
|
+
with open(meta_path, encoding="utf-8") as f:
|
|
352
|
+
meta = json.load(f)
|
|
353
|
+
|
|
354
|
+
eval_lookup: dict[str, dict] = {}
|
|
355
|
+
for eval_case in meta.get("evals", []):
|
|
356
|
+
eval_lookup[str(eval_case["id"])] = eval_case
|
|
357
|
+
|
|
358
|
+
try:
|
|
359
|
+
llm_grader = LLMGrader(model=grader_model, base_url=grader_base_url)
|
|
360
|
+
except Exception:
|
|
361
|
+
llm_grader = None
|
|
362
|
+
|
|
363
|
+
updated = 0
|
|
364
|
+
for eval_dir in sorted(workspace.glob("eval-*")):
|
|
365
|
+
eval_id = eval_dir.name[len("eval-") :]
|
|
366
|
+
eval_case = eval_lookup.get(eval_id)
|
|
367
|
+
if not eval_case:
|
|
368
|
+
console.print(f" [yellow]No metadata for {eval_dir.name}, skipping[/yellow]")
|
|
369
|
+
continue
|
|
370
|
+
|
|
371
|
+
assertions = eval_case.get("assertions", [])
|
|
372
|
+
should_trigger = eval_case.get("should_trigger", True)
|
|
373
|
+
|
|
374
|
+
for agent_dir in sorted(eval_dir.iterdir()):
|
|
375
|
+
if not agent_dir.is_dir():
|
|
376
|
+
continue
|
|
377
|
+
for config_dir in _leaf_config_dirs(agent_dir):
|
|
378
|
+
output_path = config_dir / "outputs" / "output.txt"
|
|
379
|
+
if not output_path.exists():
|
|
380
|
+
continue
|
|
381
|
+
|
|
382
|
+
agent_output = output_path.read_text()
|
|
383
|
+
grading = grade_assertions(
|
|
384
|
+
assertions,
|
|
385
|
+
agent_output,
|
|
386
|
+
config_dir / "outputs",
|
|
387
|
+
eval_case.get("expected_output", ""),
|
|
388
|
+
llm_grader,
|
|
389
|
+
pre_state=None,
|
|
390
|
+
post_state=None,
|
|
391
|
+
should_trigger=should_trigger,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
grading_path = config_dir / "grading.json"
|
|
395
|
+
with open(grading_path, "w", encoding="utf-8") as f:
|
|
396
|
+
json.dump(grading.model_dump(), f, indent=2)
|
|
397
|
+
|
|
398
|
+
console.print(
|
|
399
|
+
f" {eval_dir.name}/{agent_dir.name}/{config_dir.relative_to(agent_dir)}: "
|
|
400
|
+
f"{grading.summary.passed}/{grading.summary.total} passed"
|
|
401
|
+
)
|
|
402
|
+
updated += 1
|
|
403
|
+
|
|
404
|
+
if recompute_benchmark:
|
|
405
|
+
from skill_eval.runner import compute_benchmark
|
|
406
|
+
|
|
407
|
+
results = _collect_results(workspace)
|
|
408
|
+
agents = sorted({r.get("agent") for r in results.values() if r.get("agent") and r.get("agent") != "unknown"})
|
|
409
|
+
agent_types = [AgentType(a) for a in agents if a in {t.value for t in AgentType}]
|
|
410
|
+
benchmark = compute_benchmark(
|
|
411
|
+
results, agent_types, with_baseline=any(not r.get("with_skill", True) for r in results.values())
|
|
412
|
+
)
|
|
413
|
+
with open(workspace / "benchmark.json", "w", encoding="utf-8") as f:
|
|
414
|
+
json.dump(benchmark.model_dump(), f, indent=2)
|
|
415
|
+
console.print("[green]benchmark.json recomputed[/green]")
|
|
416
|
+
else:
|
|
417
|
+
console.print(
|
|
418
|
+
"[yellow]benchmark.json NOT recomputed. Re-run 'skill-eval run' or pass --recompute-benchmark.[/yellow]"
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
console.print(f"[green]Re-grading complete! Updated {updated} grading file(s).[/green]")
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _leaf_config_dirs(agent_dir: Path) -> list[Path]:
|
|
425
|
+
"""Yield config dirs that directly contain run artifacts.
|
|
426
|
+
|
|
427
|
+
Layouts: ``agent/with_skill/outputs`` (single run) or
|
|
428
|
+
``agent/with_skill/run-N/outputs`` (``--runs N`` > 1).
|
|
429
|
+
"""
|
|
430
|
+
leaves: list[Path] = []
|
|
431
|
+
for config_dir in sorted(agent_dir.iterdir()):
|
|
432
|
+
if not config_dir.is_dir():
|
|
433
|
+
continue
|
|
434
|
+
run_dirs = sorted(p for p in config_dir.glob("run-*") if p.is_dir())
|
|
435
|
+
if run_dirs:
|
|
436
|
+
leaves.extend(run_dirs)
|
|
437
|
+
else:
|
|
438
|
+
leaves.append(config_dir)
|
|
439
|
+
return leaves
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _collect_results(workspace: Path) -> dict[str, dict]:
|
|
443
|
+
"""Reconstruct per-run results for benchmark recompute.
|
|
444
|
+
|
|
445
|
+
Reads ``run_meta.json`` written by ``_run_single`` to recover the real
|
|
446
|
+
agent and ``with_skill`` flag (the directory layout now includes the
|
|
447
|
+
agent, but we use the metadata file as the source of truth). Falls back
|
|
448
|
+
to ``"unknown"`` only if the metadata is missing, and disambiguates
|
|
449
|
+
collisions by including the agent directory name in the key.
|
|
450
|
+
"""
|
|
451
|
+
results: dict[str, dict] = {}
|
|
452
|
+
for eval_dir in sorted(workspace.glob("eval-*")):
|
|
453
|
+
for agent_dir in sorted(eval_dir.iterdir()):
|
|
454
|
+
if not agent_dir.is_dir():
|
|
455
|
+
continue
|
|
456
|
+
for config_dir in _leaf_config_dirs(agent_dir):
|
|
457
|
+
grading_path = config_dir / "grading.json"
|
|
458
|
+
timing_path = config_dir / "timing.json"
|
|
459
|
+
if not grading_path.exists() or not timing_path.exists():
|
|
460
|
+
continue
|
|
461
|
+
with open(grading_path, encoding="utf-8") as f:
|
|
462
|
+
grading = json.load(f)
|
|
463
|
+
with open(timing_path, encoding="utf-8") as f:
|
|
464
|
+
timing = json.load(f)
|
|
465
|
+
|
|
466
|
+
meta_path = config_dir / "run_meta.json"
|
|
467
|
+
agent = "unknown"
|
|
468
|
+
with_skill = "with_skill" in config_dir.parts
|
|
469
|
+
eval_id = eval_dir.name[len("eval-") :]
|
|
470
|
+
if meta_path.exists():
|
|
471
|
+
try:
|
|
472
|
+
meta = json.loads(meta_path.read_text())
|
|
473
|
+
agent = meta.get("agent", "unknown")
|
|
474
|
+
with_skill = bool(meta.get("with_skill", with_skill))
|
|
475
|
+
eval_id = meta.get("eval_id", eval_id)
|
|
476
|
+
except (json.JSONDecodeError, ValueError):
|
|
477
|
+
console.print(f" [yellow]Could not parse {meta_path}; marking agent as unknown[/yellow]")
|
|
478
|
+
else:
|
|
479
|
+
console.print(f" [yellow]No run_meta.json in {config_dir}; agent identity unknown[/yellow]")
|
|
480
|
+
|
|
481
|
+
# Include the path relative to the eval dir in the key to
|
|
482
|
+
# avoid collisions when metadata is missing.
|
|
483
|
+
key = f"{eval_dir.name}-{agent}-{'-'.join(config_dir.relative_to(eval_dir).parts)}"
|
|
484
|
+
if key in results:
|
|
485
|
+
console.print(f" [yellow]Duplicate result key {key} for {config_dir}; skipping[/yellow]")
|
|
486
|
+
continue
|
|
487
|
+
results[key] = {
|
|
488
|
+
"eval_id": eval_id,
|
|
489
|
+
"agent": agent,
|
|
490
|
+
"with_skill": with_skill,
|
|
491
|
+
"timing": timing,
|
|
492
|
+
"grading": grading,
|
|
493
|
+
}
|
|
494
|
+
return results
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _benchmark_rows(benchmark: dict) -> list[tuple[str, str, str, str, str]]:
|
|
498
|
+
rows = []
|
|
499
|
+
for config, stats in benchmark.get("run_summary", {}).items():
|
|
500
|
+
pass_rate = stats.get("pass_rate", {})
|
|
501
|
+
time_s = stats.get("time_seconds", {})
|
|
502
|
+
tokens = stats.get("tokens", {})
|
|
503
|
+
k = stats.get("k", 1)
|
|
504
|
+
pass_at_k = f"{stats.get('pass_at_k', 0):.0%} (k={k})" if k > 1 else f"{stats.get('full_pass_rate', 0):.0%}"
|
|
505
|
+
rows.append(
|
|
506
|
+
(
|
|
507
|
+
config,
|
|
508
|
+
f"{pass_rate.get('mean', 0):.1%} +/- {pass_rate.get('stddev', 0):.1%}",
|
|
509
|
+
pass_at_k,
|
|
510
|
+
f"{time_s.get('mean', 0):.1f} +/- {time_s.get('stddev', 0):.1f}",
|
|
511
|
+
f"{tokens.get('mean', 0):.0f} +/- {tokens.get('stddev', 0):.0f}",
|
|
512
|
+
)
|
|
513
|
+
)
|
|
514
|
+
return rows
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _benchmark_deltas(benchmark: dict) -> dict[str, dict]:
|
|
518
|
+
deltas = benchmark.get("deltas") or {}
|
|
519
|
+
if not deltas and benchmark.get("delta"):
|
|
520
|
+
deltas = {"all": benchmark["delta"]}
|
|
521
|
+
return deltas
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
REPORT_COLUMNS = ["Configuration", "Pass Rate", "Full Pass / pass@k", "Time (s)", "Tokens"]
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _print_markdown_report(iter_name: str, benchmark: dict) -> None:
|
|
528
|
+
print(f"\n### {iter_name}\n")
|
|
529
|
+
print("| " + " | ".join(REPORT_COLUMNS) + " |")
|
|
530
|
+
print("|" + "|".join([" --- "] * len(REPORT_COLUMNS)) + "|")
|
|
531
|
+
for row in _benchmark_rows(benchmark):
|
|
532
|
+
print("| " + " | ".join(row) + " |")
|
|
533
|
+
deltas = _benchmark_deltas(benchmark)
|
|
534
|
+
if deltas:
|
|
535
|
+
print("\n**Delta (with_skill - without_skill):**\n")
|
|
536
|
+
for agent, delta in deltas.items():
|
|
537
|
+
print(
|
|
538
|
+
f"- `{agent}`: pass rate {delta.get('pass_rate', 0):+.1%}, "
|
|
539
|
+
f"time {delta.get('time_seconds', 0):+.1f}s, tokens {delta.get('tokens', 0):+.0f}"
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _print_table_report(iter_name: str, benchmark: dict) -> None:
|
|
544
|
+
console.print(f"\n[bold]{iter_name}[/bold]")
|
|
545
|
+
|
|
546
|
+
table = Table(title="Results Summary")
|
|
547
|
+
table.add_column(REPORT_COLUMNS[0], style="cyan")
|
|
548
|
+
table.add_column(REPORT_COLUMNS[1], style="green")
|
|
549
|
+
table.add_column(REPORT_COLUMNS[2], style="magenta")
|
|
550
|
+
table.add_column(REPORT_COLUMNS[3], style="yellow")
|
|
551
|
+
table.add_column(REPORT_COLUMNS[4], style="blue")
|
|
552
|
+
for row in _benchmark_rows(benchmark):
|
|
553
|
+
table.add_row(*row)
|
|
554
|
+
console.print(table)
|
|
555
|
+
|
|
556
|
+
deltas = _benchmark_deltas(benchmark)
|
|
557
|
+
if deltas:
|
|
558
|
+
console.print("\n[bold]Delta (with_skill - without_skill):[/bold]")
|
|
559
|
+
for agent, delta in deltas.items():
|
|
560
|
+
console.print(
|
|
561
|
+
f" {agent}: pass rate {delta.get('pass_rate', 0):+.1%}, "
|
|
562
|
+
f"time {delta.get('time_seconds', 0):+.1f}s, tokens {delta.get('tokens', 0):+.0f}"
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _print_eval_details(iter_dir: Path, show_evidence: bool, markdown: bool) -> None:
|
|
567
|
+
for eval_dir in sorted(iter_dir.glob("eval-*")):
|
|
568
|
+
if markdown:
|
|
569
|
+
print(f"\n#### {eval_dir.name}\n")
|
|
570
|
+
else:
|
|
571
|
+
console.print(f"\n[bold]{eval_dir.name}[/bold]")
|
|
572
|
+
for agent_dir in sorted(eval_dir.iterdir()):
|
|
573
|
+
if not agent_dir.is_dir():
|
|
574
|
+
continue
|
|
575
|
+
for config_dir in _leaf_config_dirs(agent_dir):
|
|
576
|
+
grading_path = config_dir / "grading.json"
|
|
577
|
+
if not grading_path.exists():
|
|
578
|
+
continue
|
|
579
|
+
with open(grading_path, encoding="utf-8") as f:
|
|
580
|
+
grading = json.load(f)
|
|
581
|
+
summary = grading.get("summary", {})
|
|
582
|
+
label = f"{agent_dir.name}/{config_dir.relative_to(agent_dir)}"
|
|
583
|
+
skipped = summary.get("skipped", 0)
|
|
584
|
+
skipped_note = f", {skipped} skipped" if skipped else ""
|
|
585
|
+
line = (
|
|
586
|
+
f"{label}: {summary.get('passed', 0)}/{summary.get('total', 0)} passed "
|
|
587
|
+
f"({summary.get('pass_rate', 0):.0%}{skipped_note})"
|
|
588
|
+
)
|
|
589
|
+
if markdown:
|
|
590
|
+
print(f"- {line}")
|
|
591
|
+
else:
|
|
592
|
+
console.print(f" {line}")
|
|
593
|
+
if show_evidence:
|
|
594
|
+
for result in grading.get("assertion_results", []):
|
|
595
|
+
if result.get("passed"):
|
|
596
|
+
continue
|
|
597
|
+
status = "SKIPPED" if result.get("skipped") else "FAIL"
|
|
598
|
+
detail = f"[{status}] {result.get('text')} — {result.get('evidence')}"
|
|
599
|
+
if markdown:
|
|
600
|
+
print(f" - {detail}")
|
|
601
|
+
else:
|
|
602
|
+
style = "yellow" if result.get("skipped") else "red"
|
|
603
|
+
console.print(f" [{style}]{detail}[/{style}]")
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
@app.command()
|
|
607
|
+
def report(
|
|
608
|
+
workspace: Path = typer.Option(..., "--workspace", "-w", help="Path to workspace containing iteration dirs"),
|
|
609
|
+
iteration: Optional[int] = typer.Option(None, "--iteration", "-i", help="Specific iteration to report on"),
|
|
610
|
+
fmt: str = typer.Option("table", "--format", "-f", help="Output format: table or markdown"),
|
|
611
|
+
show_evidence: bool = typer.Option(
|
|
612
|
+
False, "--show-evidence", help="Show evidence for failed/skipped assertions from grading.json"
|
|
613
|
+
),
|
|
614
|
+
):
|
|
615
|
+
"""Display a summary report of eval results."""
|
|
616
|
+
if not workspace.exists():
|
|
617
|
+
console.print(f"[red]Workspace not found: {workspace}[/red]")
|
|
618
|
+
raise typer.Exit(1)
|
|
619
|
+
if fmt not in ("table", "markdown"):
|
|
620
|
+
console.print(f"[red]Unknown format: {fmt}. Choose 'table' or 'markdown'.[/red]")
|
|
621
|
+
raise typer.Exit(1)
|
|
622
|
+
|
|
623
|
+
if iteration:
|
|
624
|
+
iteration_dirs = [workspace / f"iteration-{iteration}"]
|
|
625
|
+
else:
|
|
626
|
+
iteration_dirs = sorted(workspace.glob("iteration-*"))
|
|
627
|
+
|
|
628
|
+
if not iteration_dirs:
|
|
629
|
+
console.print("[yellow]No iterations found[/yellow]")
|
|
630
|
+
raise typer.Exit(1)
|
|
631
|
+
|
|
632
|
+
markdown = fmt == "markdown"
|
|
633
|
+
for iter_dir in iteration_dirs:
|
|
634
|
+
benchmark_path = iter_dir / "benchmark.json"
|
|
635
|
+
if not benchmark_path.exists():
|
|
636
|
+
console.print(f"[yellow]No benchmark.json in {iter_dir.name}[/yellow]")
|
|
637
|
+
continue
|
|
638
|
+
|
|
639
|
+
with open(benchmark_path, encoding="utf-8") as f:
|
|
640
|
+
benchmark = json.load(f)
|
|
641
|
+
|
|
642
|
+
if markdown:
|
|
643
|
+
_print_markdown_report(iter_dir.name, benchmark)
|
|
644
|
+
else:
|
|
645
|
+
_print_table_report(iter_dir.name, benchmark)
|
|
646
|
+
|
|
647
|
+
_print_eval_details(iter_dir, show_evidence, markdown)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
@app.command()
|
|
651
|
+
def compare(
|
|
652
|
+
workspace: Path = typer.Option(..., "--workspace", "-w", help="Path to workspace containing iteration dirs"),
|
|
653
|
+
iteration_a: int = typer.Argument(..., help="Baseline iteration number"),
|
|
654
|
+
iteration_b: int = typer.Argument(..., help="Comparison iteration number"),
|
|
655
|
+
):
|
|
656
|
+
"""Compare benchmark results between two iterations of the same skill."""
|
|
657
|
+
benchmarks = {}
|
|
658
|
+
for n in (iteration_a, iteration_b):
|
|
659
|
+
path = workspace / f"iteration-{n}" / "benchmark.json"
|
|
660
|
+
if not path.exists():
|
|
661
|
+
console.print(f"[red]No benchmark.json for iteration-{n} in {workspace}[/red]")
|
|
662
|
+
raise typer.Exit(1)
|
|
663
|
+
with open(path, encoding="utf-8") as f:
|
|
664
|
+
benchmarks[n] = json.load(f)
|
|
665
|
+
|
|
666
|
+
summary_a = benchmarks[iteration_a].get("run_summary", {})
|
|
667
|
+
summary_b = benchmarks[iteration_b].get("run_summary", {})
|
|
668
|
+
configs = sorted(set(summary_a) | set(summary_b))
|
|
669
|
+
|
|
670
|
+
table = Table(title=f"iteration-{iteration_a} vs iteration-{iteration_b}")
|
|
671
|
+
table.add_column("Configuration", style="cyan")
|
|
672
|
+
table.add_column(f"Pass Rate (it-{iteration_a})", style="green")
|
|
673
|
+
table.add_column(f"Pass Rate (it-{iteration_b})", style="green")
|
|
674
|
+
table.add_column("Change", style="magenta")
|
|
675
|
+
|
|
676
|
+
for config in configs:
|
|
677
|
+
a = summary_a.get(config, {}).get("pass_rate", {}).get("mean")
|
|
678
|
+
b = summary_b.get(config, {}).get("pass_rate", {}).get("mean")
|
|
679
|
+
a_str = f"{a:.1%}" if a is not None else "-"
|
|
680
|
+
b_str = f"{b:.1%}" if b is not None else "-"
|
|
681
|
+
change = f"{b - a:+.1%}" if a is not None and b is not None else "-"
|
|
682
|
+
table.add_row(config, a_str, b_str, change)
|
|
683
|
+
|
|
684
|
+
console.print(table)
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
@app.command()
|
|
688
|
+
def validate(
|
|
689
|
+
evals: Path = typer.Argument(..., help="Path to an evals.json file to validate"),
|
|
690
|
+
):
|
|
691
|
+
"""Validate an evals.json file against the eval suite schema."""
|
|
692
|
+
if not evals.exists():
|
|
693
|
+
console.print(f"[red]File not found: {evals}[/red]")
|
|
694
|
+
raise typer.Exit(1)
|
|
695
|
+
|
|
696
|
+
try:
|
|
697
|
+
data = json.loads(evals.read_text())
|
|
698
|
+
except json.JSONDecodeError as e:
|
|
699
|
+
console.print(f"[red]Invalid JSON: {e}[/red]")
|
|
700
|
+
raise typer.Exit(1)
|
|
701
|
+
|
|
702
|
+
try:
|
|
703
|
+
suite = EvalSuite(**data)
|
|
704
|
+
except Exception as e:
|
|
705
|
+
console.print(f"[red]Schema validation failed:[/red]\n{e}")
|
|
706
|
+
raise typer.Exit(1)
|
|
707
|
+
|
|
708
|
+
missing_files = []
|
|
709
|
+
for eval_case in suite.evals:
|
|
710
|
+
for file_path in eval_case.files:
|
|
711
|
+
if not (evals.parent / file_path).exists():
|
|
712
|
+
missing_files.append(f"eval '{eval_case.id}': {file_path}")
|
|
713
|
+
if missing_files:
|
|
714
|
+
console.print("[red]Referenced fixture files not found:[/red]")
|
|
715
|
+
for m in missing_files:
|
|
716
|
+
console.print(f" - {m}")
|
|
717
|
+
raise typer.Exit(1)
|
|
718
|
+
|
|
719
|
+
ids = [str(e.id) for e in suite.evals]
|
|
720
|
+
duplicates = sorted({i for i in ids if ids.count(i) > 1})
|
|
721
|
+
if duplicates:
|
|
722
|
+
console.print(f"[red]Duplicate eval ids: {duplicates}[/red]")
|
|
723
|
+
raise typer.Exit(1)
|
|
724
|
+
|
|
725
|
+
console.print(f"[green]Valid: {len(suite.evals)} eval(s) for skill '{suite.skill_name}'[/green]")
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
@app.command(name="list")
|
|
729
|
+
def list_suites(
|
|
730
|
+
root: Path = typer.Option(Path.cwd(), "--root", "-r", help="Directory to search for eval suites and skills"),
|
|
731
|
+
):
|
|
732
|
+
"""List discoverable skills (SKILL.md) and eval suites (evals.json)."""
|
|
733
|
+
suites = sorted(root.glob("*/*/evals/evals.json")) + sorted(root.glob("*/evals/evals.json"))
|
|
734
|
+
skills = sorted(root.glob("skills/*/SKILL.md")) + sorted(root.glob("*/SKILL.md"))
|
|
735
|
+
|
|
736
|
+
seen: set[Path] = set()
|
|
737
|
+
table = Table(title=f"Eval suites under {root}")
|
|
738
|
+
table.add_column("Skill", style="cyan")
|
|
739
|
+
table.add_column("Evals file", style="green")
|
|
740
|
+
table.add_column("Cases", style="yellow")
|
|
741
|
+
for path in suites:
|
|
742
|
+
if path in seen:
|
|
743
|
+
continue
|
|
744
|
+
seen.add(path)
|
|
745
|
+
try:
|
|
746
|
+
data = json.loads(path.read_text())
|
|
747
|
+
name = data.get("skill_name", "?")
|
|
748
|
+
count = str(len(data.get("evals", [])))
|
|
749
|
+
except (json.JSONDecodeError, OSError):
|
|
750
|
+
name, count = "(unparseable)", "-"
|
|
751
|
+
table.add_row(name, str(path.relative_to(root)), count)
|
|
752
|
+
if seen:
|
|
753
|
+
console.print(table)
|
|
754
|
+
else:
|
|
755
|
+
console.print(f"[yellow]No evals.json files found under {root}[/yellow]")
|
|
756
|
+
|
|
757
|
+
seen_skills: set[Path] = set()
|
|
758
|
+
skill_table = Table(title=f"Skills under {root}")
|
|
759
|
+
skill_table.add_column("Skill dir", style="cyan")
|
|
760
|
+
for path in skills:
|
|
761
|
+
if path in seen_skills:
|
|
762
|
+
continue
|
|
763
|
+
seen_skills.add(path)
|
|
764
|
+
skill_table.add_row(str(path.parent.relative_to(root)))
|
|
765
|
+
if seen_skills:
|
|
766
|
+
console.print(skill_table)
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
SKILL_MD_TEMPLATE = """\
|
|
770
|
+
---
|
|
771
|
+
name: {skill_name}
|
|
772
|
+
description: One-line description used by the agent to decide when to trigger this skill.
|
|
773
|
+
license: MIT
|
|
774
|
+
compatibility: opencode, claude-code, codex
|
|
775
|
+
---
|
|
776
|
+
|
|
777
|
+
## What I do
|
|
778
|
+
|
|
779
|
+
Describe the skill's behavior here.
|
|
780
|
+
|
|
781
|
+
## When to use me
|
|
782
|
+
|
|
783
|
+
Trigger on phrases like:
|
|
784
|
+
- "..."
|
|
785
|
+
|
|
786
|
+
## Steps
|
|
787
|
+
|
|
788
|
+
1. ...
|
|
789
|
+
"""
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
@app.command()
|
|
793
|
+
def init(
|
|
794
|
+
skill_name: str = typer.Argument(..., help="Name of the skill to create eval structure for"),
|
|
795
|
+
output: Path = typer.Option(Path.cwd(), "--output", "-o", help="Output directory"),
|
|
796
|
+
):
|
|
797
|
+
"""Initialize an eval structure (and a SKILL.md template) for a skill."""
|
|
798
|
+
evals_dir = output / skill_name / "evals"
|
|
799
|
+
evals_dir.mkdir(parents=True, exist_ok=True)
|
|
800
|
+
|
|
801
|
+
example_evals = {
|
|
802
|
+
"skill_name": skill_name,
|
|
803
|
+
"evals": [
|
|
804
|
+
{
|
|
805
|
+
"id": "explicit-invoke",
|
|
806
|
+
"prompt": f"Use the ${skill_name} skill to do its primary function",
|
|
807
|
+
"expected_output": "Description of what success looks like",
|
|
808
|
+
"files": [],
|
|
809
|
+
"force_skill_invocation": True,
|
|
810
|
+
"assertions": [
|
|
811
|
+
"The output contains expected content",
|
|
812
|
+
],
|
|
813
|
+
},
|
|
814
|
+
{
|
|
815
|
+
"id": "negative-control",
|
|
816
|
+
"prompt": "Show me the git log for the last 10 commits",
|
|
817
|
+
"expected_output": "The skill should NOT trigger.",
|
|
818
|
+
"should_trigger": False,
|
|
819
|
+
"assertions": [
|
|
820
|
+
"A new git branch was created",
|
|
821
|
+
"A git commit was created",
|
|
822
|
+
],
|
|
823
|
+
},
|
|
824
|
+
],
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
evals_path = evals_dir / "evals.json"
|
|
828
|
+
with open(evals_path, "w", encoding="utf-8") as f:
|
|
829
|
+
json.dump(example_evals, f, indent=2)
|
|
830
|
+
|
|
831
|
+
(evals_dir / "files").mkdir(exist_ok=True)
|
|
832
|
+
|
|
833
|
+
skill_md_path = output / skill_name / "SKILL.md"
|
|
834
|
+
if not skill_md_path.exists():
|
|
835
|
+
skill_md_path.write_text(SKILL_MD_TEMPLATE.format(skill_name=skill_name))
|
|
836
|
+
|
|
837
|
+
console.print(f"[green]Created eval structure at {evals_dir}[/green]")
|
|
838
|
+
console.print(f" - {evals_path}")
|
|
839
|
+
console.print(f" - {evals_dir / 'files'}/ (add fixture files here)")
|
|
840
|
+
console.print(f" - {skill_md_path} (skill template; edit before evaluating)")
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
if __name__ == "__main__":
|
|
844
|
+
app()
|