@fede0089/skill-eval 3.0.1 → 3.1.0
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.
- package/README.md +20 -4
- package/dist/commands/functional.js +11 -0
- package/dist/commands/trigger.js +2 -1
- package/dist/core/eval-runner.js +47 -10
- package/dist/core/evaluator.js +10 -1
- package/dist/reporters/html-reporter.js +4 -1
- package/dist/runners/codex/runner.js +4 -0
- package/dist/utils/eval-loader.js +5 -1
- package/dist/utils/table-renderer.js +7 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ A CLI tool for evaluating Agent Skills locally. Tests whether your skill trigger
|
|
|
6
6
|
|
|
7
7
|
skill-eval ships two commands, each targeting a different failure mode:
|
|
8
8
|
|
|
9
|
-
- **Triggering** (`skill-eval trigger`) — checks whether the agent actually decides to invoke the skill in the right context. A skill that never gets triggered cannot help, no matter how good its instructions are.
|
|
9
|
+
- **Triggering** (`skill-eval trigger`) — checks whether the agent actually decides to invoke the skill in the right context, and leaves it alone in the wrong one. A skill that never gets triggered cannot help, no matter how good its instructions are; one that triggers everywhere gets in the way.
|
|
10
10
|
- **Functional correctness** (`skill-eval functional`) — checks whether the actions the agent takes while the skill is active match your expectations. An LLM judge grades each transcript against the expectation list you provide.
|
|
11
11
|
|
|
12
12
|
## Why run skill evals
|
|
@@ -38,7 +38,7 @@ For each eval prompt, skill-eval spins up parallel agent processes with the curr
|
|
|
38
38
|
pass@k
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
> The `trigger` command only runs with-skill trials and checks whether the skill dispatch tool was actually invoked — no judge or baseline needed.
|
|
41
|
+
> The `trigger` command only runs with-skill trials and checks whether the skill dispatch tool was actually invoked — no judge or baseline needed. Evals marked `should_trigger: false` assert the opposite: that it was *not* invoked.
|
|
42
42
|
>
|
|
43
43
|
> The baseline branch is opt-in: enable it with `--compare-baseline` (no-skill control) or `--compare-ref <ref>` (historical skill versions).
|
|
44
44
|
|
|
@@ -128,6 +128,22 @@ All `.json` files in `evals/` are loaded and merged into a single suite — you
|
|
|
128
128
|
}
|
|
129
129
|
```
|
|
130
130
|
|
|
131
|
+
**Negative trigger eval** — add `should_trigger: false` to assert the skill must *not* fire:
|
|
132
|
+
```json
|
|
133
|
+
{
|
|
134
|
+
"skill_name": "my-skill",
|
|
135
|
+
"evals": [
|
|
136
|
+
{ "id": 2, "prompt": "Something my skill has no business handling", "should_trigger": false }
|
|
137
|
+
]
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Positive evals measure **under-triggering** (the skill never fires when it should). Negative evals measure the opposite failure, **over-triggering** — the one you introduce when you widen a skill's `description` to catch more cases and silently lose precision. Both kinds live in the same suite and feed a single success rate, so a change that improves one at the expense of the other shows up immediately.
|
|
142
|
+
|
|
143
|
+
A negative eval fails if the agent *attempts* to activate the skill at all, even if the activation itself errors out. These evals are trigger-only: `skill-eval functional` skips them, since that command instructs the agent to use the skill.
|
|
144
|
+
|
|
145
|
+
> Trigger detection for Codex is heuristic (it infers activations from the event stream), so negative evals are most reliable on `claude-code` and `gemini-cli`.
|
|
146
|
+
|
|
131
147
|
**Functional eval** — add `expectations` for the LLM judge to evaluate:
|
|
132
148
|
```json
|
|
133
149
|
{
|
|
@@ -205,7 +221,7 @@ Refer to your runner's documentation for the full set of settings and policy key
|
|
|
205
221
|
|
|
206
222
|
Each run writes to `.project-skill-evals/runs/<timestamp>/` and includes per-trial logs, the raw eval JSON, and a self-contained HTML report you can open in any browser. The report shows pass@k aggregates per eval, lets you expand each trial, and color-codes triggering vs. functional outcomes.
|
|
207
223
|
|
|
208
|
-
A published sample report is available at [fede0089.github.io/skill-eval/sample-report.html](https://fede0089.github.io/skill-eval/sample-report.html),generated from this project root with:
|
|
224
|
+
A published sample report is available at [fede0089.github.io/skill-eval/sample-report.html](https://fede0089.github.io/skill-eval/sample-report.html), generated from this project root with:
|
|
209
225
|
|
|
210
226
|
```sh
|
|
211
227
|
skill-eval functional --workspace . --skill mock-skill --trials 2 --compare-baseline --debug claude-code
|
|
@@ -224,7 +240,7 @@ Without `--debug` these files are not written, so reach for the flag when you ne
|
|
|
224
240
|
|
|
225
241
|
## Try it out
|
|
226
242
|
|
|
227
|
-
This repo includes a `mock-skill/` directory — a complete, working example of a license-generator skill with trigger and functional evals. Run it directly with:
|
|
243
|
+
This repo includes a `mock-skill/` directory — a complete, working example of a license-generator skill with positive trigger, negative trigger, and functional evals. Run it directly with:
|
|
228
244
|
|
|
229
245
|
```sh
|
|
230
246
|
npm run test:unit # run the unit test suite
|
|
@@ -8,6 +8,7 @@ import { EvalRunner } from '../core/eval-runner.js';
|
|
|
8
8
|
import { AgentPool } from '../core/agent-pool.js';
|
|
9
9
|
import { aggregatePassAtK, aggregateAssertionPassRate, aggregateTokenStats, aggregateDurationStats } from '../core/statistics.js';
|
|
10
10
|
import { preflight } from '../core/preflight.js';
|
|
11
|
+
import { ConfigError } from '../core/errors.js';
|
|
11
12
|
import { withRetry } from '../core/trial-utils.js';
|
|
12
13
|
import { renderFunctionalTable, renderRunHeader } from '../utils/table-renderer.js';
|
|
13
14
|
import { JsonReporter } from '../reporters/index.js';
|
|
@@ -17,6 +18,16 @@ export async function functionalCommand(agent, workspace, skillPath, maxAgents =
|
|
|
17
18
|
if (!injectedSuite)
|
|
18
19
|
preflight(agent, workspace, skillPath);
|
|
19
20
|
const suite = injectedSuite || evalLoader.loadEvalSuite(skillPath);
|
|
21
|
+
// Negative evals (should_trigger: false) only make sense for the trigger command:
|
|
22
|
+
// this pass instructs the agent that it MUST use the skill, which contradicts them.
|
|
23
|
+
const triggerOnly = suite.tasks.filter(t => t.should_trigger === false);
|
|
24
|
+
if (triggerOnly.length > 0) {
|
|
25
|
+
if (evalId !== undefined && triggerOnly.some(t => t.id === evalId)) {
|
|
26
|
+
throw new ConfigError(`Eval #${evalId} is trigger-only (should_trigger: false) and cannot run under 'functional'.`);
|
|
27
|
+
}
|
|
28
|
+
suite.tasks = suite.tasks.filter(t => t.should_trigger !== false);
|
|
29
|
+
Logger.write(chalk.dim(` Skipping ${triggerOnly.length} trigger-only eval(s)\n`));
|
|
30
|
+
}
|
|
20
31
|
if (evalId !== undefined) {
|
|
21
32
|
suite.tasks = suite.tasks.filter(t => t.id === evalId);
|
|
22
33
|
if (suite.tasks.length === 0) {
|
package/dist/commands/trigger.js
CHANGED
|
@@ -149,7 +149,8 @@ export async function triggerCommand(agent, workspace, skillPath, maxAgents = 4,
|
|
|
149
149
|
taskId: task.id,
|
|
150
150
|
prompt: task.prompt,
|
|
151
151
|
baselineTrials: [],
|
|
152
|
-
skillTrials: taskSkillTrials
|
|
152
|
+
skillTrials: taskSkillTrials,
|
|
153
|
+
shouldTrigger: task.should_trigger !== false
|
|
153
154
|
};
|
|
154
155
|
taskResults.push(taskResult);
|
|
155
156
|
if (!localAllPassed && !multi) {
|
package/dist/core/eval-runner.js
CHANGED
|
@@ -65,22 +65,59 @@ export class EvalRunner {
|
|
|
65
65
|
const tokenStats = transcript
|
|
66
66
|
? parseTokenStats(transcript.response || '') ?? undefined
|
|
67
67
|
: undefined;
|
|
68
|
-
|
|
68
|
+
// Negative evals (should_trigger: false) assert the opposite: the skill must NOT activate.
|
|
69
|
+
const shouldTrigger = task.should_trigger !== false;
|
|
70
|
+
const assertionLabel = shouldTrigger ? 'Skill was triggered' : 'Skill was not triggered';
|
|
71
|
+
let trialPassed = false;
|
|
69
72
|
const assertionResults = [];
|
|
70
73
|
if (transcript && !transcript.error) {
|
|
71
74
|
uiCtx.updateLog('Grading…');
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
75
|
+
if (shouldTrigger) {
|
|
76
|
+
const triggered = this.triggerGrader.gradeTrigger(transcript);
|
|
77
|
+
trialPassed = triggered;
|
|
78
|
+
assertionResults.push({
|
|
79
|
+
assertion: assertionLabel,
|
|
80
|
+
passed: triggered,
|
|
81
|
+
reason: triggered ? 'Detected skill activation in transcript' : 'No skill activation detected in transcript',
|
|
82
|
+
graderType: 'programmatic'
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
else if (!this.triggerGrader.hasParsableEvents(transcript)) {
|
|
86
|
+
// Without any parsable events the absence of an activation proves nothing —
|
|
87
|
+
// report it as an infrastructure error instead of a vacuous pass.
|
|
88
|
+
const reason = 'No parsable agent events — cannot verify that the skill did not trigger';
|
|
89
|
+
assertionResults.push({
|
|
90
|
+
assertion: assertionLabel,
|
|
91
|
+
passed: false,
|
|
92
|
+
reason,
|
|
93
|
+
graderType: 'programmatic'
|
|
94
|
+
});
|
|
95
|
+
return {
|
|
96
|
+
id: trialId,
|
|
97
|
+
transcript,
|
|
98
|
+
assertionResults,
|
|
99
|
+
trialPassed: false,
|
|
100
|
+
isError: true,
|
|
101
|
+
tokenStats,
|
|
102
|
+
durationMs
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
// Any activation attempt counts, even one that failed: the skill still fired.
|
|
107
|
+
const triggered = this.triggerGrader.detectSkillAttempt(transcript);
|
|
108
|
+
trialPassed = !triggered;
|
|
109
|
+
assertionResults.push({
|
|
110
|
+
assertion: assertionLabel,
|
|
111
|
+
passed: !triggered,
|
|
112
|
+
reason: triggered ? 'Skill activation detected in transcript' : 'No skill activation detected in transcript',
|
|
113
|
+
graderType: 'programmatic'
|
|
114
|
+
});
|
|
115
|
+
}
|
|
79
116
|
}
|
|
80
117
|
else {
|
|
81
118
|
const errorMsg = transcript?.error || 'Error: No transcript was produced';
|
|
82
119
|
assertionResults.push({
|
|
83
|
-
assertion:
|
|
120
|
+
assertion: assertionLabel,
|
|
84
121
|
passed: false,
|
|
85
122
|
reason: errorMsg,
|
|
86
123
|
graderType: 'programmatic'
|
|
@@ -99,7 +136,7 @@ export class EvalRunner {
|
|
|
99
136
|
id: trialId,
|
|
100
137
|
transcript: transcript || { error: 'No transcript produced' },
|
|
101
138
|
assertionResults: assertionResults,
|
|
102
|
-
trialPassed
|
|
139
|
+
trialPassed,
|
|
103
140
|
tokenStats,
|
|
104
141
|
durationMs
|
|
105
142
|
};
|
package/dist/core/evaluator.js
CHANGED
|
@@ -89,10 +89,19 @@ export class TriggerGrader {
|
|
|
89
89
|
}
|
|
90
90
|
return false;
|
|
91
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* Whether the transcript produced any parsable NDJSON events.
|
|
94
|
+
* Negative trigger evals rely on the absence of an activation event, so a transcript
|
|
95
|
+
* with no events at all cannot prove anything and must not be graded as a pass.
|
|
96
|
+
*/
|
|
97
|
+
hasParsableEvents(transcript) {
|
|
98
|
+
return parseNdjsonEvents(transcript.raw_output || '').length > 0;
|
|
99
|
+
}
|
|
92
100
|
/**
|
|
93
101
|
* Checks whether the skill was attempted (any tool_use event for activate_skill matching the skill name).
|
|
94
102
|
* Unlike gradeTrigger, does NOT require a successful tool_result — any attempt counts.
|
|
95
|
-
* Used to detect invalid baseline runs where the agent tried to invoke the restricted skill
|
|
103
|
+
* Used to detect invalid baseline runs where the agent tried to invoke the restricted skill,
|
|
104
|
+
* and to grade negative trigger evals (an attempted activation still counts as a trigger).
|
|
96
105
|
*/
|
|
97
106
|
detectSkillAttempt(transcript) {
|
|
98
107
|
const rawOutput = transcript.raw_output || '';
|
|
@@ -215,9 +215,11 @@ function renderTaskTable(report) {
|
|
|
215
215
|
const headerRow = `<tr><th>#</th><th>Prompt</th><th>Details</th></tr>`;
|
|
216
216
|
const rows = results.map(result => {
|
|
217
217
|
const prompt = escapeHtml(result.prompt);
|
|
218
|
+
// Negative trigger evals assert the opposite outcome — flag them so the row is not misread.
|
|
219
|
+
const badge = result.shouldTrigger === false ? '<span class="badge-neg">no-trigger</span>' : '';
|
|
218
220
|
const detailsBtn = `<button class="details-btn" data-target="details-${result.taskId}">▶</button>`;
|
|
219
221
|
const detailsRow = `<tr class="details-row"><td colspan="3">${renderTaskDetails(result, functional)}</td></tr>`;
|
|
220
|
-
return `<tr><td>${result.taskId}</td><td class="prompt-cell">${prompt}</td><td>${detailsBtn}</td></tr>${detailsRow}`;
|
|
222
|
+
return `<tr><td>${result.taskId}</td><td class="prompt-cell">${badge}${prompt}</td><td>${detailsBtn}</td></tr>${detailsRow}`;
|
|
221
223
|
}).join('');
|
|
222
224
|
return `<div class="table-wrap"><table><thead>${headerRow}</thead><tbody>${rows}</tbody></table></div>`;
|
|
223
225
|
}
|
|
@@ -292,6 +294,7 @@ th { background: #f8fafc; font-size: 11px; font-weight: 600; text-transform: upp
|
|
|
292
294
|
td { padding: 10px 12px; border-bottom: 1px solid #f1f5f9; vertical-align: top; }
|
|
293
295
|
tr:last-child td { border-bottom: none; }
|
|
294
296
|
.prompt-cell { max-width: 480px; word-break: break-word; color: #334155; }
|
|
297
|
+
.badge-neg { display: inline-block; margin-right: 8px; padding: 1px 7px; border-radius: 10px; background: #e0f2fe; color: #0369a1; font-size: 11px; font-weight: 600; vertical-align: 1px; }
|
|
295
298
|
|
|
296
299
|
/* Details */
|
|
297
300
|
.details-btn { background: none; border: 1px solid #e2e8f0; border-radius: 4px; cursor: pointer; padding: 2px 8px; font-size: 11px; color: #64748b; transition: background 0.15s; }
|
|
@@ -57,6 +57,10 @@ function eventSignalsSkill(event, skillName) {
|
|
|
57
57
|
const eventType = String(event.type ?? '').toLowerCase();
|
|
58
58
|
const item = getEventItem(event);
|
|
59
59
|
const itemType = String(item?.type ?? '').toLowerCase();
|
|
60
|
+
// Assistant prose is not a tool call: an agent merely naming the skill (often to say it is
|
|
61
|
+
// not needed) must not be reported as an activation. Real activations arrive as skill items.
|
|
62
|
+
if (['agent_message', 'assistant_message', 'message'].includes(itemType))
|
|
63
|
+
return false;
|
|
60
64
|
return (serialized.includes(normalizedSkill) &&
|
|
61
65
|
(serialized.includes('skill') ||
|
|
62
66
|
eventType.includes('skill') ||
|
|
@@ -44,12 +44,16 @@ export function loadEvalSuite(skillPath) {
|
|
|
44
44
|
if (e.id === undefined || typeof e.id !== 'number') {
|
|
45
45
|
throw new ConfigError(`Invalid task ID in ${file}. ID must be a number.`);
|
|
46
46
|
}
|
|
47
|
+
if (e.should_trigger !== undefined && typeof e.should_trigger !== 'boolean') {
|
|
48
|
+
throw new ConfigError(`Invalid 'should_trigger' for eval ${e.id} in ${file}. Must be a boolean.`);
|
|
49
|
+
}
|
|
47
50
|
return {
|
|
48
51
|
id: e.id,
|
|
49
52
|
prompt: e.prompt,
|
|
50
53
|
expected_output: e.expected_output,
|
|
51
54
|
assertions: e.expectations || e.assertions,
|
|
52
|
-
files: e.files
|
|
55
|
+
files: e.files,
|
|
56
|
+
should_trigger: e.should_trigger
|
|
53
57
|
};
|
|
54
58
|
});
|
|
55
59
|
mergedTasks.push(...mappedTasks);
|
|
@@ -134,7 +134,11 @@ export function renderTriggerTable(report) {
|
|
|
134
134
|
const numTrials = metrics.numTrials || 1;
|
|
135
135
|
// Identify all skill versions present in the results
|
|
136
136
|
const skillVersions = results.length > 0 ? Object.keys(results[0].skillTrials) : ['local'];
|
|
137
|
+
// Only surface the polarity column when the suite actually contains negative evals.
|
|
138
|
+
const hasNegativeEvals = results.some(r => r.shouldTrigger === false);
|
|
137
139
|
const header = ['ID', 'Prompt'];
|
|
140
|
+
if (hasNegativeEvals)
|
|
141
|
+
header.push('Expect');
|
|
138
142
|
if (numTrials > 1) {
|
|
139
143
|
for (const version of skillVersions) {
|
|
140
144
|
header.push(`${version} Trials`, `${version} Rate`);
|
|
@@ -150,6 +154,9 @@ export function renderTriggerTable(report) {
|
|
|
150
154
|
for (const result of results) {
|
|
151
155
|
const promptSnippet = result.prompt.substring(0, 40) + (result.prompt.length > 40 ? '...' : '');
|
|
152
156
|
const row = [result.taskId.toString(), promptSnippet];
|
|
157
|
+
if (hasNegativeEvals) {
|
|
158
|
+
row.push(result.shouldTrigger === false ? chalk.cyan('no-trigger') : chalk.dim('trigger'));
|
|
159
|
+
}
|
|
153
160
|
for (const version of skillVersions) {
|
|
154
161
|
const trials = result.skillTrials[version] || [];
|
|
155
162
|
const p1Cell = formatPassAt1(trials);
|