@fede0089/skill-eval 3.0.2 → 3.2.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 +26 -4
- package/dist/commands/functional.js +17 -3
- package/dist/commands/trigger.js +5 -4
- package/dist/core/eval-runner.js +47 -10
- package/dist/core/evaluator.js +10 -1
- package/dist/index.js +19 -5
- package/dist/reporters/html-reporter.js +4 -1
- package/dist/runners/codex/runner.js +4 -0
- package/dist/utils/eval-loader.js +25 -4
- package/dist/utils/table-renderer.js +14 -2
- 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
|
|
|
@@ -89,7 +89,8 @@ skill-eval functional --workspace <path> --skill <path> [options] [agent]
|
|
|
89
89
|
| `--trials <number>` | no | `3` | Trials per task (for pass@k) |
|
|
90
90
|
| `--timeout <seconds>` | no | none | Kill the agent after this many seconds |
|
|
91
91
|
| `--eval-id <id>` | no | all | Run only the eval with this numeric ID |
|
|
92
|
-
| `--
|
|
92
|
+
| `--eval-file <name>` | no | all | Run only the evals from this file in `evals/` (e.g. `edge-cases.json`) |
|
|
93
|
+
| `--compare-ref [refs...]` | no | — | Git references to compare against (variadic — put `[agent]` before it, not after) |
|
|
93
94
|
| `--compare-baseline` | no | `false` | Also run the no-skill baseline alongside the skill |
|
|
94
95
|
| `-v, --debug` | no | `false` | Enable verbose debug logging |
|
|
95
96
|
| `[agent]` | no | `gemini-cli` | Agent backend to use |
|
|
@@ -118,6 +119,8 @@ my-skill/
|
|
|
118
119
|
|
|
119
120
|
All `.json` files in `evals/` are loaded and merged into a single suite — you can split them by feature or regression category.
|
|
120
121
|
|
|
122
|
+
Use `--eval-file <name>` to run just one of them while iterating (the `.json` extension is optional), and combine it with `--eval-id <id>` to narrow down to a single eval inside that file.
|
|
123
|
+
|
|
121
124
|
**Trigger eval** — `id` must be a unique integer across all eval files:
|
|
122
125
|
```json
|
|
123
126
|
{
|
|
@@ -128,6 +131,22 @@ All `.json` files in `evals/` are loaded and merged into a single suite — you
|
|
|
128
131
|
}
|
|
129
132
|
```
|
|
130
133
|
|
|
134
|
+
**Negative trigger eval** — add `should_trigger: false` to assert the skill must *not* fire:
|
|
135
|
+
```json
|
|
136
|
+
{
|
|
137
|
+
"skill_name": "my-skill",
|
|
138
|
+
"evals": [
|
|
139
|
+
{ "id": 2, "prompt": "Something my skill has no business handling", "should_trigger": false }
|
|
140
|
+
]
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
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.
|
|
145
|
+
|
|
146
|
+
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.
|
|
147
|
+
|
|
148
|
+
> 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`.
|
|
149
|
+
|
|
131
150
|
**Functional eval** — add `expectations` for the LLM judge to evaluate:
|
|
132
151
|
```json
|
|
133
152
|
{
|
|
@@ -224,7 +243,7 @@ Without `--debug` these files are not written, so reach for the flag when you ne
|
|
|
224
243
|
|
|
225
244
|
## Try it out
|
|
226
245
|
|
|
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:
|
|
246
|
+
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
247
|
|
|
229
248
|
```sh
|
|
230
249
|
npm run test:unit # run the unit test suite
|
|
@@ -234,6 +253,9 @@ npm run test:trigger -- codex # run trigger evals with Codex
|
|
|
234
253
|
npm run test:functional -- codex # run functional evals with Codex
|
|
235
254
|
npm run test:trigger -- claude-code # run trigger evals with Claude Code
|
|
236
255
|
npm run test:functional -- claude-code # run functional evals with Claude Code
|
|
256
|
+
|
|
257
|
+
npm run test:trigger -- --eval-file negative-triggers.json # only the negative-trigger evals
|
|
258
|
+
npm run test:trigger -- --eval-file edge-cases --eval-id 3 # a single eval inside one file
|
|
237
259
|
```
|
|
238
260
|
|
|
239
261
|
## Extending
|
|
@@ -8,15 +8,29 @@ 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';
|
|
14
15
|
import chalk from 'chalk';
|
|
15
16
|
import { git } from '../utils/git.js';
|
|
16
|
-
export async function functionalCommand(agent, workspace, skillPath, maxAgents = 4, injectedSuite, numTrials = 3, reporter = new JsonReporter(), timeoutMs, evalId, compareRefs = [], compareBaseline = false) {
|
|
17
|
+
export async function functionalCommand(agent, workspace, skillPath, maxAgents = 4, injectedSuite, numTrials = 3, reporter = new JsonReporter(), timeoutMs, evalId, compareRefs = [], compareBaseline = false, evalFile) {
|
|
17
18
|
if (!injectedSuite)
|
|
18
19
|
preflight(agent, workspace, skillPath);
|
|
19
|
-
const suite = injectedSuite || evalLoader.loadEvalSuite(skillPath);
|
|
20
|
+
const suite = injectedSuite || evalLoader.loadEvalSuite(skillPath, evalFile);
|
|
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
|
+
if (suite.tasks.length === 0) {
|
|
30
|
+
throw new ConfigError(`No evals left to run: all ${triggerOnly.length} eval(s) in scope are trigger-only (should_trigger: false).`);
|
|
31
|
+
}
|
|
32
|
+
Logger.write(chalk.dim(` Skipping ${triggerOnly.length} trigger-only eval(s)\n`));
|
|
33
|
+
}
|
|
20
34
|
if (evalId !== undefined) {
|
|
21
35
|
suite.tasks = suite.tasks.filter(t => t.id === evalId);
|
|
22
36
|
if (suite.tasks.length === 0) {
|
|
@@ -83,7 +97,7 @@ export async function functionalCommand(agent, workspace, skillPath, maxAgents =
|
|
|
83
97
|
]
|
|
84
98
|
: skillVersions.flatMap(v => Array.from({ length: numTrials }, (_, i) => `${v} ${i + 1}`));
|
|
85
99
|
try {
|
|
86
|
-
renderRunHeader({ command: 'functional', skillName: skill_name, agent, workspace, tasks: tasks.length, trials: numTrials, maxAgents, timeoutMs, runDir, evalId });
|
|
100
|
+
renderRunHeader({ command: 'functional', skillName: skill_name, agent, workspace, tasks: tasks.length, trials: numTrials, maxAgents, timeoutMs, runDir, evalId, evalFile });
|
|
87
101
|
Logger.write(`──────────────────────────────────────────────────\n`);
|
|
88
102
|
for (let i = 0; i < tasks.length; i++) {
|
|
89
103
|
const task = tasks[i];
|
package/dist/commands/trigger.js
CHANGED
|
@@ -13,10 +13,10 @@ import { renderTriggerTable, renderRunHeader } from '../utils/table-renderer.js'
|
|
|
13
13
|
import { JsonReporter } from '../reporters/index.js';
|
|
14
14
|
import chalk from 'chalk';
|
|
15
15
|
import { git } from '../utils/git.js';
|
|
16
|
-
export async function triggerCommand(agent, workspace, skillPath, maxAgents = 4, injectedSuite, numTrials = 3, reporter = new JsonReporter(), timeoutMs, evalId, compareRefs = []) {
|
|
16
|
+
export async function triggerCommand(agent, workspace, skillPath, maxAgents = 4, injectedSuite, numTrials = 3, reporter = new JsonReporter(), timeoutMs, evalId, compareRefs = [], evalFile) {
|
|
17
17
|
if (!injectedSuite)
|
|
18
18
|
preflight(agent, workspace, skillPath);
|
|
19
|
-
const suite = injectedSuite || evalLoader.loadEvalSuite(skillPath);
|
|
19
|
+
const suite = injectedSuite || evalLoader.loadEvalSuite(skillPath, evalFile);
|
|
20
20
|
if (evalId !== undefined) {
|
|
21
21
|
suite.tasks = suite.tasks.filter(t => t.id === evalId);
|
|
22
22
|
if (suite.tasks.length === 0) {
|
|
@@ -73,7 +73,7 @@ export async function triggerCommand(agent, workspace, skillPath, maxAgents = 4,
|
|
|
73
73
|
const skillVersions = Array.from(variantRunners.keys());
|
|
74
74
|
const subtaskLabels = skillVersions.flatMap(v => Array.from({ length: numTrials }, (_, i) => `${v} ${i + 1}`));
|
|
75
75
|
try {
|
|
76
|
-
renderRunHeader({ command: 'trigger', skillName: skill_name, agent, workspace, tasks: tasks.length, trials: numTrials, maxAgents, timeoutMs, runDir, evalId });
|
|
76
|
+
renderRunHeader({ command: 'trigger', skillName: skill_name, agent, workspace, tasks: tasks.length, trials: numTrials, maxAgents, timeoutMs, runDir, evalId, evalFile });
|
|
77
77
|
Logger.write(`--- Trigger Pass ---\n`);
|
|
78
78
|
Logger.write(`──────────────────────────────────────────────────\n`);
|
|
79
79
|
for (let i = 0; i < tasks.length; i++) {
|
|
@@ -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 || '';
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { Command } from 'commander';
|
|
|
3
3
|
import { triggerCommand } from './commands/trigger.js';
|
|
4
4
|
import { functionalCommand } from './commands/functional.js';
|
|
5
5
|
import { Logger } from './utils/logger.js';
|
|
6
|
-
import { AppError } from './core/errors.js';
|
|
6
|
+
import { AppError, ConfigError } from './core/errors.js';
|
|
7
7
|
import { HtmlReporter } from './reporters/index.js';
|
|
8
8
|
import { DEFAULT_AGENT } from './runners/registry.js';
|
|
9
9
|
import * as path from 'path';
|
|
@@ -25,6 +25,18 @@ const errorHandler = (err) => {
|
|
|
25
25
|
}
|
|
26
26
|
process.exit(1);
|
|
27
27
|
};
|
|
28
|
+
/**
|
|
29
|
+
* A variadic option used without values (`--compare-ref` on its own) is parsed as
|
|
30
|
+
* `true` by commander; reject it instead of iterating over a boolean.
|
|
31
|
+
*/
|
|
32
|
+
const parseCompareRefs = (value) => {
|
|
33
|
+
if (value === undefined)
|
|
34
|
+
return [];
|
|
35
|
+
if (Array.isArray(value))
|
|
36
|
+
return value;
|
|
37
|
+
errorHandler(new ConfigError('--compare-ref requires at least one git reference (e.g. --compare-ref HEAD~1).'));
|
|
38
|
+
return [];
|
|
39
|
+
};
|
|
28
40
|
program
|
|
29
41
|
.name('skill-eval')
|
|
30
42
|
.description('CLI to evaluate agent skills triggering and functionality')
|
|
@@ -42,6 +54,7 @@ program
|
|
|
42
54
|
.option('--trials <number>', 'Number of trials per task for pass@k calculation', '3')
|
|
43
55
|
.option('--timeout <seconds>', 'Agent timeout in seconds')
|
|
44
56
|
.option('--eval-id <id>', 'Run only the eval with this ID (numeric)')
|
|
57
|
+
.option('--eval-file <name>', 'Run only the evals from this file in evals/ (e.g. edge-cases.json)')
|
|
45
58
|
.option('--compare-ref [refs...]', 'Compare against historical git references')
|
|
46
59
|
.action((agent, options) => {
|
|
47
60
|
const workspace = path.resolve(options.workspace);
|
|
@@ -50,8 +63,8 @@ program
|
|
|
50
63
|
const numTrials = parseInt(options.trials, 10);
|
|
51
64
|
const timeoutMs = options.timeout ? parseInt(options.timeout, 10) * 1000 : undefined;
|
|
52
65
|
const evalId = options.evalId !== undefined ? parseInt(options.evalId, 10) : undefined;
|
|
53
|
-
const compareRefs = options.compareRef
|
|
54
|
-
triggerCommand(selectedAgent, workspace, options.skill, maxAgents, undefined, numTrials, new HtmlReporter(), timeoutMs, evalId, compareRefs).catch(errorHandler);
|
|
66
|
+
const compareRefs = parseCompareRefs(options.compareRef);
|
|
67
|
+
triggerCommand(selectedAgent, workspace, options.skill, maxAgents, undefined, numTrials, new HtmlReporter(), timeoutMs, evalId, compareRefs, options.evalFile).catch(errorHandler);
|
|
55
68
|
});
|
|
56
69
|
program
|
|
57
70
|
.command('functional [agent]')
|
|
@@ -62,6 +75,7 @@ program
|
|
|
62
75
|
.option('--trials <number>', 'Number of trials per task for pass@k calculation', '3')
|
|
63
76
|
.option('--timeout <seconds>', 'Agent timeout in seconds')
|
|
64
77
|
.option('--eval-id <id>', 'Run only the eval with this ID (numeric)')
|
|
78
|
+
.option('--eval-file <name>', 'Run only the evals from this file in evals/ (e.g. edge-cases.json)')
|
|
65
79
|
.option('--compare-ref [refs...]', 'Compare against historical git references')
|
|
66
80
|
.option('--compare-baseline', 'Also run the no-skill baseline alongside the skill')
|
|
67
81
|
.action((agent, options) => {
|
|
@@ -71,9 +85,9 @@ program
|
|
|
71
85
|
const numTrials = parseInt(options.trials, 10);
|
|
72
86
|
const timeoutMs = options.timeout ? parseInt(options.timeout, 10) * 1000 : undefined;
|
|
73
87
|
const evalId = options.evalId !== undefined ? parseInt(options.evalId, 10) : undefined;
|
|
74
|
-
const compareRefs = options.compareRef
|
|
88
|
+
const compareRefs = parseCompareRefs(options.compareRef);
|
|
75
89
|
const compareBaseline = !!options.compareBaseline;
|
|
76
|
-
functionalCommand(selectedAgent, workspace, options.skill, maxAgents, undefined, numTrials, new HtmlReporter(), timeoutMs, evalId, compareRefs, compareBaseline).catch(errorHandler);
|
|
90
|
+
functionalCommand(selectedAgent, workspace, options.skill, maxAgents, undefined, numTrials, new HtmlReporter(), timeoutMs, evalId, compareRefs, compareBaseline, options.evalFile).catch(errorHandler);
|
|
77
91
|
});
|
|
78
92
|
const isMain = process.argv[1] && (() => {
|
|
79
93
|
try {
|
|
@@ -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') ||
|
|
@@ -1,20 +1,36 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
import { ConfigError } from '../core/errors.js';
|
|
4
|
+
/**
|
|
5
|
+
* Normalizes an --eval-file value to the bare file name used inside the evals directory.
|
|
6
|
+
* Accepts 'edge-cases', 'edge-cases.json' and 'path/to/evals/edge-cases.json' alike.
|
|
7
|
+
*/
|
|
8
|
+
function normalizeEvalFileName(evalFile) {
|
|
9
|
+
const base = path.basename(evalFile.trim());
|
|
10
|
+
return base.endsWith('.json') ? base : `${base}.json`;
|
|
11
|
+
}
|
|
4
12
|
/**
|
|
5
13
|
* Loads and merges all JSON evaluation files from a skill's evals directory.
|
|
6
14
|
* Aligned with Anthropic's recommendation to split evals by capability/regression.
|
|
7
15
|
* Supports legacy 'tasks' and 'assertions' internally while maintaining 'evals' and 'expectations' in files.
|
|
16
|
+
* When 'evalFile' is given, only that file is loaded instead of the whole suite.
|
|
8
17
|
*/
|
|
9
|
-
export function loadEvalSuite(skillPath) {
|
|
18
|
+
export function loadEvalSuite(skillPath, evalFile) {
|
|
10
19
|
const evalsDir = path.resolve(skillPath, 'evals');
|
|
11
20
|
if (!fs.existsSync(evalsDir)) {
|
|
12
21
|
throw new ConfigError(`Could not find evals directory at ${evalsDir}`);
|
|
13
22
|
}
|
|
14
|
-
|
|
23
|
+
let files = fs.readdirSync(evalsDir).filter(file => file.endsWith('.json'));
|
|
15
24
|
if (files.length === 0) {
|
|
16
25
|
throw new ConfigError(`No JSON evaluation files found in ${evalsDir}`);
|
|
17
26
|
}
|
|
27
|
+
if (evalFile !== undefined) {
|
|
28
|
+
const wanted = normalizeEvalFileName(evalFile);
|
|
29
|
+
if (!files.includes(wanted)) {
|
|
30
|
+
throw new ConfigError(`Eval file '${wanted}' not found in ${evalsDir}. Available: ${[...files].sort().join(', ')}.`);
|
|
31
|
+
}
|
|
32
|
+
files = [wanted];
|
|
33
|
+
}
|
|
18
34
|
let mergedSkillName = '';
|
|
19
35
|
const mergedTasks = [];
|
|
20
36
|
for (const file of files) {
|
|
@@ -44,18 +60,23 @@ export function loadEvalSuite(skillPath) {
|
|
|
44
60
|
if (e.id === undefined || typeof e.id !== 'number') {
|
|
45
61
|
throw new ConfigError(`Invalid task ID in ${file}. ID must be a number.`);
|
|
46
62
|
}
|
|
63
|
+
if (e.should_trigger !== undefined && typeof e.should_trigger !== 'boolean') {
|
|
64
|
+
throw new ConfigError(`Invalid 'should_trigger' for eval ${e.id} in ${file}. Must be a boolean.`);
|
|
65
|
+
}
|
|
47
66
|
return {
|
|
48
67
|
id: e.id,
|
|
49
68
|
prompt: e.prompt,
|
|
50
69
|
expected_output: e.expected_output,
|
|
51
70
|
assertions: e.expectations || e.assertions,
|
|
52
|
-
files: e.files
|
|
71
|
+
files: e.files,
|
|
72
|
+
should_trigger: e.should_trigger
|
|
53
73
|
};
|
|
54
74
|
});
|
|
55
75
|
mergedTasks.push(...mappedTasks);
|
|
56
76
|
}
|
|
57
77
|
if (mergedTasks.length === 0) {
|
|
58
|
-
|
|
78
|
+
const scope = evalFile !== undefined ? `${files[0]} in ${evalsDir}` : `any of the JSON files in ${evalsDir}`;
|
|
79
|
+
throw new ConfigError(`No evaluations found in ${scope}`);
|
|
59
80
|
}
|
|
60
81
|
return {
|
|
61
82
|
skill_name: mergedSkillName,
|
|
@@ -98,7 +98,7 @@ function boxLabel(key, value) {
|
|
|
98
98
|
* Always shown regardless of debug mode.
|
|
99
99
|
*/
|
|
100
100
|
export function renderRunHeader(config) {
|
|
101
|
-
const { command, skillName, agent, workspace, tasks, trials, maxAgents, timeoutMs, runDir, evalId } = config;
|
|
101
|
+
const { command, skillName, agent, workspace, tasks, trials, maxAgents, timeoutMs, runDir, evalId, evalFile } = config;
|
|
102
102
|
let timeoutStr = 'None';
|
|
103
103
|
if (timeoutMs && timeoutMs > 0) {
|
|
104
104
|
const timeoutSec = timeoutMs / 1000;
|
|
@@ -111,7 +111,12 @@ export function renderRunHeader(config) {
|
|
|
111
111
|
const dashes = '─'.repeat(BOX_INNER - titleLabel.length);
|
|
112
112
|
const top = chalk.gray('┌─ ') + chalk.bold(titleLabel) + ' ' + chalk.gray(dashes + '┐');
|
|
113
113
|
const bottom = chalk.gray('└' + '─'.repeat(BOX_INNER + 2) + '┘');
|
|
114
|
-
const
|
|
114
|
+
const filterParts = [];
|
|
115
|
+
if (evalFile !== undefined)
|
|
116
|
+
filterParts.push(evalFile);
|
|
117
|
+
if (evalId !== undefined)
|
|
118
|
+
filterParts.push(`eval #${evalId}`);
|
|
119
|
+
const commandPart = [command, ...filterParts].join(' · ');
|
|
115
120
|
const title = chalk.bold.cyan(skillName) + chalk.gray(` · ${commandPart}`);
|
|
116
121
|
const runLine = `${tasks} task${tasks !== 1 ? 's' : ''} · ${trials} trial${trials !== 1 ? 's' : ''} · agents ${maxAgents}`;
|
|
117
122
|
process.stdout.write('\n');
|
|
@@ -134,7 +139,11 @@ export function renderTriggerTable(report) {
|
|
|
134
139
|
const numTrials = metrics.numTrials || 1;
|
|
135
140
|
// Identify all skill versions present in the results
|
|
136
141
|
const skillVersions = results.length > 0 ? Object.keys(results[0].skillTrials) : ['local'];
|
|
142
|
+
// Only surface the polarity column when the suite actually contains negative evals.
|
|
143
|
+
const hasNegativeEvals = results.some(r => r.shouldTrigger === false);
|
|
137
144
|
const header = ['ID', 'Prompt'];
|
|
145
|
+
if (hasNegativeEvals)
|
|
146
|
+
header.push('Expect');
|
|
138
147
|
if (numTrials > 1) {
|
|
139
148
|
for (const version of skillVersions) {
|
|
140
149
|
header.push(`${version} Trials`, `${version} Rate`);
|
|
@@ -150,6 +159,9 @@ export function renderTriggerTable(report) {
|
|
|
150
159
|
for (const result of results) {
|
|
151
160
|
const promptSnippet = result.prompt.substring(0, 40) + (result.prompt.length > 40 ? '...' : '');
|
|
152
161
|
const row = [result.taskId.toString(), promptSnippet];
|
|
162
|
+
if (hasNegativeEvals) {
|
|
163
|
+
row.push(result.shouldTrigger === false ? chalk.cyan('no-trigger') : chalk.dim('trigger'));
|
|
164
|
+
}
|
|
153
165
|
for (const version of skillVersions) {
|
|
154
166
|
const trials = result.skillTrials[version] || [];
|
|
155
167
|
const p1Cell = formatPassAt1(trials);
|